diff --git a/docs/design/evidence/task-269-after-1440.png b/docs/design/evidence/task-269-after-1440.png new file mode 100644 index 0000000000..d9bc29a8bb Binary files /dev/null and b/docs/design/evidence/task-269-after-1440.png differ diff --git a/docs/design/evidence/task-269-after-390-zh.png b/docs/design/evidence/task-269-after-390-zh.png new file mode 100644 index 0000000000..045c371bdf Binary files /dev/null and b/docs/design/evidence/task-269-after-390-zh.png differ diff --git a/docs/design/evidence/task-269-after-390.png b/docs/design/evidence/task-269-after-390.png new file mode 100644 index 0000000000..0afe85bb08 Binary files /dev/null and b/docs/design/evidence/task-269-after-390.png differ diff --git a/docs/design/evidence/task-269-before-1440.png b/docs/design/evidence/task-269-before-1440.png new file mode 100644 index 0000000000..982c01c4a4 Binary files /dev/null and b/docs/design/evidence/task-269-before-1440.png differ diff --git a/docs/design/evidence/task-269-before-390.png b/docs/design/evidence/task-269-before-390.png new file mode 100644 index 0000000000..5d7a505f50 Binary files /dev/null and b/docs/design/evidence/task-269-before-390.png differ diff --git a/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts b/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts index 20963ea3ef..ac382c1bbb 100644 --- a/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts +++ b/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts @@ -43,6 +43,7 @@ const OPAQUE_PLACEHOLDERS = [ 'connector', 'user', 'workspace', 'channel', 'tool', 'tools', 'member', 'agents', 'os', // common.aboutPage receives a page title, currently Latin, not a count. 'adapter', 'title', + 'holder', ]; // The exception runs the safe way: a name that is usually opaque and is a number diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index a6e0a9aa72..41aecd7693 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1232,6 +1232,7 @@ }, "board": { "title": "Board", + "claimUntil": "{{holder}} holds this until {{time}}", "backToChat": "Back to chat", "newTask": "New task", "taskCount_one": "{{count}} task", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 099182c0eb..5f5c40ce98 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1225,6 +1225,7 @@ }, "board": { "title": "看板", + "claimUntil": "{{holder}} 已认领 · 至{{time}}", "backToChat": "返回聊天", "newTask": "新建任务", "taskCount_other": "{{count}}个任务", diff --git a/frontend/src/v2/__tests__/V2PodBoard.test.tsx b/frontend/src/v2/__tests__/V2PodBoard.test.tsx index 12b5ff69a4..cdd0d28b41 100644 --- a/frontend/src/v2/__tests__/V2PodBoard.test.tsx +++ b/frontend/src/v2/__tests__/V2PodBoard.test.tsx @@ -142,6 +142,51 @@ describe('V2PodBoard', () => { expect(taskReads).toBe(2); }); + test('shows the claim holder and local expiry, then hides the lease without a reload', async () => { + const NativeDateTimeFormat = Intl.DateTimeFormat; + const dateTimeFormatSpy = jest.spyOn(Intl, 'DateTimeFormat').mockImplementation((locale, options) => new NativeDateTimeFormat(locale, { + timeZone: 'America/Los_Angeles', + ...options, + })); + const now = new Date('2026-10-15T22:00:00.000Z'); + const expiresAt = new Date(now.getTime() + 60_000).toISOString(); + const task = { + taskId: 'TASK-269', title: 'Show the lease', status: 'claimed', + assignee: 'someone-else', claimedBy: 'forge-id', claimExpiresAt: expiresAt, updates: [], + }; + const holderFromUpdate = { + taskId: 'TASK-270', title: 'Renew the lease', status: 'claimed', + claimedBy: 'kai-id', claimExpiresAt: expiresAt, + updates: [{ text: 'Lease renewed', author: 'Kai', authorId: 'kai-id' }], + }; + jest.useFakeTimers({ now }); + axios.get.mockImplementation((url) => { + if (url.startsWith('/api/v1/tasks/')) return Promise.resolve({ data: { tasks: [task, holderFromUpdate] } }); + if (url.endsWith('/focus')) return Promise.resolve({ data: FOCUS }); + if (url.startsWith('/api/pods/')) return Promise.resolve({ data: { + name: 'My Workspace', members: [{ _id: 'forge-id', username: 'forge', displayName: 'Forge' }], + } }); + return Promise.resolve({ data: {} }); + }); + + try { + renderBoard(); + await act(async () => { await Promise.resolve(); }); + expect(new Intl.DateTimeFormat().resolvedOptions().timeZone).toBe('America/Los_Angeles'); + expect(screen.getByText('Forge holds this until 15:01')).toBeInTheDocument(); + expect(screen.getByText('Kai holds this until 15:01')).toBeInTheDocument(); + + act(() => { jest.advanceTimersByTime(59_999); }); + expect(screen.getByText('Forge holds this until 15:01')).toBeInTheDocument(); + act(() => { jest.advanceTimersByTime(1); }); + expect(screen.queryByText(/holds this until/)).not.toBeInTheDocument(); + expect(axios.get.mock.calls.filter(([url]) => url.startsWith('/api/v1/tasks/'))).toHaveLength(1); + } finally { + jest.useRealTimers(); + dateTimeFormatSpy.mockRestore(); + } + }); + test('renders four canonical columns and places alias statuses in them', async () => { renderBoard(); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 581df2ab5a..87fa690f1d 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -1134,6 +1134,13 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(title).toContain('overflow: hidden'); }); + test('a board claim lease reads as secondary status rather than a blue mention', () => { + expect(podBoard).toContain('className="v2-board__card-claim-lease"'); + const lease = ruleBody(v2, '.v2-board__card-claim-lease'); + expect(lease).toContain('font-size: 12px'); + expect(lease).toContain('color: var(--v2-text-secondary)'); + }); + test('craft-pass board action chips keep their 32px target and 12px type', () => { // These compact controls are repeated on every board card and in the // detail dialog. A declaration on the shared selector keeps both paths diff --git a/frontend/src/v2/components/V2PodBoard.tsx b/frontend/src/v2/components/V2PodBoard.tsx index 43b85fd5e2..ac032d54b2 100644 --- a/frontend/src/v2/components/V2PodBoard.tsx +++ b/frontend/src/v2/components/V2PodBoard.tsx @@ -27,10 +27,12 @@ interface BoardTask { title: string; status: string; assignee?: string | null; + claimedBy?: string | null; + claimExpiresAt?: string | null; notes?: string | null; prUrl?: string | null; updatedAt?: string; - updates?: Array<{ text: string; author: string; createdAt?: string }>; + updates?: Array<{ text: string; author: string; authorId?: string | null; createdAt?: string }>; } interface PodMember { @@ -134,6 +136,7 @@ const V2PodBoard: React.FC = () => { const connectionRef = useRef({ podId: null as string | null, wasConnected: false, hasConnected: false }); const [tasks, setTasks] = useState([]); + const [claimClock, setClaimClock] = useState(() => Date.now()); const [podName, setPodName] = useState(''); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(false); @@ -144,6 +147,28 @@ const V2PodBoard: React.FC = () => { const [creating, setCreating] = useState(false); const [createError, setCreateError] = useState(null); const [podMembers, setPodMembers] = useState([]); + + // Re-render at the next lease boundary even if no socket event arrives. + // A lapsed claim must stop looking held while the board stays open. + useEffect(() => { + const nextExpiry = tasks.reduce((soonest, task) => { + if (task.status !== 'claimed' || !task.claimedBy || !task.claimExpiresAt) return soonest; + const expiry = Date.parse(task.claimExpiresAt); + return Number.isFinite(expiry) && expiry > claimClock ? Math.min(soonest, expiry) : soonest; + }, Infinity); + if (!Number.isFinite(nextExpiry)) return undefined; + const timer = window.setTimeout(() => setClaimClock(Date.now()), Math.min(Math.max(0, nextExpiry - Date.now()), 2_147_483_647)); + return () => window.clearTimeout(timer); + }, [tasks, claimClock]); + + const claimHolderName = useCallback((task: BoardTask): string => { + const member = podMembers.find((item) => memberId(item) === task.claimedBy); + if (member) return memberLabel(member); + // The claim route records the claimant's User id and resolved author on + // the update. Match identity rather than the wording of the update text. + const holderUpdate = task.updates?.slice().reverse().find((update) => update.authorId === task.claimedBy); + return holderUpdate?.author || task.claimedBy || ''; + }, [podMembers]); const [focusRead, setFocusRead] = useState(null); const [focusLoading, setFocusLoading] = useState(true); const [focusError, setFocusError] = useState(false); @@ -607,6 +632,15 @@ const V2PodBoard: React.FC = () => { {task.assignee && ( @{task.assignee} )} + {task.status === 'claimed' && task.claimedBy && task.claimExpiresAt + && Date.parse(task.claimExpiresAt) > claimClock && ( + + {t('board.claimUntil', { + holder: claimHolderName(task), + time: new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(new Date(task.claimExpiresAt)), + })} + + )}
{col.moves.map((move) => ( diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 9434e14c30..55de96ed35 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -8686,6 +8686,12 @@ body.modern-ui.v2-canvas { font-weight: 500; } +.v2-board__card-claim-lease { + font-size: 12px; + color: var(--v2-text-secondary); + font-weight: 500; +} + .v2-board__card-actions { display: flex; gap: 6px;