From 66fa96c3da61702544a8612b025afd95c2bf0962 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:45:51 -0800 Subject: [PATCH 1/4] feat(activity): build Activity to the approved d215 signal frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page is now the frames Sam approved on 11-03 (#s-activity-signal): - the head is the Connectors head: title, one mono count, one Today/7 days segment and one All pods select (the two pill rows and the (i) marks go); - the composer is a one-line row that opens on focus, ringed with var(--v2-focus-ring) instead of the hard-coded 10% cobalt halo; - Needs you is one hairline-split list; the decision is the only open row and its choices are keys inside it; secondary acts sit in a hover cluster at desktop and behind a ⋯ menu on a phone; - Moved forward drops its table head for a mono pod sub-row and a time column; - the ruled line drops its leading ✓ (en, zh-CN and the slop-tells allowlist). Activity's rules move out of v2.css into components/V2ActivityPage.css. Data, routes, filters, scope queries and API calls are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DT6tbhKm6UhsmFhLk9 --- docs/design/ai-slop-tells.md | 15 +- frontend/src/i18n/locales/en.json | 19 +- frontend/src/i18n/locales/zh-CN.json | 19 +- .../src/v2/__tests__/V2ActivityPage.test.tsx | 222 +++- .../src/v2/__tests__/signalAccent.test.ts | 3 +- .../v2/__tests__/v2-layout-invariants.test.ts | 506 ++------ .../src/v2/__tests__/v2-slop-tells.test.ts | 3 - frontend/src/v2/components/V2ActivityPage.css | 340 ++++++ frontend/src/v2/components/V2ActivityPage.tsx | 1070 +++++++++------- frontend/src/v2/icons/glyphs.tsx | 10 +- frontend/src/v2/v2.css | 1083 +---------------- 11 files changed, 1255 insertions(+), 2035 deletions(-) create mode 100644 frontend/src/v2/components/V2ActivityPage.css diff --git a/docs/design/ai-slop-tells.md b/docs/design/ai-slop-tells.md index 2aa51becf..a7c0ab57b 100644 --- a/docs/design/ai-slop-tells.md +++ b/docs/design/ai-slop-tells.md @@ -57,8 +57,7 @@ listed again. line, so it excuses one literal, never the rest of the file. *Exception:* keyboard notation (`⌘↵`), the reply-aim `↳`, the DM pair `↔`; user content (the reaction picker); the parser for the kernel's `🤝` DM - event; masked-token bullets; `↵` in the marketing demo. Dated 2026-11-03: - `activity.decision.ruled`, left to the Activity redesign owner. + event; masked-token bullets; `↵` in the marketing demo. 7. **A sparkle "AI" icon.** It is the category's cliché, and it names the technology rather than the act. *Check:* no `AutoAwesome`, `AutoFixHigh`, `Sparkle(s)` or `✨` in v2 @@ -101,9 +100,9 @@ any live app surface. It found one chromatic glow, the logo key, which is drawn. | 3 | Frosted glass | `landing/v2-landing.css:87` | the marketing sticky header only | acceptable | guard | | 4 | Gradients | 30+ key rules | all d215 a/b key pairs; 4 named masks, track and hatch | acceptable | guard | | 5 | Glow | `v2.css` `.v2-rail__brand-icon`, `primitives.css` `.v2p-b-c` | artboard values (`d215/index.html:167`, `:226`) | acceptable | guard | -| 5 | Focus halo | `v2.css` `.v2-activity__compose:focus-within` | hard-coded `rgba(29,63,209,.10)` 4px, not `--v2-focus-ring`; on the dark ground it is all but invisible (`rgb(138,162,255)` 4px ring with the token, measured on a local build) | real | proposal for the Activity/Artifacts redesign owner | +| 5 | Focus halo | `v2.css` `.v2-activity__compose:focus-within` | hard-coded `rgba(29,63,209,.10)` 4px, not `--v2-focus-ring`; on the dark ground it is all but invisible (`rgb(138,162,255)` 4px ring with the token, measured on a local build) | real | fixed by the Activity build: the open composer's ring is `var(--v2-focus-ring)` (`components/V2ActivityPage.css`) | | 6 | Glyph icons | `en`/`zh-CN`: `inviteModal.connectOwnAgent`, `adminUsers.registered.analyticsLink`, `adminAnalytics.userAdminLink` (`→`), `approvalCard.approved` (`✓`) | strings | real | glyphs dropped from the copy | -| 6 | Glyph icons | `activity.decision.ruled` (`✓ {{by}} ruled`), shown on Activity and the thread decision card | string | real | proposal for the Activity/Artifacts redesign owner; allowlisted by name and date in the guard | +| 6 | Glyph icons | `activity.decision.ruled` (`✓ {{by}} ruled`), shown on Activity and the thread decision card | string | real | fixed by the Activity build: the `✓` left both locales and the guard's allowlist | | 6 | Glyph icons | `V2PodBoard.tsx` Follow `✓`, `V2LangSwitch.tsx` `✓`, `V2FirstRunHero.tsx` `↗`, `V2Lightbox.tsx` `←`/`→`, `V2SettingsFrame.tsx` `✎` | JSX text | real | SVG glyphs from `icons/glyphs.tsx` (two chevrons added) | | 6 | Close and chevron glyphs | `×` on the close buttons in `V2Lightbox`, `V2PodBoard` (focus editor and new task, both with no accessible name), `V2Composer` (reply aim), `V2InviteModal`, `V2CatchUpStrip` and `V2AvatarCropDialog`; `›` in `V2SettingsFrame` | JSX text | real | `ShellGlyph` `close` (added) and `chevron-right`; the two board buttons gained `aria-label` from `common.close` | | 6 | Emoji | `V2MessageRow.tsx` reply quote | quoted uploads rendered as `📎 name` | real | an `aria-hidden` SVG file glyph before the name | @@ -129,11 +128,9 @@ floors burn-down allowlist under a heading dated 2026-11-03, not fixed here. ### Proposals for the Activity/Artifacts redesign owner -- `.v2-activity__compose:focus-within` should use `var(--v2-focus-ring)`. The - hard-coded 10% cobalt halo barely shows in dark mode. -- Drop the `✓` from `activity.decision.ruled` (en and zh-CN), and the - allowlist entry in `v2-slop-tells.test.ts` with it. -- The Class A note on "Needs you" below. +- Done for Activity (the d215 build): the composer ring is the token, the `✓` + left `activity.decision.ruled`, and "Needs you" is one hairline-split list + (the Class A note below). ### Proposals for UX Lead (Class A, not made here) diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index f2c62c480..b534b3578 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -232,8 +232,6 @@ }, "activity": { "title": "Activity", - "subtitle": "what needs you, then what moved", - "about": "About activity", "head": { "needsYou_one": "{{count}} needs you", "needsYou_other": "{{count}} need you", @@ -248,11 +246,11 @@ }, "podScopeLabel": "Pod scope", "allPods": "All pods", - "morePods": "more ▾", "loadFailed": "Activity could not be loaded. Try again.", "openThread": "Open thread", "open": "Open", "openPod": "Open pod", + "moreActions": "More actions", "openBoard": "Open board", "compose": { "eyebrow": "Start a conversation", @@ -264,7 +262,7 @@ "working": "Sending…", "actionFailed": "Your message could not be sent. Try again.", "label": "Tell your agents", - "hint": "⌘↵ to send" + "hint": "⌘↵" }, "reply": { "open": "Reply", @@ -278,12 +276,10 @@ "updatesCount_one": "{{count}} update", "updatesCount_other": "{{count}} updates", "needsYou": { - "about": "About needs you", "eyebrow": "Decision queue", "title": "Needs you", + "order": "oldest first", "description": "Direct mentions, approvals, and active decisions that need your call.", - "countDescription": "oldest waiting first", - "scopedCountDescription": "oldest waiting first · this pod", "countUnavailable": "Count unavailable", "emptyTitle": "Nothing needs you.", "emptyDescription": "New mentions and approval requests appear here when they need a response.", @@ -317,7 +313,7 @@ "other": "Other…", "otherPlaceholder": "Write your ruling…", "sendOther": "Send ruling", - "ruled": "✓ {{by}} ruled: {{value}}", + "ruled": "{{by}} ruled: {{value}}", "ruledShort": "ruled", "actionFailed": "That ruling could not be saved. Try again." }, @@ -353,9 +349,8 @@ } }, "movedForward": { - "about": "About moved forward", "title": "Moved forward", - "description": "grouped by pod · newest first", + "order": "newest first", "empty": "Nothing yet today.", "emptyWeek": "Nothing moved in the last 7 days.", "more": "{{count}} more in {{pod}}", @@ -366,8 +361,8 @@ "footer": "Open the source thread to act; this recap leaves when the underlying fact changes.", "getStarted": { "title": "Get started", - "kicker_one": "1 step · until your first ask arrives", - "kicker_other": "{{count}} steps · until your first ask arrives", + "kicker_one": "1 step", + "kicker_other": "{{count}} steps", "checking": "checking…", "hire": { "title": "Hire your first agent", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index ddf38bb44..39b719d10 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -231,8 +231,6 @@ }, "activity": { "title": "动态", - "subtitle": "需要你处理的事项,以及刚刚推进的工作", - "about": "关于动态", "head": { "needsYou_one": "{{count}}项需要你处理", "needsYou_other": "{{count}}项需要你处理", @@ -247,11 +245,11 @@ }, "podScopeLabel": "Pod 范围", "allPods": "所有 Pod", - "morePods": "更多 ▾", "loadFailed": "无法加载动态,请重试。", "openThread": "打开讨论", "open": "打开", "openPod": "打开 pod", + "moreActions": "更多操作", "openBoard": "打开看板", "compose": { "eyebrow": "发起对话", @@ -263,7 +261,7 @@ "working": "发送中…", "actionFailed": "无法发送消息,请重试。", "label": "告诉你的智能体", - "hint": "⌘↵ 发送" + "hint": "⌘↵" }, "reply": { "open": "回复", @@ -277,12 +275,10 @@ "updatesCount_one": "{{count}}条更新", "updatesCount_other": "{{count}}条更新", "needsYou": { - "about": "关于需要你处理的事项", "eyebrow": "决策队列", "title": "需要你处理", + "order": "最早在前", "description": "这里只显示需要你回应的直接提及、审批和待决事项。", - "countDescription": "最早等待的在前", - "scopedCountDescription": "最早等待的在前 · 此 Pod", "countUnavailable": "数量暂不可用", "emptyTitle": "没有需要你处理的事项。", "emptyDescription": "需要回复的提及或审批请求会显示在这里。", @@ -316,7 +312,7 @@ "other": "其他…", "otherPlaceholder": "写下你的裁定…", "sendOther": "发送裁定", - "ruled": "✓ {{by}} 已裁定:{{value}}", + "ruled": "{{by}} 已裁定:{{value}}", "ruledShort": "已裁定", "actionFailed": "无法保存该裁定,请重试。" }, @@ -352,9 +348,8 @@ } }, "movedForward": { - "about": "关于推进的工作", "title": "推进的工作", - "description": "按 Pod 分组 · 最新在前", + "order": "最新在前", "empty": "今天还没有更新。", "emptyWeek": "最近7天没有新进展。", "more": "{{pod}} 还有{{count}}项", @@ -365,8 +360,8 @@ "footer": "请在源讨论中行动;底层事实变化后,这份回顾会自动离开。", "getStarted": { "title": "开始使用", - "kicker_one": "还有1步 · 直到你的第一个请求到来", - "kicker_other": "还有{{count}}步 · 直到你的第一个请求到来", + "kicker_one": "还有1步", + "kicker_other": "还有{{count}}步", "checking": "正在检查…", "hire": { "title": "雇用你的第一个智能体", diff --git a/frontend/src/v2/__tests__/V2ActivityPage.test.tsx b/frontend/src/v2/__tests__/V2ActivityPage.test.tsx index 981a6bf0c..6ab7a13c1 100644 --- a/frontend/src/v2/__tests__/V2ActivityPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ActivityPage.test.tsx @@ -70,6 +70,12 @@ const renderPage = () => render( , ); +// The pod scope is one select key (d215 activity): open it, then pick the pod. +const chooseScope = (name: string) => { + fireEvent.click(document.querySelector('.v2-activity__scope-button') as HTMLElement); + fireEvent.click(within(screen.getByRole('listbox', { name: 'Pod scope' })).getByRole('option', { name, exact: true })); +}; + const renderPageWithAuth = (currentUser: { _id: string } | null) => render( @@ -180,7 +186,7 @@ describe('V2ActivityPage', () => { })); }); - test('preserves authored decision order and makes only the first option primary', async () => { + test('preserves authored decision order; every choice is a raised key inside the row (d215 activity)', async () => { const authoredOrder = { ...decisionQueue, items: [{ @@ -197,12 +203,17 @@ describe('V2ActivityPage', () => { const first = await screen.findByRole('button', { name: 'Rule: Hold for review' }); const second = screen.getByRole('button', { name: 'Rule: Ship now (Recommended)' }); - expect(first).toHaveClass('v2-activity__option--primary'); - expect(second).not.toHaveClass('v2-activity__option--primary'); - expect(second).not.toHaveTextContent('Recommended'); - expect(within(second.parentElement as HTMLElement).getByText('Recommended')).toBeInTheDocument(); + // No cobalt primary: the frame draws every option as the same raised key, Other… quiet. + for (const key of [first, second]) expect(key).toHaveClass('v2p-b', 'v2p-b-s'); + expect(screen.getByRole('button', { name: 'Other…' })).toHaveClass('v2p-b-g'); + // "recommended" rides inside its own key, in mono, not as a caption under it. + expect(second).toHaveTextContent('Recommended'); expect(first).not.toHaveTextContent('Recommended'); expect(first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + // The keys sit in the decision row itself, which is the one open row. + const row = first.closest('article') as HTMLElement; + expect(row).toHaveClass('v2-activity__queue-row--open'); + expect(row).toContainElement(second); }); test('keeps long decision labels and descriptions readable when the recommendation is second', async () => { @@ -238,15 +249,11 @@ describe('V2ActivityPage', () => { const first = await screen.findByRole('button', { name: 'Rule: Keep account-wide cap' }); const second = screen.getByRole('button', { name: 'Rule: Change to squad-drafted only (Recommended)' }); const third = screen.getByRole('button', { name: 'Rule: Use a rolling seven-day cap for all sends' }); - expect(first).toHaveClass('v2-activity__option--primary'); - expect(second).not.toHaveClass('v2-activity__option--primary'); - expect(third).not.toHaveClass('v2-activity__option--primary'); expect(first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(second.compareDocumentPosition(third) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(screen.getByText('Recommended default: retain five total X posts per day, including casual replies. Simple to count and limits total volume; casual use reduces squad capacity.')).toBeInTheDocument(); expect(screen.getByText('Change the current rule to five squad-drafted sends per day, excluding Sam’s casual replies. Preserves squad capacity but permits more than five total posts and requires identifying draft origin.')).toBeInTheDocument(); - expect(second).not.toHaveTextContent('Recommended'); - expect(within(second.parentElement as HTMLElement).getByText('Recommended')).toBeInTheDocument(); + expect(second).toHaveTextContent('Recommended'); expect(second).toHaveAttribute('aria-describedby', expect.stringContaining('description')); }); @@ -298,7 +305,7 @@ describe('V2ActivityPage', () => { renderPage(); await screen.findByText('Review requested'); - fireEvent.click(screen.getByRole('button', { name: '7 days' })); + fireEvent.click(screen.getByRole('radio', { name: '7 days' })); // toHaveBeenCalledWith, not Last: the decision-queue request now fires // alongside recap, so "last call" is no longer the recap by construction. await waitFor(() => expect(mockGet).toHaveBeenCalledWith('/api/activity/recap', expect.objectContaining({ @@ -353,7 +360,7 @@ describe('V2ActivityPage', () => { mockGet.mockImplementation(mockFor([], [])); const first = renderPage(); expect(await screen.findByRole('heading', { name: 'Get started' })).toBeInTheDocument(); - expect(await screen.findByText('3 steps · until your first ask arrives')).toBeInTheDocument(); + expect(await screen.findByText('3 steps')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Hire an agent' })).toHaveClass('v2-activity__start-cta--current'); expect(screen.getByRole('button', { name: 'Open Launch pod' })).not.toHaveClass('v2-activity__start-cta--current'); expect(screen.getByRole('button', { name: 'Add a connector' })).toBeInTheDocument(); @@ -374,7 +381,7 @@ describe('V2ActivityPage', () => { const introSeat = { name: 'scout', displayName: 'Scout', lastActiveAt: '2026-09-08T10:00:00.000Z', lastMessage: { content: 'Hi, I am Scout.', createdAt: '2026-09-08T10:00:01.000Z' } }; mockGet.mockImplementation(mockFor([introSeat, { name: 'hosted-smoke', internal: true }], [], false)); const second = renderPage(); - expect(await screen.findByText('2 steps · until your first ask arrives')).toBeInTheDocument(); + expect(await screen.findByText('2 steps')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Hire an agent' })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Open Launch pod' })).toHaveClass('v2-activity__start-cta--current'); expect(screen.getByRole('textbox', { name: i18n.t('activity.compose.placeholder') })).toBeInTheDocument(); @@ -424,7 +431,7 @@ describe('V2ActivityPage', () => { await waitFor(() => expect(screen.queryByTestId('activity-start-pending')).not.toBeInTheDocument()); const card = screen.getByRole('heading', { name: 'Get started' }).closest('section') as HTMLElement; expect(card).not.toHaveAttribute('aria-busy'); - expect(within(card).getByText('2 steps · until your first ask arrives')).toBeInTheDocument(); + expect(within(card).getByText('2 steps')).toBeInTheDocument(); expect(within(card).getByText("Say hello in the pod where it lives. If it doesn't answer, @mention it.")).toBeInTheDocument(); first.unmount(); @@ -455,7 +462,7 @@ describe('V2ActivityPage', () => { ? { items: [], count: 0, countsByPod: {} } : { ...recap, needsYou: [], agents: [], board: [] } })); renderPage(); expect(await screen.findByText('Nothing yet today.')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: '7 days' })); + fireEvent.click(screen.getByRole('radio', { name: '7 days' })); expect(await screen.findByText('Nothing moved in the last 7 days.')).toBeInTheDocument(); expect(screen.queryByText('Nothing yet today.')).not.toBeInTheDocument(); @@ -475,7 +482,7 @@ describe('V2ActivityPage', () => { return Promise.resolve({ data: empty }); }); const first = renderPage(); - expect(await screen.findByText('1 step · until your first ask arrives')).toBeInTheDocument(); + expect(await screen.findByText('1 step')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Open Launch pod' })).toHaveClass('v2-activity__start-cta--current'); first.unmount(); @@ -489,7 +496,7 @@ describe('V2ActivityPage', () => { return Promise.resolve({ data: empty }); }); renderPage(); - expect(await screen.findByText('1 step · until your first ask arrives')).toBeInTheDocument(); + expect(await screen.findByText('1 step')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Open Launch pod' })); expect(screen.getByTestId('current-path')).toHaveTextContent('/v2/pods/pod-1'); }); @@ -505,7 +512,7 @@ describe('V2ActivityPage', () => { return Promise.resolve({ data: empty }); }); renderPage(); - expect(await screen.findByText('1 step · until your first ask arrives')).toBeInTheDocument(); + expect(await screen.findByText('1 step')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Open Launch pod' })); expect(screen.getByTestId('current-path')).toHaveTextContent('/v2/pods/pod-1'); }); @@ -668,7 +675,7 @@ describe('V2ActivityPage', () => { renderPage(); fireEvent.click(await screen.findByRole('button', { name: 'Rule: Ship now (Recommended)' })); - expect(await screen.findByText('✓ You ruled: Ship now')).toBeInTheDocument(); + expect(await screen.findByText('You ruled: Ship now')).toBeInTheDocument(); }); test('restores a settled Activity card from durable history after leave and return', async () => { @@ -685,11 +692,11 @@ describe('V2ActivityPage', () => { }); const first = renderPage(); - expect(await screen.findByText('✓ You ruled: Ship now')).toBeInTheDocument(); + expect(await screen.findByText('You ruled: Ship now')).toBeInTheDocument(); first.unmount(); renderPage(); - expect(await screen.findByText('✓ You ruled: Ship now')).toBeInTheDocument(); + expect(await screen.findByText('You ruled: Ship now')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Rule:/ })).not.toBeInTheDocument(); }); @@ -897,24 +904,117 @@ describe('V2ActivityPage', () => { const settled = await screen.findByText('Settled earlier'); // Panel first, settled card under it. expect(panel.compareDocumentPosition(settled) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - expect(screen.queryByText('oldest waiting first')).not.toBeInTheDocument(); + expect(screen.queryByText(/oldest first/)).not.toBeInTheDocument(); }); - test('a human whose name matches an agent label keeps the human mark when the id is present', async () => { - mockGet.mockImplementation((url: string) => Promise.resolve({ data: url === '/api/activity/decision-queue' - ? { ...decisionQueue, items: [ - { id: 'm-human', attentionItemId: 'a-human', kind: 'mention', title: 'Kai mentioned you', detail: 'x', podId: 'pod-1', podName: 'Launch pod', actorName: 'Kai', actorUserId: 'human-user-99', createdAt: '2026-08-26T11:00:00.000Z' }, - { id: 'm-agent', attentionItemId: 'a-agent', kind: 'mention', title: 'Kai mentioned you', detail: 'y', podId: 'pod-1', podName: 'Launch pod', actorName: 'Kai', createdAt: '2026-08-26T10:00:00.000Z' }, - ], count: 2, countsByPod: { 'pod-1': 2 } } - : { ...recap, agents: [{ ...recap.agents[0], id: 'agent-kai', name: 'Kai' }] } })); + test('every ask carries one line mark named by its kind, never a filled square (d215 activity)', async () => { renderPage(); - await screen.findAllByText('Kai'); + await screen.findByText('Review requested'); const marks = [...document.querySelectorAll('.v2-activity__queue-mark')]; - const human = document.querySelector('[data-activity-item-id="m-human"] .v2-activity__queue-mark') as HTMLElement; - const agent = document.querySelector('[data-activity-item-id="m-agent"] .v2-activity__queue-mark') as HTMLElement; - expect(marks.length).toBeGreaterThanOrEqual(2); - expect(human.className).toContain('v2-activity__queue-mark--human'); - expect(agent.className).toContain('v2-activity__queue-mark--agent'); + expect(marks).toHaveLength(2); + expect(screen.getByRole('img', { name: 'mention' })).toHaveClass('v2-activity__queue-mark'); + expect(screen.getByRole('img', { name: 'decision' })).toHaveClass('v2-activity__queue-mark'); + for (const mark of marks) expect(mark.className).toBe('v2-activity__queue-mark'); + // Both asks are rows of one list, not a stack of cards. + expect(document.querySelectorAll('.v2-activity__queue > article')).toHaveLength(2); + }); + + test('a desktop row keeps its secondary acts in the hover cluster beside its one key', async () => { + const approval = { id: 'approval-9', kind: 'approval', title: 'Approval requested', detail: 'Deploy.', podId: 'pod-1', podName: 'Launch pod', createdAt: '2026-08-26T08:00:00.000Z' }; + mockGet.mockImplementation((url: string) => Promise.resolve({ data: url === '/api/activity/decision-queue' + ? { ...decisionQueue, items: [...decisionQueue.items, approval], count: 3 } : recap })); + renderPage(); + const mention = (await screen.findByText('Review requested')).closest('article') as HTMLElement; + const cluster = mention.querySelector('.v2-activity__hv') as HTMLElement; + expect(within(cluster).getAllByRole('button').map((b) => b.getAttribute('aria-label'))).toEqual(['Mark handled', 'Open']); + // The key follows the cluster in the DOM, so Tab reaches Reply first; CSS draws the cluster before it. + const reply = within(mention).getByRole('button', { name: 'Reply' }); + expect(reply.compareDocumentPosition(cluster) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + const approvalRow = screen.getByText('Approval requested').closest('article') as HTMLElement; + expect(within(approvalRow.querySelector('.v2-activity__hv') as HTMLElement).getAllByRole('button').map((b) => b.getAttribute('aria-label'))) + .toEqual(['Deny', 'Open pod']); + expect(within(approvalRow).getByRole('button', { name: 'Approve' })).toHaveClass('v2p-b-s'); + // No ⋯ menu at desktop, and the decision row's Open sits beside the row, not in a cluster. + expect(screen.queryByRole('button', { name: 'More actions' })).not.toBeInTheDocument(); + const decision = screen.getByText('Choose the eslint scope').closest('article') as HTMLElement; + expect(decision.querySelector('.v2-activity__hv')).toBeNull(); + expect(within(decision).getByRole('button', { name: 'Open' })).toBeInTheDocument(); + }); + + test('the composer is one line until its text area takes focus, and stays open while it holds a draft', async () => { + renderPage(); + const box = await screen.findByRole('textbox', { name: i18n.t('activity.compose.placeholder') }); + const composer = box.closest('section') as HTMLElement; + expect(composer).not.toHaveClass('v2-activity__compose--open'); + expect(box).toHaveAttribute('rows', '1'); + fireEvent.focus(box); + expect(composer).toHaveClass('v2-activity__compose--open'); + expect(box).toHaveAttribute('rows', '2'); + fireEvent.blur(box, { relatedTarget: document.body }); + expect(composer).not.toHaveClass('v2-activity__compose--open'); + fireEvent.focus(box); + fireEvent.change(box, { target: { value: 'Draft' } }); + fireEvent.blur(box, { relatedTarget: document.body }); + expect(composer).toHaveClass('v2-activity__compose--open'); + // Focus moving to the destination key inside the composer keeps it open. + fireEvent.change(box, { target: { value: '' } }); + fireEvent.focus(box); + fireEvent.blur(box, { relatedTarget: composer.querySelector('.v2-activity__compose-picker-button') }); + expect(composer).toHaveClass('v2-activity__compose--open'); + }); + + test('the ruled line is text only, with no ✓ glyph', async () => { + expect(i18n.t('activity.decision.ruled', { by: 'You', value: 'Ship now', lng: 'en' })).toBe('You ruled: Ship now'); + expect(i18n.t('activity.decision.ruled', { by: 'sam', value: 'A', lng: 'zh-CN' })).toBe('sam 已裁定:A'); + }); + + describe('at phone width', () => { + let original: typeof window.matchMedia; + beforeEach(() => { + original = window.matchMedia; + window.matchMedia = ((query: string) => ({ + matches: query === '(max-width: 760px)', media: query, onchange: null, + addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; + }); + afterEach(() => { window.matchMedia = original; }); + + test('the head holds only the title and count; the window and scope are one row in the page', async () => { + renderPage(); + await screen.findByText('Review requested'); + const head = screen.getByRole('heading', { name: 'Activity' }).closest('header') as HTMLElement; + expect(within(head).queryByRole('radiogroup')).not.toBeInTheDocument(); + const controls = screen.getByRole('group', { name: 'Activity controls' }); + expect(head).not.toContainElement(controls); + expect(within(controls).getByRole('radio', { name: 'Today' })).toBeInTheDocument(); + expect(controls.querySelector('.v2-activity__scope-button')).toHaveTextContent('All pods'); + // The order text gives way on a phone; the count stays. + expect(screen.getByLabelText('2 waiting on you')).toBeInTheDocument(); + expect(screen.queryByText('· oldest first')).not.toBeInTheDocument(); + }); + + test('a row\'s secondary acts live behind its ⋯ key, and the menu runs them', async () => { + let reads = 0; + mockGet.mockImplementation((url: string) => Promise.resolve({ data: url === '/api/activity/decision-queue' + ? (++reads === 1 ? { ...decisionQueue, items: [decisionQueue.items[0]], count: 1 } : { items: [], count: 0, countsByPod: {} }) + : recap })); + mockPost.mockResolvedValue({ data: { success: true } }); + renderPage(); + const row = (await screen.findByText('Review requested')).closest('article') as HTMLElement; + expect(row.querySelector('.v2-activity__hv')).toBeNull(); + expect(within(row).queryByRole('button', { name: 'Mark handled' })).not.toBeInTheDocument(); + const more = within(row).getByRole('button', { name: 'More actions' }); + expect(more).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(more); + const menu = within(row).getByRole('menu'); + expect(within(menu).getAllByRole('menuitem').map((item) => item.textContent)).toEqual(['Mark handled', 'Open']); + fireEvent.keyDown(menu, { key: 'Escape' }); + expect(within(row).queryByRole('menu')).not.toBeInTheDocument(); + expect(more).toHaveFocus(); + fireEvent.click(more); + fireEvent.click(within(within(row).getByRole('menu')).getByRole('menuitem', { name: 'Mark handled' })); + await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/activity/attention-1/acknowledge', {}, expect.anything())); + }); }); test('renders a handoff as a handled action, never as a decision', async () => { @@ -1164,7 +1264,7 @@ describe('V2ActivityPage', () => { expect(await screen.findByLabelText('91 waiting on you')).toHaveTextContent('91'); }); - test('describes the count in the active queue scope without claiming rail/inspector parity', async () => { + test('counts the active queue scope beside its label, with the order and no hint', async () => { const scopedRecap = { ...recap, pods: [...recap.pods, { id: 'pod-2', name: 'GTM Programs' }] }; mockGet.mockImplementation((url: string, config: any) => { if (url === '/api/activity/decision-queue') { @@ -1176,11 +1276,13 @@ describe('V2ActivityPage', () => { }); renderPage(); - expect(await screen.findByText('oldest waiting first')).toBeInTheDocument(); + // The order rides the label's right edge as plain text; no (i) hint carries it any more. + expect(await screen.findByText('· oldest first')).toBeInTheDocument(); expect(screen.getByLabelText('56 waiting on you')).toHaveTextContent('56'); - fireEvent.click(screen.getByRole('button', { name: 'GTM Programs' })); - expect(await screen.findByText('oldest waiting first · this pod')).toBeInTheDocument(); - expect(screen.getByLabelText('9 waiting on you')).toHaveTextContent('9'); + expect(screen.queryByRole('button', { name: /about/i })).not.toBeInTheDocument(); + chooseScope('GTM Programs'); + expect(await screen.findByLabelText('9 waiting on you')).toHaveTextContent('9'); + expect(document.querySelector('.v2-activity__scope-button')).toHaveTextContent('GTM Programs'); }); test('appends the next server page without losing the existing rows', async () => { @@ -1275,7 +1377,7 @@ describe('V2ActivityPage', () => { }); renderPage(); await screen.findByText('Review requested'); - fireEvent.click(screen.getByRole('button', { name: 'GTM Programs' })); + chooseScope('GTM Programs'); expect(await screen.findByText('GTM 8')).toBeInTheDocument(); expect(screen.queryByText('Nothing needs you.')).not.toBeInTheDocument(); expect(mockGet).toHaveBeenCalledWith('/api/activity/decision-queue', expect.objectContaining({ @@ -1300,9 +1402,9 @@ describe('V2ActivityPage', () => { }); renderPage(); await screen.findByText('Review requested'); - fireEvent.click(screen.getByRole('button', { name: 'GTM Programs' })); + chooseScope('GTM Programs'); await waitFor(() => expect(resolveScoped).not.toBeNull()); - fireEvent.click(screen.getByRole('button', { name: 'All pods' })); + chooseScope('All pods'); await screen.findByText('Review requested'); await act(async () => { @@ -1401,9 +1503,9 @@ describe('V2ActivityPage', () => { }); renderPage(); await screen.findByText('Review requested'); - fireEvent.click(screen.getByRole('button', { name: 'GTM Programs', exact: true })); + chooseScope('GTM Programs'); await screen.findByRole('button', { name: 'Retry' }); - expect(screen.getByRole('button', { name: 'GTM Programs', exact: true })).toHaveAttribute('aria-pressed', 'true'); + expect(document.querySelector('.v2-activity__scope-button')).toHaveTextContent('GTM Programs'); expect(screen.queryByText('Review requested')).not.toBeInTheDocument(); expect(screen.getByText('Count unavailable')).toBeInTheDocument(); }); @@ -1538,22 +1640,30 @@ describe('V2ActivityPage', () => { expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); }); - test('keeps an overflow pod selection visible and returns focus to its trigger', async () => { + test('every pod is in the one scope select, and a choice returns focus to it (d215 activity)', async () => { const extraPods = [2, 3, 4].map((id) => ({ id: `pod-${id}`, name: `Pod ${id}` })); mockGet.mockImplementation((url: string) => Promise.resolve({ data: url === '/api/activity/decision-queue' ? decisionQueue : { ...recap, pods: [...recap.pods, ...extraPods] } })); renderPage(); - const more = await screen.findByRole('button', { name: i18n.t('activity.morePods') }); - fireEvent.click(more); - const choice = screen.getByRole('button', { name: 'Pod 4', exact: true }); + await screen.findByText('Review requested'); + const scope = document.querySelector('.v2-activity__scope-button') as HTMLButtonElement; + expect(scope).toHaveTextContent('All pods'); + expect(scope).toHaveAttribute('aria-haspopup', 'listbox'); + // No pill row: the pods are not buttons in the head until the select opens. + expect(screen.queryByRole('button', { name: 'Pod 4', exact: true })).not.toBeInTheDocument(); + fireEvent.click(scope); + const list = screen.getByRole('listbox', { name: 'Pod scope' }); + expect(within(list).getAllByRole('option').map((option) => option.textContent)).toEqual(['All pods', 'Launch pod', 'Pod 2', 'Pod 3', 'Pod 4']); + const choice = within(list).getByRole('option', { name: 'Pod 4' }); choice.focus(); fireEvent.click(choice); - expect(more).toHaveTextContent('Pod 4'); - expect(more).toHaveAttribute('aria-expanded', 'false'); - expect(more).toHaveFocus(); - fireEvent.click(more); - fireEvent.keyDown(screen.getByRole('button', { name: 'Pod 3', exact: true }), { key: 'Escape' }); - expect(more).toHaveAttribute('aria-expanded', 'false'); - expect(more).toHaveFocus(); + expect(scope).toHaveTextContent('Pod 4'); + expect(scope).toHaveAttribute('aria-expanded', 'false'); + await waitFor(() => expect(scope).toHaveFocus()); + await waitFor(() => expect(mockGet).toHaveBeenCalledWith('/api/activity/recap', expect.objectContaining({ params: { window: 'today', podId: 'pod-4' } }))); + fireEvent.click(scope); + fireEvent.keyDown(within(screen.getByRole('listbox', { name: 'Pod scope' })).getByRole('option', { name: 'Pod 3' }), { key: 'Escape' }); + expect(scope).toHaveAttribute('aria-expanded', 'false'); + expect(scope).toHaveFocus(); }); test('paginates all moved-forward lines in twenty-line batches and retains keyboard focus', async () => { diff --git a/frontend/src/v2/__tests__/signalAccent.test.ts b/frontend/src/v2/__tests__/signalAccent.test.ts index 1b0cbc609..4cd585785 100644 --- a/frontend/src/v2/__tests__/signalAccent.test.ts +++ b/frontend/src/v2/__tests__/signalAccent.test.ts @@ -58,7 +58,8 @@ describe('Signal accent on standalone and bridged surfaces', () => { ['src/components/landing/guideShell.ts', /accent:\s*'#1d3fd1'/], ['src/v2/components/V2FeaturePage.tsx', /accent:\s*'#1d3fd1'/], ['design-system/tokens.css', /::selection\s*\{\s*background:\s*rgba\(29, 63, 209, 0\.20\)/], - ['src/v2/v2.css', /\.v2-activity__compose:focus-within\s*\{\s*box-shadow: 0 0 0 4px rgba\(29, 63, 209, 0\.10\)/], + // The open composer's ring is the focus token, so it shows on the dark ground too (d215 activity). + ['src/v2/components/V2ActivityPage.css', /\.v2-activity__compose--open:focus-within \{ box-shadow: inset 0 0 0 1px var\(--v2-accent\), var\(--v2-focus-ring\); \}/], ])('%s retains its intended cobalt accent', (name, expected) => { expect(read(name as string)).toMatch(expected as RegExp); }); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 67ab89b39..872c40b66 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -174,11 +174,11 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(phone).toMatch(/\.v2-profile \.v2-profile__follow, \.v2-profile \.v2-profile__edit \{[^}]*flex: 1 0 100%/); }); - test('/v2/activity and /v2/artifacts: on a phone the page head is the pinned 52px bar (d215 .mtop 390)', () => { + test('/v2/artifacts: on a phone the page head is the pinned 52px bar (d215 .mtop 390)', () => { const v2 = readV2Css(); - const barSelector = '.v2-activity__header,\n .v2-artifacts__head {'; - const phone = mediaBlockContaining(v2, barSelector); - // Both heads render inside the scrolling .v2-feature__body; sticky is what keeps them on screen. + const barSelector = '.v2-artifacts__head {'; + const phone = mediaBlockContaining(v2, ' .v2-artifacts__head {\n position: sticky;'); + // The head renders inside the scrolling .v2-feature__body; sticky is what keeps it on screen. const bar = selectorRuleBody(phone, barSelector); expect(bar).toContain('position: sticky'); expect(bar).toContain('top: 0'); @@ -189,66 +189,21 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(bar).toContain('background: var(--v2-surface)'); // The compact 22px title, not the desktop 32px display head. expect(selectorRuleBody(phone, '.v2-artifacts__head .v2-artifacts__title {')).toContain('font: 800 22px/1'); - // Activity's title shares the rule, and keeps its width: the meta is what gives way. - const activityTitle = selectorRuleBody(phone, '.v2-activity__header .v2-activity__title,'); - expect(activityTitle).toContain('font: 800 22px/1'); - expect(activityTitle).toContain('flex-shrink: 0'); - // The meta sits at the bar's right edge, on both pages, and truncates rather than wrapping. + // The meta sits at the bar's right edge and truncates rather than wrapping. expect(selectorRuleBody(phone, '.v2-artifacts__head .v2-page-head__meta {')).toContain('margin-left: auto'); - const meta = selectorRuleBody(phone, '.v2-activity__header .v2-page-head__meta,'); - expect(meta).toContain('margin-left: auto'); - expect(meta).toContain('text-overflow: ellipsis'); - // Closed, the identity is one line (identity wraps by default, which would drop the meta to a - // second row and grow the bar); an open hint keeps its own row. - expect(selectorRuleBody(phone, '.v2-activity__header .v2-page-head__identity:not(:has(.v2-hint--open)),')).toContain('flex-wrap: nowrap'); // Artifacts pads its column 12px; the bar bleeds back out to the pane edges. // ...and the column's 16px phone top padding is dropped, so the bar starts at 0,0 before any scroll. expect(phone).toContain('.v2-artifacts { padding-top: 0; }'); expect(phone).toContain('.v2-artifacts__head { margin: 0 -12px; }'); - // The block must come after the pages' own head rules, or their padding and baseline win by order. - expect(v2.indexOf(phone)).toBeGreaterThan(v2.lastIndexOf('.v2-activity__header { min-height: 56px;')); expect(v2.indexOf(phone)).toBeGreaterThan(v2.indexOf('\n.v2-artifacts__head {')); // ...and after the Artifacts phone rule, whose 16px top padding would push the bar down until scrolled. const artifactsPhonePad = v2.indexOf('.v2-artifacts { padding: 16px 12px 0;'); expect(artifactsPhonePad).toBeGreaterThan(-1); expect(v2.indexOf(phone)).toBeGreaterThan(artifactsPhonePad); + // Activity left this shared bar for its own sheet (d215 activity build). + expect(v2).not.toContain('v2-activity'); }); - test('/v2/activity: on a phone the window and scope controls are two full-width rows under the bar (d215 activity 390)', () => { - const v2 = readV2Css(); - const phone = mediaBlockContaining(v2, '.v2-activity__controls { flex-direction: column;'); - const controls = selectorRuleBody(phone, '.v2-activity__controls {'); - expect(controls).toContain('flex-direction: column'); - expect(controls).toContain('flex-wrap: nowrap'); - expect(controls).toContain('align-items: stretch'); - // The 12px gutter is margin, so the block is the frame's 366px box; no filter-row rule under it. - // width: auto drops the ≤1100 width: 100%, which plus the margin would overflow the pane by 24px. - expect(controls).toContain('width: auto'); - expect(controls).toContain('margin: 12px 12px 0'); - expect(controls).toContain('padding: 0'); - expect(controls).toContain('border-bottom: 0'); - // 1px border + 2px padding is the frame's 3px inset. The frame's 36px buttons hold at the 44px touch floor. - expect(selectorRuleBody(phone, '.v2-activity__window,\n .v2-activity__scope {')).toContain('padding: 2px'); - expect(selectorRuleBody(phone, '.v2-activity__window,\n .v2-activity__scope {')).toContain('width: auto'); - const windowButton = selectorRuleBody(phone, '.v2-root button.v2-activity__window-button {'); - expect(windowButton).toContain('flex: 1 1 0'); - expect(windowButton).toContain('justify-content: center'); - expect(windowButton).toContain('min-height: 44px'); - const scopeButton = selectorRuleBody(phone, '.v2-root button.v2-activity__scope-button {'); - expect(scopeButton).toContain('min-height: 44px'); - // The scope stays one row: pod buttons shrink into their ellipsis rather than wrap, never below 44px wide. - expect(scopeButton).toContain('min-width: 44px'); - // Its own rule: `.v2-activity__scope {` alone would match the shared window/scope rule above it. - expect(phone).toContain('\n .v2-activity__scope { flex-wrap: nowrap; }'); - // "All pods" and the "more" trigger never shrink, so the trigger keeps its label and caret. - expect(phone).toContain('.v2-root .v2-activity__scope > button.v2-activity__scope-button:first-child,\n .v2-root button.v2-activity__scope-button--more { flex-shrink: 0; }'); - expect(fs.readFileSync(path.join(__dirname, '../components/V2ActivityPage.tsx'), 'utf8')).toContain("' v2-activity__scope-button--active' : ' v2-activity__scope-button--more'"); - expect(selectorRuleBody(phone, '.v2-activity__controls + .v2-activity__compose {')).toContain('margin-top: 12px'); - // Same selectors as the ≤1100 filter row (a row with a rule under it), so this block must come after it. - const filterRow = v2.indexOf('.v2-activity__controls { position: relative; top: auto; right: auto;'); - expect(filterRow).toBeGreaterThan(-1); - expect(v2.indexOf(phone)).toBeGreaterThan(filterRow); - }); test('/invite: a long pod name or description wraps inside the auth card (d215 s-invite)', () => { const invite = read('../components/V2InviteRedeem.css'); @@ -310,7 +265,6 @@ describe('v2 layout invariants (CSS rule presence)', () => { const podBoard = read('../components/V2PodBoard.tsx'); const activityPage = read('../components/V2ActivityPage.tsx'); const yourTeamPage = read('../components/V2YourTeamPage.tsx'); - const featurePage = read('../components/V2FeaturePage.tsx'); const v2App = read('../V2App.tsx'); const app = read('../../App.tsx'); const appStyles = read('../../App.css'); @@ -951,86 +905,12 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(v2).toMatch(/\.v2-root button\.v2-verification-banner__dismiss \{[\s\S]*?width: 44px;/); }); - test('Activity queue actions stay in the row grammar and wrap on narrow screens', () => { - // Day-zero onboarding and approval actions share the queue row. Keeping - // their action cluster explicit prevents a later button refactor from - // forcing a third column past a 390px viewport. - // The desktop declaration shares its rule with the buttons, so it is not - // eligible for ruleBody's exact-selector helper. - expect(v2).toMatch(/\.v2-activity__queue-actions,\n\.v2-activity__queue-row button \{\n\s*display: flex;[\s\S]*?gap: 6px;/); - expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-activity__queue-actions \{ grid-column: 2; \}[\s\S]*?\.v2-activity__queue-actions \{ flex-wrap: wrap; \}/); - expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-root \.v2-activity__queue-actions button \{ min-height: 44px; \}/); - // Every non-option card act is 32 tall at desktop, level with the options (ux-lead 67327): - // Approve / Deny / Mark handled / Reply / Open read 30 from the shared row-button rule. - expect(ruleBody(v2, '.v2-root .v2-activity__queue-actions button')).toContain('min-height: 32px'); - }); - - test('Activity details clamp to two lines and reveal full copy on hover, focus, or expand', () => { - const detail = ruleBody(v2, '.v2-root .v2-activity__queue-detail'); - expect(detail).toContain('display: -webkit-box'); - expect(detail).toContain('-webkit-line-clamp: 2'); - expect(detail).toContain('overflow: hidden'); - const reveal = selectorRuleBody(v2, '.v2-root .v2-activity__queue-detail--expanded'); - expect(reveal).toContain('display: block'); - // Tab-in preview is kept; a reader's collapse is excluded so focus can't re-open it (#2407). - const focusReveal = selectorRuleBody(v2, '.v2-root .v2-activity__queue-detail:focus-visible:not(.v2-activity__queue-detail--dismissed)'); - expect(focusReveal).toContain('display: block'); - expect(focusReveal).toContain('-webkit-line-clamp: unset'); - expect(activityPage).toContain('v2-activity__queue-detail--dismissed'); - expect(reveal).toContain('-webkit-line-clamp: unset'); - const baseDetail = selectorRuleBody(v2, '.v2-root button.v2-activity__queue-detail'); - expect(baseDetail).toContain('color: var(--v2-text-secondary)'); - const mentionDetail = selectorRuleBody(v2, '.v2-root .v2-activity__queue-row--mention .v2-activity__queue-detail'); - expect(mentionDetail).toContain('color: var(--v2-text-primary)'); - expect(v2).toContain('.v2-root:lang(zh) .v2-activity__queue-detail,'); - expect(v2).toContain('.v2-root .v2-activity__queue-detail:hover:not(:focus)'); - expect(v2).toContain('@media (hover: hover)'); - expect(activityPage).toContain('aria-expanded={expandedDetailIds.has(item.id)}'); - expect(activityPage).toContain('v2-activity__queue-detail'); - expect(activityPage).toContain('aria-label={t(\'activity.compose.placeholder\')}'); - expect(activityPage).not.toContain('v2-activity__compose-label'); - }); - - test('Activity pagination keeps the Show more affordance visible and keyboard-sized', () => { - const more = ruleBody(v2, '.v2-activity__queue-more'); - expect(more).toContain('min-height: 36px'); - expect(more).toContain('border: 1px solid var(--v2-accent)'); - expect(more).toContain('color: var(--v2-accent-text)'); - expect(v2).toContain('.v2-activity__queue-more:hover:not(:disabled)'); - expect(v2).toContain('.v2-activity__queue-more:disabled'); - }); - - test('DecisionRequest options preserve authored order with one cobalt primary choice', () => { - // The first build left options inside the narrow actions column. Generic - // queue-button CSS once made every non-recommended option blue while the - // first authored option looked secondary — exactly backwards for a fork card. - const decisionActions = ruleBody(v2, '.v2-activity__queue-row--decision .v2-activity__queue-actions'); - expect(decisionActions).toContain('grid-column: 1 / -1'); - expect(decisionActions).toContain('justify-content: flex-start'); - const decisionActionsOverride = lastRuleBody(v2, '.v2-activity__queue-row.v2-activity__queue-row--decision .v2-activity__queue-actions'); - expect(decisionActionsOverride).toContain('grid-column: 1 / -1'); - expect(decisionActionsOverride).toContain('max-width: none'); - expect(decisionActionsOverride).toContain('width: 100%'); - // Inline at 32px with Other… beside (ux-lead 66400 fix 4), never stacked bars. - expect(decisionActionsOverride).toContain('flex-direction: row'); - expect(decisionActionsOverride).toContain('flex-wrap: wrap'); - expect(decisionActionsOverride).toContain('gap: 8px'); - expect(lastRuleBody(v2, '.v2-activity__queue-row.v2-activity__queue-row--decision .v2-activity__decision-footer')).toContain('display: contents'); - expect(ruleBody(v2, '.v2-activity__queue-row.v2-activity__queue-row--decision')).toContain('align-items: start'); - expect(lastRuleBody(v2, '.v2-activity__queue-row.v2-activity__queue-row--decision .v2-activity__option-choice')) - .toContain('flex: 0 1 auto'); - expect(lastRuleBody(v2, '.v2-activity__queue-row.v2-activity__queue-row--decision .v2-activity__option-choice button')) - .toContain('min-height: 32px'); - expect(lastRuleBody(v2, '.v2-activity__queue-row.v2-activity__queue-row--decision .v2-activity__option-choice button')) - .toContain('padding-inline: 12px'); - const neutralOptionHover = ruleBody(v2, '.v2-activity__queue-row.v2-activity__queue-row--decision .v2-activity__option-choice button.v2-activity__option:not(.v2-activity__option--primary):hover:not(:disabled)'); - expect(neutralOptionHover).toContain('background: var(--v2-surface-hover)'); - expect(neutralOptionHover).toContain('color: var(--v2-text-primary)'); - const primaryOptionHover = ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__option--primary:hover:not(:disabled)'); - expect(primaryOptionHover).toContain('background: var(--v2-accent-strong)'); - expect(primaryOptionHover).toContain('color: var(--v2-on-ink)'); - expect(activityPage).toContain('aria-describedby={describedBy}'); - expect(activityPage).toContain('v2-activity__decision-footer'); + + + + test('DecisionRequest options preserve authored order on the thread card', () => { + // Activity's decision row is pinned in the d215 activity block below; the + // thread card keeps its own grammar and its decision loader here. expect(decisionCard).toContain('aria-describedby={describedBy}'); expect(decisionCard).toContain('v2-decision-card__option-recommended'); expect(decisionCard).toContain('v2-decision-card__option-description'); @@ -1038,36 +918,6 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(ruleBody(v2, '.v2-decision-card__options')).toContain('gap: 12px'); expect(ruleBody(v2, '.v2-decision-card__option')).toContain('display: grid'); expect(ruleBody(v2, '.v2-decision-card__option')).toContain('gap: 4px'); - - const neutralOption = ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__option'); - expect(neutralOption).toContain('border: 1px solid var(--v2-border)'); - expect(neutralOption).toContain('background: var(--v2-surface)'); - expect(neutralOption).toContain('border-radius: var(--v2-radius-sm)'); - - const primaryOption = ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__option--primary'); - expect(primaryOption).toContain('background: var(--v2-accent)'); - expect(primaryOption).toContain('color: var(--v2-on-ink)'); - expect(v2).toContain('v2-activity__queue-action--bordered'); - const borderedAction = ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__queue-action--bordered'); - expect(borderedAction).toContain('border: 1px solid var(--v2-border)'); - expect(ruleBody(v2, '.v2-root .v2-activity__compose .v2-activity__compose-picker-button')).toContain('border: 1px solid var(--v2-border)'); - const composeMenu = ruleBody(v2, '.v2-root .v2-activity__compose .v2-activity__compose-picker-menu'); - expect(composeMenu).toContain('max-height: 240px'); - expect(composeMenu).toContain('overflow-y: auto'); - const composeOption = ruleBody(v2, '.v2-root .v2-activity__compose .v2-activity__compose-picker-option'); - expect(composeOption).toContain('min-height: 30px'); - expect(composeOption).toContain('background: transparent'); - expect(composeOption).toContain('color: var(--v2-text-primary)'); - const composeOptionHover = ruleBody(v2, '.v2-root .v2-activity__compose .v2-activity__compose-picker-menu button.v2-activity__compose-picker-option:hover:not(:disabled)'); - expect(composeOptionHover).toContain('background: var(--v2-surface-hover)'); - expect(composeOptionHover).toContain('color: var(--v2-text-primary)'); - expect(composeOptionHover).toContain('border-color: transparent'); - expect(v2).toMatch(/@media \(max-width: 640px\) \{[\s\S]*?\.v2-root \.v2-activity__compose \.v2-activity__compose-picker-menu \.v2-activity__compose-picker-option \{ min-height: 44px; \}/); - const otherOption = ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__option.v2-activity__queue-action--secondary'); - expect(otherOption).toContain('color: var(--v2-accent-text)'); - expect(v2).toContain('.v2-activity__option-description'); - expect(activityPage).toContain('(option, index)'); - expect(activityPage).not.toContain('.sort((a, b) => Number(Boolean(b.recommended))'); expect(thread).toContain("loadDecisionPages("); expect(thread).toContain("'/api/activity/decision-queue'"); expect(thread).toContain("'/api/activity/decision-history'"); @@ -1075,7 +925,6 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(thread).toContain('loadDecisionPagesForMessageIds'); expect(thread).toContain('loadedMessageIdsRef.current = [...new Set(messages'); expect(thread).toContain('if (pendingData)'); - expect(thread).toContain("'/api/activity/decision-history'"); expect(thread).toContain('settledDecisionByMessageId'); }); @@ -2083,35 +1932,8 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(phone).toContain('.v2-pane--rail { display: none; }'); }); - test('Activity cards have shrinkable desktop and mobile layout guards', () => { - // The recap is a feature-wide page, but it is still reachable at 390px. - // The zero-min grid tracks are the load-bearing no-horizontal-overflow - // rule; jsdom cannot observe the scrollbar they prevent. - expect(ruleBody(v2, '.v2-activity__queue-row')) - .toContain('28px minmax(0, 1fr) auto'); - expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-activity__queue-row \{[\s\S]*?28px minmax\(0, 1fr\)/); - // The agent grid and board rows this also pinned are gone from the page, - // and their rules with them (TASK-758). - }); - test('Activity queue actions distinguish an action from the thread handoff', () => { - expect(ruleBody(v2, '.v2-root .v2-activity__queue-actions button')).toContain('background: var(--v2-ink)'); - expect(ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__queue-action--secondary')).toContain('background: var(--v2-surface-hover)'); - expect(ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__queue-action--thread')).toContain('background: transparent'); - const bordered = ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__queue-action--bordered'); - expect(bordered).toContain('border: 1px solid var(--v2-border)'); - expect(bordered).toContain('background: var(--v2-surface)'); - }); - test('DecisionRequest options remain 44px touch targets when they wrap at 390px', () => { - // Options are agent-authored data, not compact task metadata. Keep the - // primary state and the free-text escape hatch visible in the CSS - // source because jsdom has no layout engine to catch a narrow regression. - expect(ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__option--primary')) - .toContain('background: var(--v2-accent)'); - expect(ruleBody(v2, '.v2-activity__decision-other')).toContain('flex-basis: 100%'); - expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-root \.v2-activity__queue-actions button \{ min-height: 44px; \}/); - }); test('starter prompts wrap within the mobile chat pane', () => { // On the edge-to-edge phone thread, both the row and each chip need @@ -2883,7 +2705,7 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(ruleBody(v2, '.v2-connector-gate__switch')).toContain('accent-color: var(--v2-ink)'); expect(ruleBody(v2, '.v2-connector-gate__switch')).not.toContain('border-radius'); expect(ruleBody(v2, '.v2-connector-gate__tag')).toContain('var(--v2-font-mono)'); - const connectorCss = v2.slice(v2.indexOf('/* ── Connectors'), v2.indexOf('/* Activity queue')); + const connectorCss = v2.slice(v2.indexOf('/* ── Connectors'), v2.indexOf('/* ---------- Workspace sidebar (TASK-129 PR 1)')); expect(connectorCss.length).toBeGreaterThan(1000); expect(connectorCss).not.toContain('var(--v2-success)'); expect(connectorCss).not.toContain('var(--v2-warning)'); @@ -3029,28 +2851,11 @@ describe('v2 layout invariants (CSS rule presence)', () => { } }); - test('Needs-you rows: open asks sit on a 1px neutral border, no shadow, no dividers (66390; ring moved to focus, TASK-748)', () => { - const row = ruleBody(v2, '.v2-activity__queue-row'); - expect(row).toContain('border: 1px solid var(--v2-border)'); - expect(row).toContain('border-radius: var(--v2-radius)'); - expect(v2).not.toContain('.v2-activity__queue-row + .v2-activity__queue-row'); - const hover = ruleBody(v2, '.v2-activity__queue-row:hover'); - const declarations = hover.slice(hover.indexOf('{') + 1).split(';').map((d) => d.trim()).filter(Boolean); - expect(declarations.length).toBeGreaterThan(0); - for (const declaration of declarations) expect(declaration).toMatch(/^background/); - // A pending decision or approval wears the same neutral border as every other ask — never a lift. - const pending = ruleBody(v2, '.v2-activity__queue-row--decision,\n.v2-activity__queue-row--approval'); - expect(pending).toContain('border-color: var(--v2-border)'); - expect(pending).toContain('box-shadow: none'); + test('the pending-card shadow token stays in step with the design system', () => { expect(cssVariable(v2Root, '--v2-shadow-pending')).toBe(cssVariable(tokens, '--c-shadow-pending')); - // A ruled decision settles to a flat bordered row. - const settled = ruleBody(v2, '.v2-activity__queue-row--settled'); - expect(settled).toContain('border: 1px solid var(--v2-border)'); - expect(settled).toContain('box-shadow: none'); - expect(activityPage).toContain("' v2-activity__queue-row--settled'"); }); - test('Needs you never folds; one count; toggles are tint-selected; kicker and marks per 66389/66390', () => { + test('Needs you never folds and runs oldest waiting first; the rail badge is the exact count (66311, 66390)', () => { // Pages after the first load themselves — the only queue control left is Retry/Loading. expect(activityPage).toContain('void loadMoreQueue(true);'); // The remaining arm stays as the way back if auto-load ever stops short (sprint-review 66398). @@ -3060,76 +2865,11 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(activityPage).toContain('disabled={queueLoadingMore}'); // The stall detector clears the expectation when nothing fetches the next page (sprint-review 66417). expect(activityPage).toContain('globalThis.window.setTimeout(() => setQueueAutoPending(false), 1500)'); - expect(activityPage).toContain('item.actorUserId\n ? agentUserIds.has(item.actorUserId)'); // Oldest waiting first. expect(activityPage).toContain('new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime()'); - // The section count is mono text beside the heading, not an ink pill. - const count = ruleBody(v2, '.v2-activity__count'); - expect(count).toContain('var(--v2-font-mono)'); - expect(count).not.toContain('var(--v2-ink)'); - // Selected segment: tint ground + ink text inside the bordered group. Ink fill is for acts. - expect(ruleBody(v2, '.v2-root button.v2-activity__window-button--active')).toContain('background: var(--v2-surface-hover)'); - expect(ruleBody(v2, '.v2-root button.v2-activity__window-button--active')).toContain('box-shadow: none'); - expect(ruleBody(v2, '.v2-root button.v2-activity__scope-button--active')).toContain('background: var(--v2-surface-hover)'); - expect(ruleBody(v2, '.v2-root button.v2-activity__scope-button--active')).not.toContain('var(--v2-ink)'); - // Card kicker: kind · pod (lowercase) · time, in mono 500, lowercase — and the LAST rule says so. - expect(activityPage).toContain('shortPodName(item.podName)'); - expect(lastRuleBody(v2, '.v2-activity__queue-kind')).toContain('500 11px/16px var(--v2-font-mono)'); - expect(lastRuleBody(v2, '.v2-activity__queue-kind')).toContain('text-transform: lowercase'); - // Open asks sit on a 1px neutral border (slop tell 2); the cobalt ring belongs to the focused row only. - expect(lastRuleBody(v2, '.v2-activity__queue-row')).toContain('border: 1px solid var(--v2-border)'); - expect(lastRuleBody(v2, '.v2-activity__queue-row')).not.toContain('var(--v2-accent)'); - expect(lastRuleBody(v2, '.v2-activity__queue-row--approval')).toContain('border-color: var(--v2-border)'); - expect(v2).toContain('.v2-activity__queue-row--approval:focus-within { border-color: var(--v2-accent); box-shadow: 0 0 0 1px var(--v2-accent); }'); - expect(lastRuleBody(v2, '.v2-activity__queue-row--decision')).toContain('box-shadow: none'); - expect(v2).toContain('.v2-activity__queue-row--settled,\n.v2-activity__queue-row--onboarding { padding: 12px; border: 1px solid var(--v2-border); }'); - // Day zero (66658/66666): Get started is its own card above Needs you; a step is not an ask. - expect(activityPage).toContain('className="v2-activity__start" aria-labelledby="activity-get-started"'); - expect(activityPage).not.toContain('v2-activity__queue-row--onboarding'); + // Day zero (66658/66666): the composer hides while nobody is there to wake. expect(activityPage).toContain("{!startSteps.includes('hire') && ("); - expect(ruleBody(v2, '.v2-activity__start-num')).toContain('background: var(--v2-ink)'); - expect(ruleBody(v2, '.v2-root .v2-activity__start-act button')).toContain('min-height: 32px'); - expect(v2).toMatch(/@media \(max-width: 760px\) \{[\s\S]*?\.v2-root \.v2-activity__start-act button \{ width: 100%; min-height: 44px; \}/); - // One segment grammar (66400 fix 6): both groups bordered, active = tint + ink 600, inactive = white + secondary. - expect(lastRuleBody(v2, '.v2-activity__scope')).toContain('border: 1px solid var(--v2-border)'); - expect(lastRuleBody(v2, '.v2-activity__scope')).toContain('background: var(--v2-surface)'); - expect(lastRuleBody(v2, '.v2-root button.v2-activity__scope-button')).toContain('border: 0'); - expect(lastRuleBody(v2, '.v2-activity__window')).toContain('background: var(--v2-surface)'); - // 0-state (66400 fix 5): a dashed panel with the sentence, not plain text. - expect(lastRuleBody(v2, '.v2-activity__empty--plain')).toContain('border: 1px dashed var(--v2-border)'); expect(activityPage).toContain("t('activity.needsYou.emptyLast', { age: relativeTime(lastAnsweredAt) })"); - // One seat, one mark: agent cobalt, human tint. - expect(ruleBody(v2, '.v2-activity__queue-row .v2-activity__queue-mark--agent')).toContain('background: var(--v2-accent)'); - expect(ruleBody(v2, '.v2-activity__queue-row .v2-activity__queue-mark--human')).toContain('background: var(--v2-surface-hover)'); - // Direction A: the mark holds a 16px glyph; a secondary act is a 32px square - // (44 under 760), never a word — and never a menu. - expect(lastRuleBody(v2, '.v2-root .v2-activity__queue-mark svg')).toContain('width: 16px'); - expect(lastRuleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__queue-action--icon svg')).toContain('width: 16px'); - const iconAct = lastRuleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__queue-action--icon'); - expect(iconAct).toContain('width: 32px'); - expect(iconAct).toContain('padding: 0'); - // Both 44px rules must sit INSIDE a ≤760 block — sprint-review's gate on #1671: a lazy regex - // matched from an earlier 760 query into the 640 block, so 641–760 got 674px bars, and a move - // to top level passed too. Resolve the media block that actually encloses each rule. - const enclosingMedia = (rule: string): string | null => { - const at = v2.indexOf(rule); - expect(at).toBeGreaterThan(-1); - const open = v2.lastIndexOf('@media', at); - if (open === -1) return null; - const close = v2.indexOf('\n}', open); - return close > at ? v2.slice(open, v2.indexOf('{', open)).trim() : null; - }; - const icon44 = '.v2-root .v2-activity__queue-actions button.v2-activity__queue-action--icon { width: 44px; min-width: 44px; flex: none; }'; - const footer44 = '.v2-root .v2-activity__queue-row.v2-activity__queue-row--decision .v2-activity__decision-footer > button.v2-activity__queue-action--icon { flex: none; width: 44px; min-width: 44px; }'; - expect(enclosingMedia(icon44)).toBe('@media (max-width: 760px)'); - expect(enclosingMedia(footer44)).toBe('@media (max-width: 760px)'); - // And the footer override follows the text-button rule it beats, inside that same block. - const footerText = '.v2-activity__decision-footer > button { flex: 1 1 auto;'; - expect(v2.indexOf(footer44)).toBeGreaterThan(v2.indexOf(footerText)); - expect(enclosingMedia(footerText)).toBe('@media (max-width: 760px)'); - // Other… is cobalt text; the handoff act is ink (no bordered modifier). - expect(ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__option--other')).toContain('color: var(--v2-accent-text)'); - expect(activityPage).toContain(' + {open && ( +
+ {options.map((option, index) => ( + + ))} +
+ )} + + ); +}; + +interface RowAct { key: string; label: string; glyph: React.ReactNode; onSelect: () => void; disabled?: boolean } + +// The phone's ⋯ key (d215 activity 390): there is no hover, so the row's +// secondary acts live behind one 44px key, in the same order as the cluster. +const RowMenu: React.FC<{ acts: RowAct[]; label: string; open: boolean; setOpen: (open: boolean) => void }> = ({ + acts, label, open, setOpen, +}) => { + const holderRef = useRef(null); + const buttonRef = useRef(null); + useOutsidePress(open, holderRef, () => setOpen(false)); + return ( +
{ + if (event.key === 'Escape' && open) { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + buttonRef.current?.focus(); + } + }} + > + + {open && ( +
+ {acts.map((act) => ( + + ))} +
+ )} +
+ ); +}; + const V2ActivityPage: React.FC = () => { const location = useLocation(); const navigate = useNavigate(); @@ -238,10 +416,13 @@ const V2ActivityPage: React.FC = () => { const [composePodId, setComposePodId] = useState(''); const [composeDraft, setComposeDraft] = useState(''); const [composeMenuOpen, setComposeMenuOpen] = useState(false); - const composePickerRef = useRef(null); - const composePickerButtonRef = useRef(null); - const composePickerOptionRefs = useRef>({}); - const scopeMenuButtonRef = useRef(null); + // The composer is one line until its text area takes focus (d215 activity), and stays open + // while focus is inside it or it holds a draft, a send or an error. + const [composeFocused, setComposeFocused] = useState(false); + const [rowMenuId, setRowMenuId] = useState(null); + // At phone width the head holds only the title and count; the window and scope move into + // the page as one row, and each row's secondary acts sit behind ⋯ (d215 activity 390). + const phone = usePhone('(max-width: 760px)'); const [composing, setComposing] = useState(false); const [composeError, setComposeError] = useState(null); const [movedVisibleCounts, setMovedVisibleCounts] = useState>({}); @@ -320,22 +501,6 @@ const V2ActivityPage: React.FC = () => { }; }, []); - useEffect(() => { - if (!composeMenuOpen) return undefined; - const closeOnOutsidePress = (event: MouseEvent | TouchEvent) => { - const target = event.target; - if (target instanceof Node && composePickerRef.current && !composePickerRef.current.contains(target)) { - setComposeMenuOpen(false); - } - }; - document.addEventListener('mousedown', closeOnOutsidePress); - document.addEventListener('touchstart', closeOnOutsidePress); - return () => { - document.removeEventListener('mousedown', closeOnOutsidePress); - document.removeEventListener('touchstart', closeOnOutsidePress); - }; - }, [composeMenuOpen]); - useEffect(() => { const snapshot = restoredSnapshotRef.current; if (!snapshotReady || !snapshot || !queueHydrated) return; @@ -823,32 +988,9 @@ const V2ActivityPage: React.FC = () => { return recap?.pods || []; }, [recap]); const composePodName = scopedPods.find((pod) => pod.id === composePodId)?.name || t('activity.allPods'); - const selectComposePod = (nextPodId: string) => { - setComposePodId(nextPodId); - setComposeMenuOpen(false); - globalThis.window.requestAnimationFrame(() => composePickerButtonRef.current?.focus()); - }; - const focusComposePickerOption = (index: number) => { - if (scopedPods.length === 0) return; - const boundedIndex = (index + scopedPods.length) % scopedPods.length; - const option = scopedPods[boundedIndex]; - if (!option) return; - setComposeMenuOpen(true); - globalThis.window.requestAnimationFrame(() => composePickerOptionRefs.current[option.id]?.focus()); - }; - // One seat has one mark everywhere (66390): an agent actor takes the chat's - // cobalt mark, a human the tint mark. The kind glyph stays for asks with no actor. - // Rows written before the actor id was stamped still carry the name, so the - // name is the second door. - const agentUserIds = useMemo(() => new Set((recap?.agents || []).map((agent) => String(agent.id))), [recap]); - const agentNames = useMemo(() => new Set((recap?.agents || []).map((agent) => agent.name.trim().toLowerCase())), [recap]); - // The id is the authority when it exists — a failed id lookup means "not an - // agent", not "unknown" — so the name door opens only for rows with no id. - const isAgentActor = (item: NeedsYouItem): boolean => ( - item.actorUserId - ? agentUserIds.has(item.actorUserId) - : (!!item.actorName && agentNames.has(item.actorName.trim().toLowerCase())) - ); + const podOptions = useMemo(() => scopedPods.map((pod) => ({ id: pod.id, label: pod.name })), [scopedPods]); + const scopeOptions = useMemo(() => [{ id: 'all', label: t('activity.allPods') }, ...podOptions], [podOptions, t]); + const scopeName = scopeOptions.find((option) => option.id === podId)?.label || t('activity.allPods'); // 0-state (66400 fix 5): "Nothing needs you." and, when the history knows it, // when the last ask was answered — the newest ruling in this scope. const lastAnsweredAt = useMemo(() => { @@ -1091,335 +1233,322 @@ const V2ActivityPage: React.FC = () => { const markHandoffHandled = (item: NeedsYouItem) => acknowledgeAttention(item, 'activity.handoff.actionFailed'); - return ( -
- sum + group.lines.length, 0) })}`} - hint={t('activity.subtitle')} - hintLabel={t('activity.about')} + const openLabel = (item: NeedsYouItem) => ( + item.messageId === undefined || item.messageId === null || item.messageId === '' ? t('activity.openPod') : t('activity.open') + ); + const openAct = (item: NeedsYouItem): RowAct => ({ + key: 'open', label: openLabel(item), glyph: , onSelect: () => openPod(item.podId, item.messageId), disabled: !item.podId, + }); + const iconKey = (act: RowAct) => ( + + ); + // A row's secondary acts: the hover cluster at desktop, the ⋯ menu on a phone (d215 activity). + const secondaryActs = (item: NeedsYouItem): RowAct[] => { + if (item.kind === 'approval') { + return [{ + key: 'deny', label: t('activity.approval.deny'), glyph: , onSelect: () => { void actOnApproval(item, 'reject'); }, disabled: actingApprovalId === item.id, + }, openAct(item)]; + } + if (item.kind === 'mention') { + return [{ + key: 'handled', + label: acknowledgingAttentionId === item.id ? t('activity.mention.working') : t('activity.mention.markHandled'), + glyph: , + onSelect: () => { void acknowledgeMention(item); }, + disabled: acknowledgingAttentionId === item.id, + }, openAct(item)]; + } + if (item.kind === 'handoff' || item.kind === 'blocked') return [openAct(item)]; + return []; + }; + const primaryKey = (item: NeedsYouItem): React.ReactNode => { + if (item.kind === 'approval') { + return ( + + ); + } + if (item.kind === 'mention') { + if (replyOpenIds.has(item.id)) return null; + return ( + + ); + } + if (item.kind === 'handoff' || item.kind === 'blocked') { + return ( + + ); + } + // A decision's only act beside its row is Open; its choices are keys inside the row. + return iconKey(openAct(item)); + }; + const rowDomId = (item: NeedsYouItem) => `activity-row-${item.id}`.replace(/[^a-zA-Z0-9_-]/g, '-'); + const movedCount = movedGroups.reduce((sum, group) => sum + group.lines.length, 0); + const startGlyph = { hire: 'agents', speak: 'chat', connect: 'plug' } as const; + + const controls = ( +
+ + className="v2-activity__window" + ariaLabel={t('activity.windowAriaLabel')} + value={window} + onChange={setWindow} + options={(['today', '7d'] as ActivityWindow[]).map((value) => ({ value, label: t(`activity.windows.${value}`) }))} /> -
-
- {(['today', '7d'] as ActivityWindow[]).map((value) => ( - - ))} -
-
{ - if (event.key === 'Escape' && scopeMenuOpen) { - event.preventDefault(); - event.stopPropagation(); // This menu restores focus; the global Escape handler blurs it. - setScopeMenuOpen(false); - scopeMenuButtonRef.current?.focus(); - } - }}> - - {scopedPods.slice(0, 2).map((pod) => ( - - ))} - {scopedPods.length > 2 && } - {scopeMenuOpen &&
{scopedPods.slice(2).map((pod) => ( - - ))}
} -
-
+ +
+ ); + const composeOpen = composeFocused || composeDraft.length > 0 || composing || Boolean(composeError); - {loading &&
} - {!loading && error &&
- {error} - -
} - {!loading && !error && recap && ( - <> - {/* The composer stays hidden while step 1 is open — nobody to wake — and returns the moment an agent exists. */} - {!startSteps.includes('hire') && ( -
-
-
- {t('activity.compose.podLabel')} -
- - {composeMenuOpen &&
- {scopedPods.map((pod, index) => ( - - ))} -
} -
-
-
-