From 26260ce45c3ffdcda471cd1458d189152247a86c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:14:44 -0700 Subject: [PATCH 1/3] feat(chat): fold a run of claim lines into one summary row (TASK-692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two or more adjacent task-claimed system lines render as one row, "N claims · TASK-X to Y". The row is a button with aria-expanded; opening it shows the claim lines in order. A claim that roots a thread stays a plain message so its card still renders, and any other item ends a run. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: Polish Agent-Model: claude/claude-haiku-5-5/default --- frontend/src/i18n/locales/en.json | 3 + frontend/src/i18n/locales/zh-CN.json | 3 + frontend/src/v2/__tests__/claimRun.test.ts | 57 +++++++++++++++++++ .../v2/__tests__/v2-layout-invariants.test.ts | 10 ++++ frontend/src/v2/components/V2Thread.tsx | 2 + .../src/v2/components/V2ThreadMessages.tsx | 32 +++++++++++ frontend/src/v2/utils/threadView.ts | 33 ++++++++++- frontend/src/v2/v2.css | 31 ++++++++++ 8 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 frontend/src/v2/__tests__/claimRun.test.ts diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 25680c0b90..a04e23de85 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -561,6 +561,9 @@ "systemActions": { "taskClaimedVerb": "claimed" }, + "claimRun": { + "label": "{{count}} claims · {{first}} to {{last}}" + }, "starts": { "label": "Ways to start", "openByo": "Open Bring your own", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 3ffafea633..98c7a5f652 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -561,6 +561,9 @@ "systemActions": { "taskClaimedVerb": "认领了" }, + "claimRun": { + "label": "{{count}} 次认领 · {{first}} 至 {{last}}" + }, "starts": { "label": "开始方式", "openByo": "去接入自己的智能体", diff --git a/frontend/src/v2/__tests__/claimRun.test.ts b/frontend/src/v2/__tests__/claimRun.test.ts new file mode 100644 index 0000000000..a589ce1eed --- /dev/null +++ b/frontend/src/v2/__tests__/claimRun.test.ts @@ -0,0 +1,57 @@ +import { buildThreadView } from '../utils/threadView'; + +const claim = (id: number, taskId: string, opts: Partial = {}) => ({ + id: String(id), + pod_id: 'pod-1', + user_id: 'u1', + content: '', + message_type: 'system', + created_at: `2026-10-31T10:0${id % 10}:00Z`, + thread_root_id: opts.thread_root_id ?? null, + payload: { kind: 'system-action', action: 'task-claimed', taskId, actorName: 'Scout' }, +} as any); + +const text = (id: number) => ({ + id: String(id), + pod_id: 'pod-1', + user_id: 'u2', + content: `m${id}`, + message_type: 'text', + created_at: `2026-10-31T10:0${id % 10}:00Z`, + thread_root_id: null, +} as any); + +const state = (ids: number[]) => new Map( + ids.map((i) => [String(i), { threadRootId: i, following: null, collapsed: true }]), +) as any; + +describe('consecutive claim lines fold into one summary item', () => { + test('two adjacent claims become one claims item, in order', () => { + const items = buildThreadView([claim(1, 'TASK-674'), claim(2, 'TASK-675')], state([])); + expect(items.map((i) => i.kind)).toEqual(['claims']); + expect(items[0]).toMatchObject({ kind: 'claims', key: '1' }); + expect((items[0] as any).messages.map((m: any) => m.id)).toEqual(['1', '2']); + }); + + test('a single claim stays a plain message', () => { + const items = buildThreadView([text(1), claim(2, 'TASK-674'), text(3)], state([])); + expect(items.map((i) => i.kind)).toEqual(['message', 'message', 'message']); + }); + + test('a normal message between claims ends the run', () => { + const items = buildThreadView( + [claim(1, 'TASK-674'), claim(2, 'TASK-675'), text(3), claim(4, 'TASK-676'), claim(5, 'TASK-677')], + state([]), + ); + expect(items.map((i) => i.kind)).toEqual(['claims', 'message', 'claims']); + }); + + test('a claim that roots a thread stays a message so its card still renders', () => { + const items = buildThreadView( + [claim(1, 'TASK-674'), claim(2, 'TASK-675'), claim(3, 'TASK-676', {}), claim(4, 'TASK-677', { thread_root_id: 3 })], + state([3]), + ); + expect(items.map((i) => i.kind)).toEqual(['claims', 'message', 'card']); + expect(items[1]).toMatchObject({ kind: 'message', message: { id: '3' } }); + }); +}); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 26527a609a..4b50e97604 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -5226,3 +5226,13 @@ describe('v2 board on a phone (TASK-576)', () => { expect(page).toContain(''); }); }); + +describe('claim run summary (TASK-692)', () => { + test('the toggle is a real button styled as a control, with its state on aria-expanded', () => { + const css = fs.readFileSync(path.join(__dirname, '../v2.css'), 'utf8'); + const toggle = css.match(/\.v2-root button\.v2-claim-run__toggle \{[^}]*\}/); + expect(toggle).not.toBeNull(); + expect(toggle![0]).toMatch(/box-shadow: inset 0 0 0 1px var\(--v2-border\)/); + expect(css).toMatch(/\.v2-claim-run__toggle\[aria-expanded='true'\]::after/); + }); +}); diff --git a/frontend/src/v2/components/V2Thread.tsx b/frontend/src/v2/components/V2Thread.tsx index 22495b787c..e9c6778cbc 100644 --- a/frontend/src/v2/components/V2Thread.tsx +++ b/frontend/src/v2/components/V2Thread.tsx @@ -498,6 +498,8 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in // Only rows that render with their starts count: a flat message or a thread // root. A reply folded into a thread card renders without starts. const byoOfferShown = useMemo(() => threadView.some((item) => { + // A folded claim run carries no starts, so only a flat message or a root can offer one. + if (item.kind === 'claims') return false; const id = item.kind === 'message' ? String(item.message.id) : item.rootId; return (startsByMessageId.get(id) || []).some((start) => start.id === 'open-byo'); }), [threadView, startsByMessageId]); diff --git a/frontend/src/v2/components/V2ThreadMessages.tsx b/frontend/src/v2/components/V2ThreadMessages.tsx index a480979cf0..0a07b554b3 100644 --- a/frontend/src/v2/components/V2ThreadMessages.tsx +++ b/frontend/src/v2/components/V2ThreadMessages.tsx @@ -167,6 +167,16 @@ const V2ThreadMessages: React.FC = ({ // row is written to match on each open/close (it still drives the wake // path), but a fresh mount always shows chips. const [openRoots, setOpenRoots] = useState>(() => new Set()); + // A run of claim lines folds to one summary row; this is the open ones. + const [openClaimRuns, setOpenClaimRuns] = useState>(() => new Set()); + const toggleClaimRun = useCallback((key: string) => { + setOpenClaimRuns((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); const setOpen = useCallback((rootId: string, open: boolean) => { setOpenRoots((current) => { const next = new Set(current); @@ -362,6 +372,28 @@ const V2ThreadMessages: React.FC = ({ ); }; + if (item.kind === 'claims') { + const open = openClaimRuns.has(item.key); + const first = item.messages[0].payload?.taskId ?? ''; + const last = item.messages[item.messages.length - 1].payload?.taskId ?? ''; + return ( +
+ + {open && item.messages.map((message, claimIndex) => renderMessage( + message, + claimIndex === 0 ? view[index - 1] : { kind: 'message', message: item.messages[claimIndex - 1] }, + ))} +
+ ); + } + if (item.kind === 'message') { const next = view[index + 1]; // A root with a live thread renders INSIDE its band (below), so the diff --git a/frontend/src/v2/utils/threadView.ts b/frontend/src/v2/utils/threadView.ts index e4cf8e8196..a064719329 100644 --- a/frontend/src/v2/utils/threadView.ts +++ b/frontend/src/v2/utils/threadView.ts @@ -12,6 +12,7 @@ import { V2ThreadParticipant } from '../components/V2ThreadCard'; */ export type ThreadViewItem = | { kind: 'message'; message: V2Message } + | { kind: 'claims'; key: string; messages: V2Message[] } | { kind: 'card'; rootId: string; @@ -26,6 +27,36 @@ const rootOf = (m: V2Message): string | null => ( m.thread_root_id === null || m.thread_root_id === undefined ? null : String(m.thread_root_id) ); const whenOf = (m: V2Message): string => String(m.created_at || m.createdAt || ''); +const isClaimLine = (m: V2Message): boolean => ( + (m.message_type === 'system' || m.messageType === 'system') + && m.payload?.kind === 'system-action' + && m.payload.action === 'task-claimed' +); + +/** + * Collapse each run of two or more consecutive claim lines into one summary + * item. A claim that roots a thread stays a message so its card still renders, + * and any other item ends the run, so only adjacent claims fold together. + */ +const groupClaimRuns = (items: ThreadViewItem[]): ThreadViewItem[] => { + const out: ThreadViewItem[] = []; + let run: V2Message[] = []; + const flush = () => { + if (run.length >= 2) out.push({ kind: 'claims', key: idOf(run[0]), messages: run }); + else run.forEach((message) => out.push({ kind: 'message', message })); + run = []; + }; + items.forEach((item, index) => { + if (item.kind === 'message' && isClaimLine(item.message) && items[index + 1]?.kind !== 'card') { + run.push(item.message); + return; + } + flush(); + out.push(item); + }); + flush(); + return out; +}; /** * Keep replies that were visible before their root arrived flat for the life @@ -115,7 +146,7 @@ export const buildThreadView = ( replies, }); } - return out; + return groupClaimRuns(out); }; export default buildThreadView; diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 15d0f216cb..3f03cdb00b 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -2904,6 +2904,37 @@ body.modern-ui.v2-canvas { font: 400 11px/16px var(--v2-font-mono); } +/* A run of claim lines folds to one row. The toggle is a control, so it + carries the same chevron pattern as the long-message Show more. */ +.v2-root button.v2-claim-run__toggle { + display: inline-flex; + align-items: center; + gap: 5px; + height: 24px; + margin: 4px 0; + padding: 0 8px; + border: 0; + border-radius: 7px; + background: var(--v2-surface); + color: var(--v2-text-secondary); + box-shadow: inset 0 0 0 1px var(--v2-border); + font: 500 12px/1 var(--v2-font); + cursor: pointer; +} +.v2-root button.v2-claim-run__toggle:hover { background: var(--v2-surface-hover); } +.v2-root button.v2-claim-run__toggle:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--v2-accent-text); } +.v2-claim-run__toggle::after { + content: ''; + width: 6px; + height: 6px; + margin-left: 2px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(45deg) translateY(-2px); + transition: transform 160ms; +} +.v2-claim-run__toggle[aria-expanded='true']::after { transform: rotate(225deg) translateY(-2px); } + /* §3.8 system event card — replaces the regular author/avatar bubble for commonly-bot announcements like "Pixel and codex started a DM". One-row, chrome-light, with a primary CTA that navigates internally. */ From 2f26cb10bf62250f14402e1b89c41b3f57dacbd5 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:40:56 -0700 Subject: [PATCH 2/3] fix(i18n): attach the zh-CN claim-run count and declare its last placeholder (TASK-692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zh-CN spacing guard rejects a space between a numeral and the hanzi that follows it, and it requires every interpolation to be declared. The claim-run label now reads "{{count}}次认领", and `last` joins the opaque placeholders beside `first`, since both are Latin task ids. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: Polish Agent-Model: claude/claude-haiku-5-5/default --- frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts | 2 ++ frontend/src/i18n/locales/zh-CN.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts b/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts index a4e9ef7157..fb16ab2982 100644 --- a/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts +++ b/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts @@ -52,6 +52,8 @@ const OPAQUE_PLACEHOLDERS = [ 'id', // podChat.deliveryHintOthers names the agent that was woken. 'woken', + // podChat.claimRun.label names the first and last task of a claim run (Latin task ids). + 'last', ]; // The exception runs the safe way: a name that is usually opaque and is a number diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 98c7a5f652..26a68897b4 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -562,7 +562,7 @@ "taskClaimedVerb": "认领了" }, "claimRun": { - "label": "{{count}} 次认领 · {{first}} 至 {{last}}" + "label": "{{count}}次认领 · {{first}} 至 {{last}}" }, "starts": { "label": "开始方式", From 848780d1e0197da47b5148a6192002b014e8dcd0 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:46:09 -0700 Subject: [PATCH 3/3] test(chat): cover the claim-run summary control in a rendered thread (TASK-692) The grouping had unit tests but nothing rendered the toggle. This checks the label with the count and first and last task ids, aria-expanded starting false with no rows, ordered rows after activation, collapse on a second activation, and Enter and Space on the native button. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: Polish Agent-Model: claude/claude-haiku-5-5/default --- frontend/src/v2/__tests__/V2ClaimRun.test.tsx | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 frontend/src/v2/__tests__/V2ClaimRun.test.tsx diff --git a/frontend/src/v2/__tests__/V2ClaimRun.test.tsx b/frontend/src/v2/__tests__/V2ClaimRun.test.tsx new file mode 100644 index 0000000000..7d08c2b4dc --- /dev/null +++ b/frontend/src/v2/__tests__/V2ClaimRun.test.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { act, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import i18n from '../../i18n'; +import V2ThreadMessages from '../components/V2ThreadMessages'; +import { buildThreadView } from '../utils/threadView'; + +jest.mock('../../context/AuthContext', () => ({ + useAuth: () => ({ currentUser: { username: 'viewer' } }), +})); +jest.mock('../hooks/useV2Api', () => ({ + useV2Api: () => ({ get: jest.fn(), post: jest.fn(), patch: jest.fn(), del: jest.fn() }), +})); + +const claim = (id: number, taskId: string) => ({ + id: String(id), + pod_id: 'pod-1', + user_id: 'u1', + content: '', + message_type: 'system', + created_at: `2026-10-31T10:0${id}:00.000Z`, + thread_root_id: null, + user: { username: 'scout' }, + payload: { kind: 'system-action', action: 'task-claimed', taskId, actorName: 'Scout' }, +}); + +const claims = [claim(1, 'TASK-674'), claim(2, 'TASK-675'), claim(3, 'TASK-676'), claim(4, 'TASK-678')]; + +const renderClaims = (messages = claims) => { + const threadView = buildThreadView(messages as any, new Map()); + return render( + + + , + ); +}; + +// The claim rows render as V2MessageRow system lines with a task id each. +const claimRowTaskIds = (container: HTMLElement) => Array.from( + container.querySelectorAll('.v2-system-action-line__object'), +).map((node) => node.textContent); + +describe('claim run summary control (TASK-692)', () => { + afterEach(() => i18n.changeLanguage('en')); + + test('shows the count and the first and last task ids, collapsed by default', () => { + const { container } = renderClaims(); + const toggle = screen.getByRole('button', { name: '4 claims · TASK-674 to TASK-678' }); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(claimRowTaskIds(container)).toEqual([]); + }); + + test('clicking expands the claim rows in order and clicking again collapses them', () => { + const { container } = renderClaims(); + const toggle = screen.getByRole('button', { name: /4 claims/ }); + act(() => toggle.click()); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(claimRowTaskIds(container)).toEqual(['TASK-674', 'TASK-675', 'TASK-676', 'TASK-678']); + act(() => toggle.click()); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(claimRowTaskIds(container)).toEqual([]); + }); + + test('Enter and Space on the focused toggle expand and collapse it', async () => { + const user = userEvent.setup(); + const { container } = renderClaims(); + const toggle = screen.getByRole('button', { name: /4 claims/ }); + toggle.focus(); + await user.keyboard('{Enter}'); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(claimRowTaskIds(container)).toHaveLength(4); + await user.keyboard(' '); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(claimRowTaskIds(container)).toEqual([]); + }); + + test('the expanded rows sit inside the summary group, below the toggle', () => { + const { container } = renderClaims(); + act(() => screen.getByRole('button', { name: /4 claims/ }).click()); + const group = container.querySelector('.v2-claim-run') as HTMLElement; + expect(within(group).getByRole('button', { name: /4 claims/ })).toBeInTheDocument(); + expect(group.querySelectorAll('.v2-system-action-line')).toHaveLength(4); + }); +});