Skip to content
Merged
68 changes: 65 additions & 3 deletions docs/design/artboards/d215/index.html

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2609,7 +2609,11 @@
"foot": "{{shown}} of {{total}} · newest first",
"more": "{{count}} more",
"loadingMore": "Loading…",
"empty": "Nothing shared yet. Attach a file in any pod and it lands here.",
"dayZero": {
"title": "Nothing shared yet",
"body": "Files you and your agents share in a pod land here: docs, images and pages.",
"open": "Open {{pod}}"
},
"emptyFiltered": "Nothing matches. Clear a filter or the search.",
"loadFailed": "Could not load artifacts."
},
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2611,7 +2611,11 @@
"foot": "{{shown}} / {{total}} · 最新在前",
"more": "还有{{count}}项",
"loadingMore": "加载中…",
"empty": "还没有共享内容。在任意 Pod 里附加文件,就会出现在这里。",
"dayZero": {
"title": "还没有分享内容",
"body": "你和你的智能体在 Pod 里分享的文件会出现在这里,包括文档、图片和页面。",
"open": "打开 {{pod}}"
},
"emptyFiltered": "没有匹配项。清除筛选或搜索。",
"loadFailed": "无法加载产物。"
},
Expand Down
109 changes: 103 additions & 6 deletions frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @ts-nocheck
// Artifacts, direction C (ScaleArtifacts.dc.html) — PR 5.
import React from 'react';
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
import { render, screen, fireEvent, waitFor, within, cleanup, act } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import axios from 'axios';
import V2ArtifactsPage, { extOf, formatSize, whenLabel } from '../components/V2ArtifactsPage';
Expand Down Expand Up @@ -75,6 +75,10 @@ describe('V2ArtifactsPage (direction C, PR 5)', () => {
expect(within(rows[3]).getAllByText('Connectors v2').length).toBeGreaterThan(0);
expect(screen.getByText('3 of 3 · newest first')).toBeInTheDocument();
expect(axios.get).toHaveBeenCalledWith('/api/artifacts', expect.objectContaining({ params: { limit: 50 } }));
// Busy is unchanged: the controls and the count stay; no day-zero card.
expect(screen.getByRole('group', { name: 'Kind' })).toBeInTheDocument();
expect(screen.getByRole('searchbox', { name: 'Search files' })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Nothing shared yet' })).not.toBeInTheDocument();
});

test('kind, pod and search filters travel through the URL to the one query', async () => {
Expand Down Expand Up @@ -121,11 +125,104 @@ describe('V2ArtifactsPage (direction C, PR 5)', () => {
open.mockRestore();
});

test('empty states are honest: nothing shared vs nothing matching', async () => {
axios.get.mockImplementation((url) => Promise.resolve({ data: url === '/api/pods' ? [] : { items: [], nextCursor: null, total: 0 } }));
// Day zero (d215 s-artifacts): nothing shared and no filter set → no controls, no count, one card.
const renderEmpty = (initial, { pods = [] } = {}) => {
axios.get.mockImplementation((url) => {
if (url === '/api/pods') return Promise.resolve({ data: pods });
if (url === '/api/artifacts') return Promise.resolve({ data: { items: [], nextCursor: null, total: 0, limit: 50 } });
return Promise.resolve({ data: {} });
});
return render(<MemoryRouter initialEntries={[initial]}><V2ArtifactsPage /></MemoryRouter>);
};
const workspace = { _id: 'w1', name: 'My Workspace', createdAt: '2026-10-01T00:00:00.000Z' };

test('day zero: no kind, pod or search controls and no count; one card whose key opens My Workspace', async () => {
const { container } = renderEmpty('/v2/artifacts', { pods: [workspace] });
expect(await screen.findByRole('heading', { name: 'Nothing shared yet' })).toBeInTheDocument();
expect(screen.getByText('Files you and your agents share in a pod land here: docs, images and pages.')).toBeInTheDocument();
const open = await screen.findByRole('link', { name: 'Open My Workspace' });
expect(open).toHaveAttribute('href', '/v2/pods/w1');
expect(open).toHaveClass('v2-artifacts__open');
expect(screen.queryByRole('group', { name: 'Kind' })).not.toBeInTheDocument();
expect(screen.queryByRole('group', { name: 'Pod' })).not.toBeInTheDocument();
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument();
expect(container.querySelector('.v2-artifacts__controls')).toBeNull();
expect(container.querySelector('.v2-page-head__meta')).toBeNull();
expect(screen.queryByText(/Nothing matches/)).not.toBeInTheDocument();
});

test('day zero opens the oldest pod, the one signup creates, whatever order /api/pods lists', async () => {
const team = { _id: 'p2', name: 'Launch room', createdAt: '2026-10-05T00:00:00.000Z' };
const later = { _id: 'p3', name: 'Field notes', createdAt: '2026-10-09T00:00:00.000Z' };
// /api/pods lists by last update, newest first.
renderEmpty('/v2/artifacts', { pods: [later, team, workspace] });
expect(await screen.findByRole('link', { name: 'Open My Workspace' })).toHaveAttribute('href', '/v2/pods/w1');
// No extra lookups: the choice is made from /api/pods alone.
expect(axios.get.mock.calls.map(([url]) => url).sort()).toEqual(['/api/artifacts', '/api/pods']);
});

test('clearing a filter on a busy account never shows day zero or drops the controls', async () => {
// YC S27 prep holds nothing, so ?podId=p3 is a filtered empty result on an account with files.
const { container } = renderPage('/v2/artifacts?podId=p3');
expect(await screen.findByText('Nothing matches. Clear a filter or the search.')).toBeInTheDocument();
const seen = [];
const observer = new MutationObserver((records) => records.forEach((record) => {
record.addedNodes.forEach((node) => { if (node.classList) seen.push(`add:${node.className}`); });
record.removedNodes.forEach((node) => { if (node.classList) seen.push(`rm:${node.className}`); });
}));
observer.observe(container, { childList: true, subtree: true });
const all = within(screen.getByRole('group', { name: 'Pod' })).getByRole('button', { name: 'All pods' });
all.focus();
fireEvent.click(all);
await screen.findByRole('button', { name: 'workspace-at-scale.html' });
observer.disconnect();
expect(seen.filter((entry) => /v2-artifacts__(zero|controls)/.test(entry))).toEqual([]);
expect(document.activeElement).toBe(within(screen.getByRole('group', { name: 'Pod' })).getByRole('button', { name: 'All pods' }));
});

test('day zero with no pods keeps the card and omits the key', async () => {
renderEmpty('/v2/artifacts');
expect(await screen.findByRole('heading', { name: 'Nothing shared yet' })).toBeInTheDocument();
await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/pods', expect.anything()));
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});

test('a filtered empty result keeps the controls, the count and the dashed line, not the card', async () => {
const { container } = renderEmpty('/v2/artifacts?kind=image', { pods: [workspace] });
expect(await screen.findByText('Nothing matches. Clear a filter or the search.')).toBeInTheDocument();
expect(screen.getByRole('group', { name: 'Kind' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Images' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('searchbox', { name: 'Search files' })).toBeInTheDocument();
expect(container.querySelector('.v2-page-head__meta')).toHaveTextContent('0');
expect(screen.queryByRole('heading', { name: 'Nothing shared yet' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: /^Open / })).not.toBeInTheDocument();
});

test('typing a search before the first empty answer lands keeps the search box and focus', async () => {
let answer;
axios.get.mockImplementation((url) => {
if (url === '/api/pods') return Promise.resolve({ data: [workspace] });
if (url === '/api/artifacts') return new Promise((resolve) => { answer = resolve; });
return Promise.resolve({ data: {} });
});
render(<MemoryRouter initialEntries={['/v2/artifacts']}><V2ArtifactsPage /></MemoryRouter>);
expect(await screen.findByText(/Nothing shared yet/)).toBeInTheDocument();
render(<MemoryRouter initialEntries={['/v2/artifacts?kind=page']}><V2ArtifactsPage /></MemoryRouter>);
expect(await screen.findByText(/Nothing matches/)).toBeInTheDocument();
const search = screen.getByRole('searchbox', { name: 'Search files' });
search.focus();
fireEvent.change(search, { target: { value: 'pl' } });
// The unfiltered answer lands inside the 250ms debounce, before ?q reaches the URL.
await act(async () => { answer({ data: { items: [], nextCursor: null, total: 0, limit: 50 } }); });
expect(screen.queryByRole('heading', { name: 'Nothing shared yet' })).not.toBeInTheDocument();
expect(screen.getByRole('searchbox', { name: 'Search files' })).toBe(search);
expect(document.activeElement).toBe(search);
});

test.each([
['a search', '/v2/artifacts?q=unmatched'],
['a pod', '/v2/artifacts?podId=w1'],
])('%s with no matches keeps the controls and the filtered line, never day zero', async (_label, url) => {
const { container } = renderEmpty(url, { pods: [workspace] });
expect(await screen.findByText('Nothing matches. Clear a filter or the search.')).toBeInTheDocument();
expect(container.querySelector('.v2-artifacts__controls')).not.toBeNull();
expect(screen.queryByRole('heading', { name: 'Nothing shared yet' })).not.toBeInTheDocument();
});
});
25 changes: 25 additions & 0 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1066,6 +1066,31 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(ruleBody(v2, '.v2-root button.v2-workspace-inspector__all-files')).toContain('color: var(--v2-accent-text)');
});

test('Artifacts day zero (d215 s-artifacts): one bordered card, an ink key, 44px full width at ≤760', () => {
const zero = ruleBody(v2, '.v2-artifacts__zero');
// Bordered, not dashed: the dashed line is the filtered-empty state.
expect(zero).toContain('border: 1px solid var(--v2-border)');
expect(zero).toContain('border-radius: var(--v2-radius)');
const key = ruleBody(v2, '.v2-root a.v2-artifacts__open');
expect(key).toContain('background: var(--v2-ink)');
expect(key).toContain('color: var(--v2-on-ink)');
expect(key).toContain('min-height: 32px');
// Pod names have no length limit: the label wraps inside the card instead of running past it.
expect(key).toContain('overflow-wrap: anywhere');
expect(key).not.toContain('nowrap');
// The 44px rule must sit INSIDE a ≤760 block, not at top level or in a narrower query:
// resolve the media block that actually encloses it (the enclosingMedia pattern above).
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;
};
expect(enclosingMedia('.v2-root a.v2-artifacts__open { display: flex; width: 100%; min-height: 44px; }')).toBe('@media (max-width: 760px)');
});

test('workspace inspector is the small three-card replacement, with a phone sheet rather than legacy tabs', () => {
// TASK-129 replaces the 86KB tabbed inspector in the same cutover as the
// artboard cards. Keeping the old component anywhere in the route would
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/v2/components/V2ActivityPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -603,7 +603,9 @@ const V2ActivityPage: React.FC = () => {
}, [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.
// holds its place as a placeholder the height of the two-step day-zero card, so in that case
// Needs you does not move when they land. A one- or three-step result shifts it by one row,
// and an empty result removes the card.
// 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);
Expand Down
Loading
Loading