Skip to content
Merged
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
152 changes: 151 additions & 1 deletion docs/design/artboards/d215/index.html

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@
"title": "Moved forward",
"description": "grouped by pod · newest first",
"empty": "Nothing yet today.",
"emptyWeek": "Nothing moved in the last 7 days.",
"more": "{{count}} more in {{pod}}",
"omitted": "{{count}} more updates in {{pod}} not shown",
"showLess": "Show less",
Expand All @@ -335,14 +336,15 @@
"title": "Get started",
"kicker_one": "1 step · until your first ask arrives",
"kicker_other": "{{count}} steps · until your first ask arrives",
"checking": "checking…",
"hire": {
"title": "Hire your first agent",
"description": "Pick a persona. It gets a seat in this workspace and answers when you @mention it.",
"cta": "Hire an agent"
},
"speak": {
"title": "Say something to it",
"description": "@mention it in the pod where it lives. Your first message there is the moment this becomes yours.",
"description": "Say hello in the pod where it lives. If it doesn't answer, @mention it.",
"cta": "Open {{pod}}",
"anyPod": "a pod"
},
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@
"title": "推进的工作",
"description": "按 Pod 分组 · 最新在前",
"empty": "今天还没有更新。",
"emptyWeek": "最近7天没有新进展。",
"more": "{{pod}} 还有{{count}}项",
"omitted": "{{pod}} 还有{{count}}项未显示",
"showLess": "收起",
Expand All @@ -335,14 +336,15 @@
"title": "开始使用",
"kicker_one": "还有1步 · 直到你的第一个请求到来",
"kicker_other": "还有{{count}}步 · 直到你的第一个请求到来",
"checking": "正在检查…",
"hire": {
"title": "雇用你的第一个智能体",
"description": "选一个角色。它会在这个工作空间有一个席位,你 @提及它就会回复。",
"cta": "雇用智能体"
},
"speak": {
"title": "对它说点什么",
"description": "在它所在的 Pod 中 @ 它。你在那里发出的第一条消息,就是它真正属于你的时刻。",
"description": "在它所在的 Pod 里打个招呼。如果它没回应,就 @提及它。",
"cta": "打开 {{pod}}",
"anyPod": "Pod"
},
Expand Down
75 changes: 75 additions & 0 deletions frontend/src/v2/__tests__/V2ActivityPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,82 @@ describe('V2ActivityPage', () => {
mockGet.mockImplementation(mockFor([silentSeat], [{ status: 'active' }], true));
renderPage();
expect(await screen.findByText('Nothing needs you.')).toBeInTheDocument();
// The card holds its place while the roster is in flight (d215), then drops when no step is left.
await waitFor(() => expect(screen.queryByRole('heading', { name: 'Get started' })).not.toBeInTheDocument());
});

test('day zero: Get started holds its place while the roster and connectors are in flight, then becomes the card (d215 s-activity)', async () => {
const empty = { ...recap, hasEverHadAttention: false, needsYou: [], agents: [], board: [] };
const seat = { name: 'scout', displayName: 'Scout' };
const deferred = () => {
let release: (value: unknown) => void = () => {};
const promise = new Promise((resolve) => { release = resolve; });
return { promise, release };
};
const mockWith = (registry: Promise<unknown>, connectors: Promise<unknown>, recapData = empty) => (url: string) => {
if (url === '/api/activity/decision-queue') return Promise.resolve({ data: { items: [], count: 0, countsByPod: {} } });
if (url === '/api/integrations/user/all') return connectors;
if (url.startsWith('/api/registry/pods/')) return registry;
return Promise.resolve({ data: recapData });
};

// In flight: the real head with "checking…", two rows of bars, aria-busy, no step act yet.
const roster = deferred();
const connectors = deferred();
mockGet.mockImplementation(mockWith(roster.promise, connectors.promise));
const first = renderPage();
const pending = await screen.findByTestId('activity-start-pending');
expect(pending).toHaveAttribute('aria-busy', 'true');
expect(within(pending).getByRole('heading', { name: 'Get started' })).toBeInTheDocument();
expect(within(pending).getByText('checking…')).toBeInTheDocument();
expect(pending.querySelectorAll('.v2-activity__start-row')).toHaveLength(2);
expect(within(pending).queryByRole('button')).not.toBeInTheDocument();
// It sits where the card will sit: above Needs you.
expect(pending.compareDocumentPosition(screen.getByRole('heading', { name: 'Needs you' })) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
// The roster alone is not enough — the connector count still decides whether step 3 is a row.
await act(async () => { roster.release({ data: { agents: [seat], callerSpoke: false } }); });
expect(screen.getByTestId('activity-start-pending')).toBeInTheDocument();
await act(async () => { connectors.release({ data: [] }); });
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("Say hello in the pod where it lives. If it doesn't answer, @mention it.")).toBeInTheDocument();
first.unmount();

// Steps come back empty: the placeholder drops and no card replaces it.
const roster2 = deferred();
mockGet.mockImplementation(mockWith(roster2.promise, Promise.resolve({ data: [{ status: 'active' }] })));
const second = renderPage();
expect(await screen.findByTestId('activity-start-pending')).toBeInTheDocument();
await act(async () => { roster2.release({ data: { agents: [seat], callerSpoke: true } }); });
await waitFor(() => expect(screen.queryByTestId('activity-start-pending')).not.toBeInTheDocument());
expect(screen.queryByRole('heading', { name: 'Get started' })).not.toBeInTheDocument();
second.unmount();

// Fail-closed: an account that has had an ask, or whose history is unknown, never reserves the slot.
for (const hasEverHadAttention of [true, null]) {
const never = deferred();
mockGet.mockImplementation(mockWith(never.promise, never.promise, { ...empty, hasEverHadAttention }));
const view = renderPage();
expect(await screen.findByText('Nothing needs you.')).toBeInTheDocument();
expect(screen.queryByTestId('activity-start-pending')).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Get started' })).not.toBeInTheDocument();
view.unmount();
}
});

test('Moved forward names its window when empty (d215 s-activity)', async () => {
mockGet.mockImplementation((url: string) => Promise.resolve({ data: url === '/api/activity/decision-queue'
? { 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' }));
expect(await screen.findByText('Nothing moved in the last 7 days.')).toBeInTheDocument();
expect(screen.queryByText('Nothing yet today.')).not.toBeInTheDocument();

await act(async () => { await i18n.changeLanguage('zh-CN'); });
expect(await screen.findByText('最近7天没有新进展。')).toBeInTheDocument();
});

test('day zero: a roster without the field, or a roster that fails, never reads as spoken (#1648)', async () => {
Expand Down
52 changes: 46 additions & 6 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3018,10 +3018,10 @@ describe('v2 layout invariants (CSS rule presence)', () => {
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 full-width rule it beats, inside that same block.
const fullWidth = '.v2-activity__decision-footer > button { flex: 1 1 100%;';
expect(v2.indexOf(footer44)).toBeGreaterThan(v2.indexOf(fullWidth));
expect(enclosingMedia(fullWidth)).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('<button type="button" onClick={() => markHandoffHandled(item)}');
Expand All @@ -3034,8 +3034,8 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(badge).toContain('box-shadow: 0 0 0 2px var(--v2-shell-bg)');
// Moved forward: no section count; N more in <pod>.
expect(activityPage).toContain("pod: shortPodName(group.name)");
// ≤760: toggles stack, actions drop to 44px full width.
expect(v2).toContain('.v2-root .v2-activity__queue-actions > button { flex: 1 1 100%; min-height: 44px; }');
// ≤760: toggles stack, actions drop under the copy at 44px — on ONE row (d215 s-activity, see below).
expect(v2).toContain('.v2-root .v2-activity__queue-actions > button { flex: 1 1 auto; min-height: 44px; }');
// The reply editor's nested Send takes only height at ≤760 — never a descendant width rule (66493/66498).
expect(v2).toContain('.v2-root .v2-activity__reply > button { min-height: 44px; }');
expect(v2).not.toContain('.v2-root .v2-activity__queue-actions button { flex: 1 1 100%;');
Expand All @@ -3047,6 +3047,46 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(stackedActionsAt).toBeGreaterThan(desktopActionsAt);
});

test('Activity: at ≤760 a card\'s acts are one row — the text act fills it, the 44px squares sit beside it (walk 11-02, d215 s-activity)', () => {
// The walk measured each Needs-you card at 390 stacking Reply / ✓ / ↗ on separate lines (~60px
// a card) because the ≤760 rule gave every direct-child button `flex: 1 1 100%`. The drawn
// row: Reply (or Approve, or Mark handled) grows into the line, the icon keys stay `flex: none`.
const enclosing = (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 textAct = '.v2-root .v2-activity__queue-actions > button { flex: 1 1 auto; min-height: 44px; }';
const iconAct = '.v2-root .v2-activity__queue-actions button.v2-activity__queue-action--icon { width: 44px; min-width: 44px; flex: none; }';
expect(enclosing(textAct)).toBe('@media (max-width: 760px)');
expect(enclosing(iconAct)).toBe('@media (max-width: 760px)');
// The squares' `flex: none` must follow the grow rule it overrides (equal specificity + 1 class wins
// anyway, but order keeps the intent legible), and no full-width basis may come back for direct acts.
expect(v2.indexOf(iconAct)).toBeGreaterThan(v2.indexOf(textAct));
expect(v2).not.toMatch(/\.v2-activity__queue-actions > button \{ flex: 1 1 100%/);
// Decision options and their footer may still stack full width at ≤760 — unchanged by the walk.
expect(v2).toContain('.v2-activity__queue-row.v2-activity__queue-row--decision .v2-activity__option-choice { flex: 1 1 100%; max-width: none; }');
// An open reply editor still takes the whole row at ≤640, pushing the squares to the next line.
expect(v2).toMatch(/@media \(max-width: 640px\) \{\n \.v2-activity__reply \{ flex-basis: 100%; \}/);
// The older ≤640 rules (`grid-column: 2`, `flex-wrap: wrap`, the descendant 44px floor) are kept,
// not deleted: the descendant floor still lifts nested acts the ≤760 block never names (the
// reply's and Other…'s Send), so the block is not dead.
expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-root \.v2-activity__queue-actions button \{ min-height: 44px; \}/);
});

test('Activity: Get started holds its place while its steps are in flight (d215 s-activity)', () => {
// Same line boxes as the loaded rows: 20px bars, a 32px key that turns 44px full width at ≤760.
expect(activityPage).toContain('className="v2-activity__start v2-activity__start--pending" aria-busy="true"');
expect(ruleBody(v2, '.v2-activity__start-bar')).toContain('height: 20px');
const key = ruleBody(v2, '.v2-activity__start-bar--key');
expect(key).toContain('height: 32px');
expect(ruleBody(v2, '.v2-activity__start-bar--key', { within: mediaBlockContaining(v2, '.v2-activity__start-bar--key') }))
.toContain('height: 44px');
});

test('Activity keeps the Direction C bar, inbox measure, and moved-forward grouping', () => {
expect(v2App).toContain("'v2-feature--activity'");
expect(featurePage).toContain('className?: string;');
Expand Down
30 changes: 28 additions & 2 deletions frontend/src/v2/components/V2ActivityPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,12 @@ const V2ActivityPage: React.FC = () => {
const target = seatedPods[0]?.podId ?? recap?.pods[0]?.id;
return target ? { id: target, name: recap?.pods.find((pod) => pod.id === target)?.name ?? '' } : null;
}, [seatedPods, recap]);
// Get started never pops in (d215 s-activity, walk 11-02): on an account the card can show for,
// its steps wait on the roster and the connector count. While either is in flight the card
// holds its place as a same-height placeholder, so Needs you does not move when they land.
// Eligibility is the card's own gate, so a failed or unknown history still shows nothing.
const startEligible = queueCount === 0 && recap?.hasEverHadAttention === false && podId === 'all';
const startPending = startEligible && (seatPods === null || connectorCount === null);
const startStepTarget = (step: 'hire' | 'speak' | 'connect') => {
if (step === 'hire') return '/v2/agents';
if (step === 'speak') return speakPod ? `/v2/pods/${speakPod.id}` : '/v2';
Expand Down Expand Up @@ -1218,7 +1224,27 @@ const V2ActivityPage: React.FC = () => {
{composeError && <div className="v2-activity__action-error" role="alert">{composeError}</div>}
</section>
)}
{queueCount === 0 && recap.hasEverHadAttention === false && startSteps.length > 0 && (
{startPending && (
<section className="v2-activity__start v2-activity__start--pending" aria-busy="true" aria-labelledby="activity-get-started" data-testid="activity-start-pending">
<div className="v2-activity__start-head">
<h2 id="activity-get-started">{t('activity.getStarted.title')}</h2>
<span className="v2-activity__start-kicker">{t('activity.getStarted.checking')}</span>
</div>
{/* A step's description is one line at desktop and two on a phone; the second bar shows only ≤760. */}
{['first', 'second'].map((row) => (
<div key={row} className="v2-activity__start-row" aria-hidden="true">
<span className="v2-activity__start-num v2-activity__start-num--pending" />
<div className="v2-activity__start-copy">
<i className="v2-activity__start-bar v2-activity__start-bar--title" />
<i className="v2-activity__start-bar" />
<i className="v2-activity__start-bar v2-activity__start-bar--phone" />
</div>
<div className="v2-activity__start-act"><i className="v2-activity__start-bar v2-activity__start-bar--key" /></div>
</div>
))}
</section>
)}
{!startPending && startEligible && startSteps.length > 0 && (
<section className="v2-activity__start" aria-labelledby="activity-get-started">
<div className="v2-activity__start-head">
<h2 id="activity-get-started">{t('activity.getStarted.title')}</h2>
Expand Down Expand Up @@ -1469,7 +1495,7 @@ const V2ActivityPage: React.FC = () => {
<h2 id="activity-moved-forward">{t('activity.movedForward.title')}</h2>
<V2Hint label={t('activity.movedForward.about')}>{t('activity.movedForward.description')}</V2Hint>
</div>
{movedGroups.length === 0 ? <div className="v2-activity__empty v2-activity__empty--plain"><strong>{t('activity.movedForward.empty')}</strong></div> : <div className="v2-activity__moved-list">
{movedGroups.length === 0 ? <div className="v2-activity__empty v2-activity__empty--plain"><strong>{t(window === '7d' ? 'activity.movedForward.emptyWeek' : 'activity.movedForward.empty')}</strong></div> : <div className="v2-activity__moved-list">
{movedGroups.map((group) => {
const visibleCount = movedVisibleCounts[group.id] || 3;
const visible = group.lines.slice(0, Math.min(visibleCount, group.lines.length));
Expand Down
Loading
Loading