From aee774bd2a5bbaf0e836dcb354176de23fce6ceb Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:41:39 -0700 Subject: [PATCH 1/2] feat(activity): add sectioned activity layout --- frontend/src/i18n/locales/en.json | 15 ++ frontend/src/i18n/locales/zh-CN.json | 15 ++ .../src/v2/__tests__/V2ActivityPage.test.tsx | 38 ++++- .../v2/__tests__/v2-layout-invariants.test.ts | 17 ++ frontend/src/v2/components/V2ActivityPage.tsx | 159 ++++++++++++++---- frontend/src/v2/v2.css | 144 +++++++++++++++- 6 files changed, 356 insertions(+), 32 deletions(-) diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 2ed2dc42e..5519bfe62 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -71,6 +71,21 @@ "press": "Ready for your press" } }, + "views": { + "ariaLabel": "Activity view", + "label": "Show", + "sections": "Sections", + "timeline": "Timeline" + }, + "timeline": { + "eyebrow": "Chronological view", + "title": "Timeline", + "description": "Agent updates and board changes together, newest first.", + "emptyTitle": "No activity in this window", + "emptyDescription": "Agent updates and board changes will appear here as they happen.", + "agentUpdate": "Agent update", + "boardChange": "Board change" + }, "dayZero": { "kind": "Get started", "guide": { diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index f9746a3b9..fec4c16e2 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -71,6 +71,21 @@ "press": "待你合并" } }, + "views": { + "ariaLabel": "动态视图", + "label": "显示", + "sections": "分区", + "timeline": "时间线" + }, + "timeline": { + "eyebrow": "按时间查看", + "title": "时间线", + "description": "智能体更新和看板变更按时间合并,最新在前。", + "emptyTitle": "此时间范围内没有动态", + "emptyDescription": "智能体更新和看板变更发生时会显示在这里。", + "agentUpdate": "智能体更新", + "boardChange": "看板变更" + }, "dayZero": { "kind": "开始使用", "guide": { diff --git a/frontend/src/v2/__tests__/V2ActivityPage.test.tsx b/frontend/src/v2/__tests__/V2ActivityPage.test.tsx index 4a015b1d2..471c5eb3e 100644 --- a/frontend/src/v2/__tests__/V2ActivityPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ActivityPage.test.tsx @@ -100,7 +100,12 @@ describe('V2ActivityPage', () => { // findBy, not getBy: the header renders unconditionally, so awaiting it // proves nothing about data arrival — and the queue+recap Promise.all // adds a microtask hop the old single-request race happened to win. - expect(await screen.findByRole('heading', { name: 'Needs you' })).toBeInTheDocument(); + const needsYouHeading = await screen.findByRole('heading', { name: 'Needs you' }); + expect(needsYouHeading).toBeInTheDocument(); + // The human decision queue is the first activity surface; composing a + // new message must not bury an existing ask below another card. + const composeHeading = screen.getByRole('heading', { name: 'Tell your agents what is on your mind' }); + expect(needsYouHeading.compareDocumentPosition(composeHeading) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(screen.getByText('Review requested')).toBeInTheDocument(); // A board press remains an Open-board action; DecisionRequest cards use // their declared options rather than deriving actions from task prose. @@ -137,6 +142,37 @@ describe('V2ActivityPage', () => { expect(screen.getByTestId('current-path')).toHaveTextContent('/v2/pods/pod-1'); }); + test('keeps the sectioned evidence view as the default and projects the same facts into an opt-in timeline', async () => { + const timelineRecap = { + ...recap, + agents: [{ + ...recap.agents[0], + updates: [{ ...recap.agents[0].updates[0], timestamp: '2026-08-26T10:00:00.000Z' }], + }], + board: [{ ...recap.board[0], updatedAt: '2026-08-26T11:00:00.000Z' }], + }; + mockGet.mockImplementation((url: string) => Promise.resolve({ + data: url === '/api/activity/decision-queue' ? decisionQueue : timelineRecap, + })); + renderPage(); + await screen.findByText('Review requested'); + + expect(screen.getByRole('button', { name: 'Sections' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('heading', { name: 'What your agents did' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Board' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Timeline' })); + expect(screen.getByRole('button', { name: 'Timeline' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('heading', { name: 'Timeline' })).toBeInTheDocument(); + expect(screen.getByText('Agent update')).toBeInTheDocument(); + expect(screen.getByText('Board change')).toBeInTheDocument(); + expect(screen.getByText('Checks passed.')).toBeInTheDocument(); + expect(screen.getByText('Activity tab')).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'What your agents did' })).not.toBeInTheDocument(); + const timelineText = screen.getByRole('list').textContent || ''; + expect(timelineText.indexOf('Activity tab')).toBeLessThan(timelineText.indexOf('Checks passed.')); + }); + test('acknowledges a mention explicitly instead of treating a feed read as acknowledgement', async () => { mockGet .mockResolvedValueOnce({ data: recap }) diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 36b92ea8a..85b3c0179 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -490,6 +490,23 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-activity__board-row \{[\s\S]*?grid-template-columns: minmax\(0, 1fr\)/); }); + test('Activity keeps attention first and uses a responsive sectioned evidence layout', () => { + // Needs-you remains a full-width first section. The lower evidence lenses + // earn two balanced desktop tracks, then collapse before a 390px capture + // can turn either agent recap or board change into a narrow side column. + const sectioned = ruleBody(v2, '.v2-activity__sectioned'); + expect(sectioned).toContain('repeat(2, minmax(0, 1fr))'); + expect(sectioned).toContain('align-items: start'); + expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-activity__sectioned \{ grid-template-columns: minmax\(0, 1fr\); \}/); + + // Timeline is an opt-in projection that stays in the same shrinkable + // column grammar as the sectioned surface; it cannot create page-wide + // horizontal scrolling from a long pod, task, or agent name. + const timelineRow = ruleBody(v2, '.v2-activity__timeline-row'); + expect(timelineRow).toContain('14px minmax(0, 1fr)'); + expect(timelineRow).toContain('min-width: 0'); + }); + 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-accent)'); expect(ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__queue-action--secondary')).toContain('background: var(--v2-surface-hover)'); diff --git a/frontend/src/v2/components/V2ActivityPage.tsx b/frontend/src/v2/components/V2ActivityPage.tsx index 57bd59a38..68cecda74 100644 --- a/frontend/src/v2/components/V2ActivityPage.tsx +++ b/frontend/src/v2/components/V2ActivityPage.tsx @@ -6,6 +6,7 @@ import V2Avatar from './V2Avatar'; import { requestFirstRunGuide } from '../firstRunGuide'; type ActivityWindow = 'today' | '7d'; +type ActivityView = 'sections' | 'timeline'; interface ActivityUpdate { id: string; @@ -53,6 +54,17 @@ interface BoardItem { lastUpdate: { text: string; author: string; createdAt: string | null } | null; } +interface TimelineItem { + id: string; + kind: 'agent' | 'board'; + title: string; + detail: string; + podId: string | null; + podName: string; + timestamp: string | null; + status?: BoardItem['status']; +} + interface ActivityRecap { pods: Array<{ id: string; name: string }>; needsYou: NeedsYouItem[]; @@ -75,6 +87,7 @@ const V2ActivityPage: React.FC = () => { const navigate = useNavigate(); const { t } = useTranslation(); const [window, setWindow] = useState('today'); + const [view, setView] = useState('sections'); const [podId, setPodId] = useState('all'); const [recap, setRecap] = useState(null); const [loading, setLoading] = useState(true); @@ -304,6 +317,35 @@ const V2ActivityPage: React.FC = () => { && recap?.agents.length === 0 && recap.board.length === 0; + // The timeline is a second projection of the existing recap facts. It + // deliberately adds no feed endpoint or event semantics: the sectioned + // view remains the default because it preserves the agent and board lens. + const timeline: TimelineItem[] = recap ? [ + ...recap.agents.flatMap((agent) => agent.updates.map((update) => ({ + id: `agent-${agent.id}-${update.id}`, + kind: 'agent' as const, + title: agent.name, + detail: update.content, + podId: update.podId, + podName: update.podName, + timestamp: update.timestamp, + }))), + ...recap.board.map((item) => ({ + id: `board-${item.id}`, + kind: 'board' as const, + title: item.title, + detail: item.lastUpdate + ? `${item.lastUpdate.author ? `${item.lastUpdate.author}: ` : ''}${item.lastUpdate.text}` + : item.taskId, + podId: item.podId, + podName: item.podName, + timestamp: item.updatedAt, + status: item.status, + })), + ].sort((left, right) => ( + new Date(right.timestamp || 0).getTime() - new Date(left.timestamp || 0).getTime() + )) : []; + return (
@@ -339,36 +381,6 @@ const V2ActivityPage: React.FC = () => { {!loading && error &&
{error}
} {!loading && !error && recap && ( <> -
-
-
-
{t('activity.compose.eyebrow')}
-

{t('activity.compose.title')}

-
- -
-
-