Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,20 @@
"press": "Ready for your press"
}
},
"views": {
"ariaLabel": "Activity view",
"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": {
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,20 @@
"press": "待你合并"
}
},
"views": {
"ariaLabel": "动态视图",
"sections": "分区",
"timeline": "时间线"
},
"timeline": {
"eyebrow": "按时间查看",
"title": "时间线",
"description": "智能体更新和看板变更按时间合并,最新在前。",
"emptyTitle": "此时间范围内没有动态",
"emptyDescription": "智能体更新和看板变更发生时会显示在这里。",
"agentUpdate": "智能体更新",
"boardChange": "看板变更"
},
"dayZero": {
"kind": "开始使用",
"guide": {
Expand Down
38 changes: 37 additions & 1 deletion frontend/src/v2/__tests__/V2ActivityPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 })
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,22 @@ 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
// stack full-width too: a split makes long agent evidence and board titles
// truncate at desktop widths before the user has any reason to expect it.
const sectioned = ruleBody(v2, '.v2-activity__sectioned');
expect(sectioned).toContain('grid-template-columns: minmax(0, 1fr)');
expect(sectioned).toContain('align-items: start');

// 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)');
Expand Down
157 changes: 127 additions & 30 deletions frontend/src/v2/components/V2ActivityPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import V2Avatar from './V2Avatar';
import { requestFirstRunGuide } from '../firstRunGuide';

type ActivityWindow = 'today' | '7d';
type ActivityView = 'sections' | 'timeline';

interface ActivityUpdate {
id: string;
Expand Down Expand Up @@ -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[];
Expand All @@ -75,6 +87,7 @@ const V2ActivityPage: React.FC = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const [window, setWindow] = useState<ActivityWindow>('today');
const [view, setView] = useState<ActivityView>('sections');
const [podId, setPodId] = useState('all');
const [recap, setRecap] = useState<ActivityRecap | null>(null);
const [loading, setLoading] = useState(true);
Expand Down Expand Up @@ -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 (
<div className="v2-activity" aria-busy={loading}>
<header className="v2-activity__header">
Expand All @@ -325,6 +367,19 @@ const V2ActivityPage: React.FC = () => {
</button>
))}
</div>
<div className="v2-activity__window" role="group" aria-label={t('activity.views.ariaLabel')}>
{(['sections', 'timeline'] as ActivityView[]).map((value) => (
<button
key={value}
type="button"
className={`v2-activity__window-button${view === value ? ' v2-activity__window-button--active' : ''}`}
onClick={() => setView(value)}
aria-pressed={view === value}
>
{t(`activity.views.${value}`)}
</button>
))}
</div>
<label className="v2-activity__scope">
<span className="v2-sr-only">{t('activity.podScopeLabel')}</span>
<select value={podId} onChange={(event) => setPodId(event.target.value)}>
Expand All @@ -339,36 +394,6 @@ const V2ActivityPage: React.FC = () => {
{!loading && error && <div className="v2-activity__error" role="alert">{error}</div>}
{!loading && !error && recap && (
<>
<section className="v2-activity__compose" aria-labelledby="activity-compose-title">
<div className="v2-activity__compose-heading">
<div>
<div className="v2-activity__eyebrow">{t('activity.compose.eyebrow')}</div>
<h2 id="activity-compose-title">{t('activity.compose.title')}</h2>
</div>
<label className="v2-activity__compose-pod">
<span>{t('activity.compose.podLabel')}</span>
<select value={composePodId} onChange={(event) => setComposePodId(event.target.value)}>
{(recap.pods || []).map((pod) => <option key={pod.id} value={pod.id}>{pod.name}</option>)}
</select>
</label>
</div>
<div className="v2-activity__compose-entry">
<textarea
aria-label={t('activity.compose.placeholder')}
rows={3}
placeholder={t('activity.compose.placeholder')}
value={composeDraft}
onChange={(event) => setComposeDraft(event.target.value)}
onKeyDown={(event) => { if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') sendCompose(); }}
disabled={composing || !composePodId}
/>
<button type="button" aria-label={t('activity.compose.sendAriaLabel')} onClick={sendCompose} disabled={composing || !composePodId || !composeDraft.trim()}>
{composing ? t('activity.compose.working') : t('activity.compose.send')}
</button>
</div>
{composeError && <div className="v2-activity__action-error" role="alert">{composeError}</div>}
</section>

<div className="v2-activity__sections">
<section className="v2-activity__section" aria-labelledby="activity-needs-you">
<div className="v2-activity__section-heading">
Expand Down Expand Up @@ -544,6 +569,38 @@ const V2ActivityPage: React.FC = () => {
{actionError && <div className="v2-activity__action-error" role="alert">{actionError}</div>}
</section>

<section className="v2-activity__compose" aria-labelledby="activity-compose-title">
<div className="v2-activity__compose-heading">
<div>
<div className="v2-activity__eyebrow">{t('activity.compose.eyebrow')}</div>
<h2 id="activity-compose-title">{t('activity.compose.title')}</h2>
</div>
<label className="v2-activity__compose-pod">
<span>{t('activity.compose.podLabel')}</span>
<select value={composePodId} onChange={(event) => setComposePodId(event.target.value)}>
{(recap.pods || []).map((pod) => <option key={pod.id} value={pod.id}>{pod.name}</option>)}
</select>
</label>
</div>
<div className="v2-activity__compose-entry">
<textarea
aria-label={t('activity.compose.placeholder')}
rows={3}
placeholder={t('activity.compose.placeholder')}
value={composeDraft}
onChange={(event) => setComposeDraft(event.target.value)}
onKeyDown={(event) => { if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') sendCompose(); }}
disabled={composing || !composePodId}
/>
<button type="button" aria-label={t('activity.compose.sendAriaLabel')} onClick={sendCompose} disabled={composing || !composePodId || !composeDraft.trim()}>
{composing ? t('activity.compose.working') : t('activity.compose.send')}
</button>
</div>
{composeError && <div className="v2-activity__action-error" role="alert">{composeError}</div>}
</section>

{view === 'sections' ? (
<div className="v2-activity__sectioned">
<section className="v2-activity__section" aria-labelledby="activity-agents">
<div className="v2-activity__section-heading">
<div>
Expand Down Expand Up @@ -621,6 +678,46 @@ const V2ActivityPage: React.FC = () => {
)}
</section>
</div>
) : (
<section className="v2-activity__section v2-activity__timeline-section" aria-labelledby="activity-timeline">
<div className="v2-activity__section-heading">
<div>
<div className="v2-activity__eyebrow">{t('activity.timeline.eyebrow')}</div>
<h2 id="activity-timeline">{t('activity.timeline.title')}</h2>
</div>
<p>{t('activity.timeline.description')}</p>
</div>
{timeline.length === 0 ? (
<div className="v2-activity__empty">
<strong>{t('activity.timeline.emptyTitle')}</strong>
<span>{t('activity.timeline.emptyDescription')}</span>
</div>
) : (
<ol className="v2-activity__timeline">
{timeline.map((item) => (
<li key={item.id} className={`v2-activity__timeline-row v2-activity__timeline-row--${item.kind}`}>
<span className="v2-activity__timeline-mark" aria-hidden="true" />
<button type="button" onClick={() => openPod(item.podId)} disabled={!item.podId}>
<span className="v2-activity__timeline-meta">
{item.kind === 'board' && item.status && (
<span className={`v2-activity__status v2-activity__status--${item.status}`}>
{t(`activity.board.status.${item.status}`)}
</span>
)}
<span>{item.kind === 'agent' ? t('activity.timeline.agentUpdate') : t('activity.timeline.boardChange')}</span>
<span>{item.podName}</span>
{item.timestamp && <span>{relativeTime(item.timestamp)}</span>}
</span>
<strong>{item.title}</strong>
<span className="v2-activity__timeline-detail">{item.detail}</span>
</button>
</li>
))}
</ol>
)}
</section>
)}
</div>
</>
)}
<footer className="v2-activity__footer">{t('activity.footer')}</footer>
Expand Down
Loading
Loading