;
+
+const NOW = Date.parse('2026-09-06T12:00:00.000Z');
+const hoursAgo = (hours: number) => new Date(NOW - hours * 3600 * 1000).toISOString();
+const human = (id) => ({ _id: id, username: id, isBot: false });
+const agent = (id) => ({ _id: id, username: id, isBot: true });
+const pod = (id, name, type, members, extra = {}) => ({
+ _id: id,
+ name,
+ type,
+ members,
+ lastMessage: { content: 'x', createdAt: hoursAgo(1), username: 'me' },
+ ...extra,
+});
+
+// 12 pods: 2 pinned, 9 rooms, 1 DM — enough to overflow Recent (8) and fill Everything.
+const fleet = [
+ pod('sharpen', 'Sharpen', 'team', [human('me'), human('a')], { lastMessage: { content: 'x', createdAt: hoursAgo(0.15), username: 'a' } }),
+ pod('connectors', 'Connectors v2', 'team', [human('me'), human('b')], { lastMessage: { content: 'x', createdAt: hoursAgo(0.2), username: 'b' } }),
+ pod('hq', 'Commonly HQ', 'team', [human('me'), human('c')], { communityListed: true, lastMessage: { content: 'x', createdAt: hoursAgo(0.5), username: 'c' } }),
+ pod('rewire', 'Rewire Live Demo', 'team', [human('me'), human('d')], { lastMessage: { content: 'x', createdAt: hoursAgo(1), username: 'd' } }),
+ pod('naming', 'Naming huddle', 'chat', [human('me'), human('e'), human('f')], { lastMessage: { content: 'x', createdAt: hoursAgo(2), username: 'e' } }),
+ pod('payments', 'Payments — memory demo', 'chat', [human('me'), human('g'), human('h')], { lastMessage: { content: 'x', createdAt: hoursAgo(3), username: 'g' } }),
+ pod('sprint', 'Rewire Demo Sprint', 'team', [human('me'), human('i')], { lastMessage: { content: 'x', createdAt: hoursAgo(5), username: 'i' } }),
+ pod('files', 'File access test', 'chat', [human('me'), human('j'), human('k')], { lastMessage: { content: 'x', createdAt: hoursAgo(24 * 61), username: 'j' } }),
+ pod('dm', 'DM test', 'agent-room', [human('me'), agent('builder')], { lastMessage: { content: 'x', createdAt: hoursAgo(24), username: 'builder' } }),
+ pod('canvas', 'Design canvas review', 'team', [human('me'), human('l')], { lastMessage: { content: 'x', createdAt: hoursAgo(26), username: 'l' } }),
+ pod('study', 'Study — agents lit', 'study', [human('me'), human('m')], { lastMessage: { content: 'x', createdAt: hoursAgo(24 * 14), username: 'm' } }),
+ pod('fleet', 'Fleet ops', 'agent-admin', [human('me'), agent('ops')], { lastMessage: { content: 'x', createdAt: hoursAgo(24 * 7), username: 'ops' } }),
+];
+
+// 42 pods = the walk's account size: 12 hand-written plus 30 generated, spread
+// over team / chat / study / games / community with descending message times.
+const KINDS_42 = ['team', 'chat', 'study', 'games', 'team', 'team', 'chat', 'community'];
+const fleet42 = [
+ ...fleet,
+ ...Array.from({ length: 30 }, (_, i) => {
+ const kind = KINDS_42[i % KINDS_42.length];
+ const type = kind === 'community' ? 'team' : kind;
+ const members = type === 'chat' ? [human('me'), human(`x${i}`), human(`y${i}`)] : [human('me'), human(`x${i}`)];
+ return pod(`gen${i}`, `Generated pod ${i + 1}`, type, members, {
+ ...(kind === 'community' ? { communityListed: true } : {}),
+ lastMessage: { content: 'x', createdAt: hoursAgo(30 + i * 7), username: 'me' },
+ });
+ }),
+];
+
+const renderSidebar = (pods, selectedPodId = 'sharpen', extra = {}) => render(
+
+
+
+ ,
+);
+
+const section = (name) => (
+ screen.queryByRole('button', { name: new RegExp(`^${name}`) })
+ || screen.getByRole('heading', { name: new RegExp(`^${name}`) })
+).closest('section');
+
+describe('V2PodsSidebar — direction C', () => {
+ beforeAll(async () => { await i18nReady; });
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ jest.useFakeTimers({ now: NOW });
+ mockPinned.clear();
+ localStorage.clear();
+ sessionStorage.clear();
+ await act(async () => { await i18n.changeLanguage('en'); });
+ });
+
+ afterEach(() => { jest.useRealTimers(); });
+
+ test('Pinned, Recent (eight) and Everything (folded, with the total) in that order; no pods/team/channels/direct labels', () => {
+ mockPinned.add('sharpen');
+ mockPinned.add('connectors');
+ renderSidebar(fleet);
+
+ const heads = screen.getAllByRole('button', { name: /^(Pinned|Recent|Everything)/ }).map((h) => h.textContent.replace(/[▾▸\s]+$/, '').trim());
+ expect(heads).toEqual(['Pinned', 'Recent', 'Everything 12']);
+ expect(screen.getByRole('button', { name: /^Pinned/ })).toHaveAttribute('aria-expanded', 'true');
+ const everything = screen.getByRole('button', { name: /^Everything/ });
+ expect(everything).toHaveAttribute('aria-expanded', 'false');
+ expect(everything).toHaveTextContent('12');
+
+ expect(within(section('Pinned')).getAllByRole('button').filter((b) => b.className.includes('v2-pods__row')).map((b) => b.textContent)).toEqual(
+ expect.arrayContaining([expect.stringContaining('Sharpen'), expect.stringContaining('Connectors v2')]),
+ );
+ const recentRows = within(section('Recent')).getAllByRole('button').filter((b) => b.className.includes('v2-pods__row'));
+ expect(recentRows).toHaveLength(8);
+ expect(recentRows.map((row) => row.textContent)).not.toEqual(expect.arrayContaining([expect.stringContaining('Sharpen')]));
+ // The 10th non-pinned pod (Study, 14 days) falls off Recent.
+ expect(recentRows.map((row) => row.textContent).join(' ')).not.toContain('Study — agents lit');
+
+ ['pods', 'team', 'chat', 'channels', 'direct', 'Connect Slack'].forEach((label) => {
+ expect(screen.queryByText(new RegExp(`^${label}$`))).not.toBeInTheDocument();
+ });
+ });
+
+ test('every row carries a mark, the needs-you pill when there is a count, and the last-message time', () => {
+ mockPinned.add('sharpen');
+ renderSidebar(fleet);
+ const sharpen = screen.getByRole('button', { name: /^Sharpen\b/ });
+ expect(sharpen).toHaveClass('v2-pods__row--selected');
+ expect(within(sharpen).getByLabelText('2 needs you')).toHaveTextContent('2');
+ expect(within(sharpen).getByText('9m')).toBeInTheDocument();
+ const connectors = screen.getByRole('button', { name: /^Connectors v2/ });
+ expect(connectors).toHaveClass('v2-pods__row--unread');
+ expect(within(connectors).getByLabelText('1 needs you')).toHaveTextContent('1');
+ expect(within(connectors).getByText('12m')).toBeInTheDocument();
+ // Above the 12-row queue cap: the pill is the server total, never a list length.
+ expect(within(screen.getByRole('button', { name: /Commonly HQ/ })).getByLabelText('91 needs you')).toHaveTextContent('91');
+ const naming = screen.getByRole('button', { name: /Naming huddle/ });
+ expect(naming).not.toHaveClass('v2-pods__row--unread');
+ expect(within(naming).queryByLabelText(/needs you/)).not.toBeInTheDocument();
+ expect(within(naming).getByText('2h')).toBeInTheDocument();
+ // A DM is an ordinary row with the peer's avatar instead of initials.
+ const dm = screen.getByRole('button', { name: /DM test/ });
+ expect(dm.querySelector('.v2-pods__row-mark--avatar')).toBeTruthy();
+ expect(within(dm).getByText('1d')).toBeInTheDocument();
+ });
+
+ test('Recent is ordered by my last visit, not by the last message; the time column stays the last message', () => {
+ localStorage.setItem(POD_VISITS_KEY, JSON.stringify({ files: NOW - 1000, study: NOW - 2000 }));
+ renderSidebar(fleet, null);
+ const rows = within(section('Recent')).getAllByRole('button').filter((b) => b.className.includes('v2-pods__row')).map((row) => row.textContent);
+ expect(rows[0]).toContain('File access test');
+ expect(rows[0]).toContain('2mo');
+ expect(rows[1]).toContain('Study — agents lit');
+ expect(rows[1]).toContain('2w');
+ expect(rows[2]).toContain('Sharpen');
+ });
+
+ test('Everything unfolds into kinds with counts, each closed, and remembers its open state for the session', () => {
+ mockPinned.add('sharpen');
+ renderSidebar(fleet);
+ fireEvent.click(screen.getByRole('button', { name: /^Everything/ }));
+ expect(screen.getByRole('button', { name: /^Everything/ })).toHaveAttribute('aria-expanded', 'true');
+ const kinds = screen.getAllByRole('button', { name: /^(team|community|chat|study|admin|direct)\s+\d+/ });
+ expect(kinds.map((k) => k.textContent.replace(/[▾▸]/g, '').replace(/\s+/g, ' ').trim())).toEqual([
+ 'team 5', 'community 1', 'chat 3', 'study 1', 'admin 1', 'direct 1',
+ ]);
+ kinds.forEach((kind) => expect(kind).toHaveAttribute('aria-expanded', 'false'));
+ // Pinned pods are in the inventory too.
+ fireEvent.click(screen.getByRole('button', { name: /^team\s+5/ }));
+ expect(within(section('team')).getByRole('button', { name: /Sharpen/ })).toHaveClass('v2-pods__row--in-fold');
+ expect(sessionStorage.getItem('v2:pods.everythingOpen')).toBe('1');
+ expect(JSON.parse(sessionStorage.getItem('v2:pods.kindsOpen'))).toEqual(['team']);
+ });
+
+ test('search flattens to matches in last-touched order, Esc clears, no match says so', () => {
+ renderSidebar(fleet);
+ const input = screen.getByRole('searchbox', { name: 'Search pods' });
+ fireEvent.change(input, { target: { value: 'rewire' } });
+ expect(screen.queryByRole('button', { name: /^Recent/ })).not.toBeInTheDocument();
+ expect(screen.getAllByRole('button', { name: /Rewire/ }).map((b) => b.textContent)).toEqual([
+ expect.stringContaining('Rewire Live Demo'), expect.stringContaining('Rewire Demo Sprint'),
+ ]);
+ fireEvent.change(input, { target: { value: 'zzz' } });
+ expect(screen.getByText('no pods match')).toBeInTheDocument();
+ fireEvent.keyDown(input, { key: 'Escape' });
+ expect(screen.getByRole('button', { name: /^Recent/ })).toBeInTheDocument();
+ });
+
+ test('⌘K focuses the search box from anywhere', () => {
+ renderSidebar(fleet);
+ const input = screen.getByRole('searchbox', { name: 'Search pods' });
+ expect(document.activeElement).not.toBe(input);
+ fireEvent.keyDown(window, { key: 'k', metaKey: true });
+ expect(document.activeElement).toBe(input);
+ });
+
+ test('a row navigates to its pod; the + square opens the create form', () => {
+ renderSidebar(fleet);
+ fireEvent.click(screen.getByRole('button', { name: /Naming huddle/ }));
+ expect(screen.getByTestId('current-path')).toHaveTextContent('/v2/pods/naming');
+ fireEvent.click(screen.getByRole('button', { name: 'New pod' }));
+ expect(screen.getByPlaceholderText('Pod name')).toBeInTheDocument();
+ });
+
+ test('the page variant renders the phone list with a Pods title', () => {
+ renderSidebar(fleet, null, { variant: 'page' });
+ expect(screen.getByRole('heading', { level: 1, name: 'Pods' })).toBeInTheDocument();
+ expect(document.querySelector('.v2-pods-aside--page')).toBeTruthy();
+ });
+
+ test('kinds: every reviewed type maps to one kind, unknown types are not listed, DMs are direct', () => {
+ expect(podKind(pod('t', 'T', 'team', []))).toBe('team');
+ expect(podKind(pod('c', 'C', 'team', [], { communityListed: true }))).toBe('community');
+ expect(podKind(pod('e', 'E', 'agent-ensemble', []))).toBe('ensemble');
+ expect(podKind(pod('g', 'G', 'games', []))).toBe('games');
+ expect(podKind(pod('x', 'X', 'future-type', []))).toBeNull();
+ expect(podKind(pod('r', 'R', 'agent-room', [human('me'), agent('a')]))).toBe('direct');
+ expect(podKind(pod('p', 'P', 'chat', [human('me'), human('you')]))).toBe('direct');
+ expect(podKind(pod('q', 'Q', 'chat', [human('me'), human('you'), human('them')]))).toBe('chat');
+ const groups = groupPodsByKind(fleet);
+ expect(groups.map((g) => g.kind)).toEqual(['team', 'community', 'chat', 'study', 'admin', 'direct']);
+ expect(groups.flatMap((g) => g.pods).length).toBe(fleet.length);
+ });
+
+ test('42 pods: Everything counts every kind, Recent stays eight, pinned rows show the pin, search finds a generated pod', () => {
+ mockPinned.add('sharpen');
+ renderSidebar(fleet42);
+ const everything = screen.getByRole('button', { name: /^Everything/ });
+ expect(everything).toHaveTextContent('42');
+ expect(within(section('Recent')).getAllByRole('button', { name: /^(?!Pin|Unpin)/ }).filter((b) => b.className.includes('v2-pods__row'))).toHaveLength(8);
+ fireEvent.click(everything);
+ const kinds = screen.getAllByRole('button', { name: /^(team|community|chat|study|games|admin|direct)\s+\d+/ });
+ const counts = Object.fromEntries(kinds.map((k) => k.textContent.replace(/[▾▸]/g, '').replace(/\s+/g, ' ').trim().split(' ')));
+ expect(Object.values(counts).reduce((a, n) => a + Number(n), 0)).toBe(42);
+ expect(counts.games).toBe('4');
+ // The pinned row carries an active pin; an unpinned row's pin toggles the store.
+ expect(screen.getByRole('button', { name: 'Unpin pod' })).toHaveClass('v2-pods__pin--active');
+ fireEvent.click(screen.getAllByRole('button', { name: 'Pin pod' })[0]);
+ expect(mockTogglePin).toHaveBeenCalledTimes(1);
+ const input = screen.getByRole('searchbox', { name: 'Search pods' });
+ fireEvent.change(input, { target: { value: 'Generated pod 30' } });
+ expect(screen.getAllByRole('button', { name: /Generated pod 30/ })).toHaveLength(1);
+ });
+
+ test('Pinned and Recent collapse and remember it for the session', () => {
+ mockPinned.add('sharpen');
+ renderSidebar(fleet);
+ fireEvent.click(screen.getByRole('button', { name: /^Recent/ }));
+ expect(screen.getByRole('button', { name: /^Recent/ })).toHaveAttribute('aria-expanded', 'false');
+ expect(within(section('Recent')).queryByRole('button', { name: /Commonly HQ/ })).not.toBeInTheDocument();
+ expect(JSON.parse(sessionStorage.getItem('v2:pods.closedSections'))).toEqual(['recent']);
+ });
+
+ test('recentPods excludes pinned pods and caps at the limit', () => {
+ const rows = recentPods(fleet, new Set(['sharpen']), {}, 3);
+ expect(rows.map((p) => p._id)).toEqual(['connectors', 'hq', 'rewire']);
+ });
+});
diff --git a/frontend/src/v2/__tests__/V2PodsSidebarCreate.test.tsx b/frontend/src/v2/__tests__/V2PodsSidebarCreate.test.tsx
index dfb12acc9b..cea47b61db 100644
--- a/frontend/src/v2/__tests__/V2PodsSidebarCreate.test.tsx
+++ b/frontend/src/v2/__tests__/V2PodsSidebarCreate.test.tsx
@@ -92,7 +92,7 @@ describe('V2PodsSidebar create flow', () => {
,
);
- fireEvent.click(screen.getByRole('button', { name: '+ new' }));
+ fireEvent.click(screen.getByRole('button', { name: 'New pod' }));
// The audience choice is gone — it set one field, could not be honoured
// for non-admins, and asked a stranger to decide before they had content.
@@ -136,7 +136,7 @@ describe('V2PodsSidebar create flow', () => {
,
);
- fireEvent.click(screen.getByRole('button', { name: '+ 新建' }));
+ fireEvent.click(screen.getByRole('button', { name: '新建 Pod' }));
// Audience options are gone in every locale — a zh-only regression here
// would mean the removal was done in the component but not the catalog.
diff --git a/frontend/src/v2/__tests__/podRecency.test.ts b/frontend/src/v2/__tests__/podRecency.test.ts
new file mode 100644
index 0000000000..b55f17c004
--- /dev/null
+++ b/frontend/src/v2/__tests__/podRecency.test.ts
@@ -0,0 +1,43 @@
+import {
+ POD_VISITS_KEY, podInitials, readPodVisits, recordPodVisit, relativeTime,
+} from '../lib/podRecency';
+
+describe('podRecency', () => {
+ beforeEach(() => localStorage.clear());
+
+ test('relativeTime is one number and one unit, never a date', () => {
+ const now = Date.parse('2026-09-06T12:00:00.000Z');
+ expect(relativeTime('2026-09-06T11:59:40.000Z', now)).toBe('now');
+ expect(relativeTime('2026-09-06T11:51:00.000Z', now)).toBe('9m');
+ expect(relativeTime('2026-09-06T09:00:00.000Z', now)).toBe('3h');
+ expect(relativeTime('2026-09-04T12:00:00.000Z', now)).toBe('2d');
+ expect(relativeTime('2026-08-20T12:00:00.000Z', now)).toBe('2w');
+ expect(relativeTime('2026-07-07T12:00:00.000Z', now)).toBe('2mo');
+ expect(relativeTime('2024-09-06T12:00:00.000Z', now)).toBe('2y');
+ expect(relativeTime(null, now)).toBe('');
+ expect(relativeTime('not a date', now)).toBe('');
+ });
+
+ test('a future timestamp reads as now, not as a negative age', () => {
+ const now = Date.parse('2026-09-06T12:00:00.000Z');
+ expect(relativeTime('2026-09-06T12:05:00.000Z', now)).toBe('now');
+ });
+
+ test('the visit log survives a round trip and ignores garbage', () => {
+ expect(readPodVisits()).toEqual({});
+ recordPodVisit('a', 10);
+ recordPodVisit('b', 20);
+ expect(readPodVisits()).toEqual({ a: 10, b: 20 });
+ localStorage.setItem(POD_VISITS_KEY, '{"a": "ten", "c": 30, "d": null}');
+ expect(readPodVisits()).toEqual({ c: 30 });
+ localStorage.setItem(POD_VISITS_KEY, 'not json');
+ expect(readPodVisits()).toEqual({});
+ });
+
+ test('podInitials takes two words, or two letters of one', () => {
+ expect(podInitials('Connectors v2')).toBe('CV');
+ expect(podInitials('Sharpen')).toBe('SH');
+ expect(podInitials(' Payments — memory demo ')).toBe('P—');
+ expect(podInitials('')).toBe('·');
+ });
+});
diff --git a/frontend/src/v2/__tests__/useV2PodHeaderMeta.test.ts b/frontend/src/v2/__tests__/useV2PodHeaderMeta.test.ts
new file mode 100644
index 0000000000..6d01b505fa
--- /dev/null
+++ b/frontend/src/v2/__tests__/useV2PodHeaderMeta.test.ts
@@ -0,0 +1,45 @@
+import { renderHook, waitFor } from '@testing-library/react';
+import { useV2PodHeaderMeta } from '../hooks/useV2PodHeaderMeta';
+
+const mockGet = jest.fn();
+// One stable client object, like the real hook: a fresh object per render would
+// re-run the effect after every state update and loop the reads.
+const mockApi = { get: mockGet, post: jest.fn(), patch: jest.fn(), del: jest.fn() };
+jest.mock('../hooks/useV2Api', () => ({
+ useV2Api: () => mockApi,
+}));
+
+describe('useV2PodHeaderMeta', () => {
+ beforeEach(() => mockGet.mockReset());
+
+ test('counts open board rows (pending, claimed, in progress) and lists connected channels bound to this pod', async () => {
+ mockGet.mockImplementation((url: string) => {
+ if (url.startsWith('/api/v1/tasks/')) {
+ return Promise.resolve({ tasks: [{ status: 'pending' }, { status: 'claimed' }, { status: 'in_progress' }, { status: 'done' }] });
+ }
+ return Promise.resolve([
+ { type: 'telegram', status: 'connected', podId: { _id: 'sharpen' } },
+ { type: 'telegram', status: 'connected', podId: 'sharpen' },
+ { type: 'slack', status: 'connected', podId: 'other' },
+ { type: 'discord', status: 'pending', podId: 'sharpen' },
+ ]);
+ });
+ const { result } = renderHook(() => useV2PodHeaderMeta('sharpen'));
+ await waitFor(() => expect(result.current.boardOpen).toBe(3));
+ expect(result.current.channels).toEqual(['telegram']);
+ });
+
+ test('a failed read leaves that fragment empty rather than blank-ing the header', async () => {
+ mockGet.mockImplementation((url: string) => (url.startsWith('/api/v1/tasks/') ? Promise.reject(new Error('503')) : Promise.resolve('nope')));
+ const { result } = renderHook(() => useV2PodHeaderMeta('sharpen'));
+ await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(2));
+ expect(result.current.boardOpen).toBeNull();
+ expect(result.current.channels).toEqual([]);
+ });
+
+ test('no pod, no reads', () => {
+ const { result } = renderHook(() => useV2PodHeaderMeta(null));
+ expect(mockGet).not.toHaveBeenCalled();
+ expect(result.current).toEqual({ boardOpen: null, channels: [] });
+ });
+});
diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
index e6daaeec78..f5c2c68ed0 100644
--- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
+++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
@@ -99,6 +99,7 @@ describe('v2 layout invariants (CSS rule presence)', () => {
const podChat = read('../components/V2Thread.tsx');
const podsSidebar = read('../components/V2PodsSidebar.tsx');
const podAttention = read('../hooks/useV2PodAttention.ts');
+ const podHeaderMeta = read('../hooks/useV2PodHeaderMeta.ts');
const workspaceInspector = read('../components/V2Inspector.tsx');
const thread = read('../components/V2Thread.tsx');
const threadMessages = read('../components/V2ThreadMessages.tsx');
@@ -135,13 +136,13 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(rule).not.toContain('white-space: nowrap');
});
- test('the workspace grid owns the measured 56 / 232 / fluid / 300 geometry', () => {
- // The artboard at 1440 has 14px page padding and three 12px gaps. Those
- // values leave the 788px chat track measured by UX; a 76px rail cannot.
+ test('the workspace grid owns the measured 56 / 260 / fluid / 272 geometry', () => {
+ // Direction C (walk-1 ruling a, 2026-09-06): 14px page padding and three
+ // 14px gaps leave the 782px chat track the artboard measured.
expect(cssVariable(v2, '--v2-rail-w')).toBe('56px');
- expect(cssVariable(v2, '--v2-pods-w')).toBe('232px');
- expect(cssVariable(v2, '--v2-inspector-w')).toBe('300px');
- expect(lastRuleBody(v2, '.v2-shell')).toContain('gap: 12px');
+ expect(cssVariable(v2, '--v2-pods-w')).toBe('260px');
+ expect(cssVariable(v2, '--v2-inspector-w')).toBe('272px');
+ expect(lastRuleBody(v2, '.v2-shell')).toContain('gap: 14px');
expect(lastRuleBody(v2, '.v2-shell')).toContain('padding: 14px');
});
@@ -167,71 +168,82 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(active).toContain('background: var(--v2-ink)');
});
- test('sidebar is the artboard’s three-list grammar, not the retired search/filter surface', () => {
- const labels = lastRuleBody(v2, '.v2-pods__group-label');
- const sublabels = ruleBody(v2, '.v2-pods__subgroup-label');
- const selected = selectorRuleBody(v2, '.v2-root button.v2-pods__row--selected');
- const channelDot = ruleBody(v2, '.v2-pods__channel-dot');
- const liveChannelDot = ruleBody(v2, '.v2-pods__channel-dot--live');
- const directAvatar = ruleBody(v2, '.v2-pods__direct-avatar.v2-avatar');
-
- expect(labels).toContain('font-family: var(--v2-font-mono)');
- expect(labels).toContain('font-size: 11px');
- expect(sublabels).toContain('padding: 0 24px');
- expect(sublabels).toContain('margin: 0 0 6px');
- expect(sublabels).toContain('color: var(--v2-text-placeholder)');
- expect(sublabels).toContain('font-family: var(--v2-font-mono)');
- expect(selected).toContain('background: var(--v2-accent)');
- expect(selected).toContain('color: #fff');
- expect(ruleBody(v2, '.v2-root button.v2-pods__row')).toContain('border-radius: 4px');
- // The selection is the full 232px sidebar block; 12px is inside its row,
- // not a list inset that would shrink the cobalt slab.
- expect(lastRuleBody(v2, '.v2-pods')).toContain('padding: 6px 0');
- expect(labels).toContain('padding: 0 12px');
- expect(lastRuleBody(v2, '.v2-pods__rows')).toContain('gap: 4px');
- expect(channelDot).toContain('width: 8px');
- expect(liveChannelDot).toContain('background: var(--v2-accent)');
- expect(directAvatar).toContain('width: 20px');
- expect(directAvatar).toContain('height: 20px');
- expect(podsSidebar).toContain("'podsSidebar.workspace.pods'");
- expect(podsSidebar).toContain("'podsSidebar.workspace.channels'");
- expect(podsSidebar).toContain("'podsSidebar.workspace.direct'");
- expect(podsSidebar).not.toContain('v2-pods__search');
+ test('sidebar is direction C: a white panel with search, Pinned / Recent / Everything, and rows that carry mark, pill and time', () => {
+ // Walk-1 rulings (2026-09-06): the six 11px mono labels are gone; two
+ // 12px semibold section heads plus a folded inventory replace them.
+ const panel = lastRuleBody(v2, '.v2-pods');
+ const head = ruleBody(v2, '.v2-root button.v2-pods__section-head');
+ const row = ruleBody(v2, '.v2-root button.v2-pods__row');
+ const mark = ruleBody(v2, '.v2-pods__row-mark');
+ const meta = ruleBody(v2, '.v2-pods__row-meta');
+ const pill = ruleBody(v2, '.v2-pods__row-pill');
+ const search = lastRuleBody(v2, '.v2-pods__search');
+
+ expect(panel).toContain('border: 1px solid var(--v2-border)');
+ expect(panel).toContain('background: var(--v2-surface)');
+ expect(head).toContain('font: 600 12px/16px var(--v2-font)');
+ expect(search).toContain('height: 32px');
+ // The + square shares the search box's 32px so the two tools sit on one
+ // baseline (Sam caught the 28/32 mismatch on the first walk, 2026-09-06).
+ expect(ruleBody(v2, '.v2-root button.v2-pods__new')).toContain('height: 32px');
+ expect(ruleBody(v2, '.v2-root button.v2-pods__new')).toContain('width: 32px');
+ expect(row).toContain('grid-template-columns: 22px minmax(0, 1fr) auto');
+ expect(row).toContain('min-height: 34px');
+ // The selected block runs edge to edge: no radius, no inset.
+ expect(row).toContain('border-radius: 0');
+ expect(mark).toContain('width: 22px');
+ expect(mark).toContain('height: 22px');
+ expect(meta).toContain('font: 400 11px/16px var(--v2-font-mono)');
+ expect(pill).toContain('background: var(--v2-ink)');
+ expect(podsSidebar).toContain("'podsSidebar.workspace.recent'");
+ expect(podsSidebar).toContain("'podsSidebar.workspace.everything'");
+ expect(podsSidebar).toContain('v2-pods__search');
+ expect(podsSidebar).toContain('RECENT_LIMIT');
+ // Pinned has an entry point (ux-lead gate 2026-09-06): the pin control is a
+ // sibling of the row, shown on hover / focus / when pinned.
+ expect(podsSidebar).toContain('togglePin(pod._id)');
+ expect(ruleBody(v2, '.v2-root button.v2-pods__pin')).toContain('opacity: 0');
+ expect(v2).toContain('.v2-pods__rowwrap:hover .v2-pods__pin');
+ expect(v2).toContain('.v2-root .v2-pods__section--kind button.v2-pods__section-head');
+ expect(lastRuleBody(v2, '.v2-pods__search:focus-within')).toContain('inset 0 0 0 2px var(--v2-accent)');
+ expect(podsSidebar).not.toContain("'podsSidebar.workspace.pods'");
+ expect(podsSidebar).not.toContain("'podsSidebar.workspace.channels'");
expect(podsSidebar).not.toContain('v2-pods__filter');
expect(podsSidebar).not.toContain('v2-pods__community');
+ expect(podsSidebar).not.toContain('v2-pods__subgroup-label');
+ expect(v2).not.toContain('.v2-pods__subgroup-label');
+ expect(v2).not.toContain('.v2-pods__channel-dot');
});
- test('the selected room keeps the sidebar’s one cobalt block treatment', () => {
+ test('the selected pod keeps the sidebar’s one cobalt block treatment', () => {
const selected = selectorRuleBody(v2, '.v2-root button.v2-pods__row--selected');
expect(selected).toContain('background: var(--v2-accent)');
expect(selected).toContain('font-weight: 600');
expect(podsSidebar).not.toContain('v2-pods__item--active');
- expect(podsSidebar).not.toContain('v2-pods__pin');
expect(podsSidebar).not.toContain('v2-pods__item-dot');
});
- test('the selected-room count, inspector rows, and phone badge derive from one attention collection', () => {
+ test('row pills, inspector rows, and the phone tab count derive from one attention collection', () => {
expect(podAttention).toContain("'/api/activity/decision-queue'");
expect(v2Layout).toContain('useV2PodAttention()');
expect(v2Layout).toContain('attentionItems={attention.items}');
expect(v2Layout).toContain('attentionCountByPod={attention.countByPod}');
expect(v2Layout).toContain('needsYouCount={attention.count}');
expect(v2Layout).toContain('attentionCount={attention.count === null ? null : (attention.countByPod[selectedPodId] || 0)}');
- expect(v2Layout).toContain('needsYouCount={selectedPodId ? (attention.countByPod[selectedPodId] || 0) : 0}');
+ expect(v2Layout).toContain('needsYouCount={attention.count ?? 0}');
expect(podsSidebar).toContain('attentionCountByPod');
- expect(podsSidebar).toContain('selected && attentionCount > 0');
+ expect(podsSidebar).toContain('count > 0');
expect(workspaceInspector).toContain('attentionItems.filter');
expect(podsSidebar).not.toContain('useV2Unread');
});
- test('room types are an explicit reviewed mapping, not a complement of direct types', () => {
+ test('pod kinds are an explicit reviewed mapping, not a complement of direct types', () => {
expect(podsSidebar).toContain('const ROOM_POD_TYPES');
expect(podsSidebar).toContain('const ROOM_POD_TYPE_LABELS');
expect(podsSidebar).toContain('communityListed === true');
- expect(podsSidebar).toContain("? 'pinned'");
- expect(podsSidebar).toContain("? 'community'");
+ expect(podsSidebar).toContain('export const podKind');
expect(podsSidebar).toContain('export const isRoomPod');
- expect(podsSidebar).toContain('pods.filter(isRoomPod)');
+ expect(podsSidebar).toContain('podKind(pod) !== null');
expect(podsSidebar).not.toContain('pods.filter((pod) => !isDirectPod(pod))');
});
@@ -255,15 +267,21 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(mappedRoomTypes.sort()).toEqual(roomTypes.sort());
});
- test('channels read the existing connector binding endpoint and retain their room target', () => {
- expect(podsSidebar).toContain("'/api/integrations/user/all'");
- expect(podsSidebar).toContain('connector.podId');
- expect(podsSidebar).toContain("navigate('/v2/connectors')");
+ test('bound channels left the sidebar for the pod header meta, read from the same binding endpoint', () => {
+ expect(podHeaderMeta).toContain("'/api/integrations/user/all'");
+ expect(podHeaderMeta).toContain("row.status === 'connected'");
+ expect(thread).toContain('v2-pod-header__channel');
+ expect(podsSidebar).not.toContain('/api/integrations');
});
- test('the compact sidebar preserves the existing mobile drawer contract', () => {
- expect(v2).toMatch(/@media \(max-width: 760px\)[\s\S]*?\.v2-pods-aside \{[\s\S]*?transform: translateX\(-100%\)/);
- expect(v2).toMatch(/@media \(max-width: 760px\)[\s\S]*?\.v2-pods__mobile-close \{[\s\S]*?position: absolute/);
+ test('on the phone the pods list is a page and a pod is the next page — no drawer', () => {
+ expect(v2Layout).toContain('phone && !selectedPodId');
+ expect(v2Layout).toContain('variant="page"');
+ expect(v2Layout).not.toContain('mobileNavOpen');
+ expect(v2).toContain('.v2-shell--list');
+ expect(v2).toContain('.v2-pods-aside:not(.v2-pods-aside--page) { display: none; }');
+ expect(thread).toContain('v2-thread__back');
+ expect(thread).not.toContain('v2-chat__mobile-nav-btn');
});
test('grouped messages keep the avatar column so text never shifts (craft audit rule 3)', () => {
@@ -789,26 +807,35 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(podBoard).not.toContain('+ {t(\'board.newTask\')}');
});
- test('the thread header is the artboard’s name, muted description, and working count', () => {
- expect(podChat).toContain('className="v2-thread__header"');
- expect(podChat).toContain('podChat.header.agentsWorking');
+ test('the pod header is 50px: sans 15/600 name, inline description, mono meta — the working count moved to the inspector', () => {
+ // Walk-1 ruling (b) reverses the Main artboard's Bricolage 22px: a pod is
+ // a conversation, not a page. The display face stays on page titles.
+ expect(podChat).toContain('className="v2-thread__header v2-pod-header"');
+ expect(podChat).toContain('podChat.header.boardOpen');
expect(podChat).toContain('v2-thread__inspector-toggle');
+ expect(podChat).not.toContain('podChat.header.agentsWorking');
+ expect(ruleBody(v2, '.v2-thread__header')).toContain('min-height: 50px');
expect(ruleBody(v2, '.v2-thread__header')).toContain('border-bottom: 1px solid var(--v2-border-soft)');
- expect(ruleBody(v2, '.v2-thread__title h1')).toContain('font: 700 22px/28px var(--v2-font-display)');
- expect(ruleBody(v2, '.v2-thread__working')).toContain('font: 500 11px/16px var(--v2-font-mono)');
- expect(ruleBody(v2, '.v2-thread__working::before')).toContain('color: var(--v2-text-muted)');
- expect(ruleBody(v2, '.v2-thread__working--active::before')).toContain('color: var(--v2-accent)');
- expect(thread).toContain("onlineAgentCount > 0 ? ' v2-thread__working--active' : ''");
- });
-
- test('the phone workspace is an edge-to-edge thread with reachable drawers and a real tab bar', () => {
+ expect(ruleBody(v2, '.v2-thread__title h1')).toContain('font: 600 15px/20px var(--v2-font)');
+ expect(ruleBody(v2, '.v2-pod-header__meta')).toContain('font: 400 11px/16px var(--v2-font-mono)');
+ // Both metas carry the base class; the compact one must out-rank the base
+ // `display: flex` or 1440 shows two metas side by side (ux-lead, 2026-09-06).
+ const baseMeta = v2.indexOf('.v2-pod-header__meta {');
+ const compactMeta = v2.indexOf('.v2-pod-header__meta.v2-pod-header__meta--compact { display: none; }');
+ expect(baseMeta).toBeGreaterThan(-1);
+ expect(compactMeta).toBeGreaterThan(-1);
+ expect(ruleBody(v2, '.v2-pod-header__meta')).toContain('display: flex');
+ expect(compactMeta).toBeGreaterThan(baseMeta);
+ expect(v2).not.toContain('.v2-thread__working');
+ });
+
+ test('the phone workspace is an edge-to-edge thread with a labelled tab bar carrying one count', () => {
expect(v2).toContain('.v2-pane--rail { display: none; }');
- expect(v2).toContain('.v2-thread__working--mobile { display: block; color: var(--v2-accent-text); }');
- expect(v2).toContain('.v2-mobile-tabs__badge');
- expect(ruleBody(v2, '.v2-root button.v2-mobile-tabs__item')).toContain('width: 44px');
- expect(ruleBody(v2, '.v2-root button.v2-mobile-tabs__item')).toContain('height: 32px');
- expect(mobileTabs).toContain('onOpenInspector');
- expect(mobileTabs).toContain("navigate('/v2/settings')");
+ expect(v2).toContain('.v2-mobile-tabs__label');
+ expect(mobileTabs).toContain('needsYouCount');
+ expect(mobileTabs).toContain("path: '/v2/activity'");
+ expect(mobileTabs).toContain("path: '/v2/settings'");
+ expect(mobileTabs).not.toContain('onOpenInspector');
expect(v2Layout).toContain(' {
const author = selectorRuleBody(v2, '.v2-thread .v2-msg__author');
const time = ruleBody(v2, '.v2-thread .v2-msg__time');
const actions = ruleBody(v2, '.v2-thread .v2-msg__actions');
- expect(title).toContain('var(--v2-font-display)');
+ expect(title).toContain('var(--v2-font)');
+ expect(title).not.toContain('var(--v2-font-display)');
expect(avatarRule).toContain('width: 28px');
expect(avatarRule).toContain('border-radius: 4px');
expect(avatarRule).toContain('border: 0');
@@ -839,23 +867,21 @@ describe('v2 layout invariants (CSS rule presence)', () => {
const phone = v2.slice(phoneStart);
expect(phone).toContain('.v2-decision-card__options { align-items: stretch; flex-direction: column; }');
expect(phone).toContain('.v2-root button.v2-decision-card__choice { width: 100%; min-height: 44px; font-size: 15px; }');
- expect(phone).toContain('.v2-root button.v2-chat__mobile-nav-btn');
+ expect(phone).toContain('.v2-root button.v2-thread__back');
expect(phone).toContain('border: 1px solid var(--v2-border)');
});
- test('phone drawer and inspector sheet are mutually exclusive overlays', () => {
- // Both panels are fixed overlays at 390px. Leaving the sheet open while
- // mounting the drawer puts the latter underneath an inert hit target.
- const mobileNavOpener = v2Layout.slice(
- v2Layout.indexOf('const openMobileNav = useCallback(() => {'),
- v2Layout.indexOf('const closeMobileNav', v2Layout.indexOf('const openMobileNav = useCallback(() => {')),
- );
- const inspectorOpener = v2Layout.slice(
- v2Layout.indexOf('const openInspector = useCallback(() => {'),
- v2Layout.indexOf('// File pills', v2Layout.indexOf('const openInspector = useCallback(() => {')),
+ test('the phone list page hides the rail and the desktop sidebar column, never both list and pod at once', () => {
+ // The list is a route state, not an overlay: V2Layout returns early with
+ // the page variant and no thread, so nothing can sit under an inert sheet.
+ const listBranch = v2Layout.slice(
+ v2Layout.indexOf('if (phone && !selectedPodId) {'),
+ v2Layout.indexOf('// The inspector is a separate column'),
);
- expect(mobileNavOpener).toContain('setInspectorCollapsed(true);');
- expect(inspectorOpener).toContain('setMobileNavOpen(false);');
+ expect(listBranch).toContain('variant="page"');
+ expect(listBranch).not.toContain(' {
@@ -1099,8 +1125,9 @@ describe('v2 layout invariants (CSS rule presence)', () => {
activeClasses.add('v2-workspace-inspector__state--working');
const cobalt = activeAccentBackgroundSelectors(v2, activeClasses);
+ // Direction C removed the sidebar channel rows (walk-1 miss 8): the
+ // live-channel dot is no longer a cobalt background in the shell.
expect(cobalt).toEqual([
- '.v2-pods__channel-dot--live',
'.v2-rail__brand-icon',
'.v2-root button.v2-decision-card__choice--primary',
'.v2-root button.v2-pods__row--selected',
diff --git a/frontend/src/v2/components/V2Layout.tsx b/frontend/src/v2/components/V2Layout.tsx
index 472b302248..82a561671c 100644
--- a/frontend/src/v2/components/V2Layout.tsx
+++ b/frontend/src/v2/components/V2Layout.tsx
@@ -1,6 +1,5 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
-import { useTranslation } from 'react-i18next';
import V2NavRail from './V2NavRail';
import V2PodsSidebar from './V2PodsSidebar';
import V2Thread from './V2Thread';
@@ -13,6 +12,7 @@ import { useV2PodDetail } from '../hooks/useV2PodDetail';
import { useV2PodAttention, notifyAttentionChanged } from '../hooks/useV2PodAttention';
import { getSignedAttachmentUrl } from '../../utils/signedAttachmentUrl';
import { useAuth } from '../../context/AuthContext';
+import { recordPodVisit } from '../lib/podRecency';
interface V2LayoutProps {
selectionMode?: 'auto' | 'param';
@@ -70,7 +70,6 @@ const createdAtTime = (createdAt?: string): number => {
};
const V2Layout: React.FC = ({ selectionMode = 'auto' }) => {
- const { t } = useTranslation();
const { podId: paramPodId } = useParams<{ podId: string }>();
const navigate = useNavigate();
const { currentUser } = useAuth();
@@ -78,6 +77,10 @@ const V2Layout: React.FC = ({ selectionMode = 'auto' }) => {
const { pods, loading } = podsState;
const attention = useV2PodAttention();
+ // Direction C on the phone: the pods list is a page and a pod is the next
+ // page. There is no drawer. `phone` tracks the viewport so `/v2` renders the
+ // list instead of redirecting into the last pod.
+ const [phone, setPhone] = useState(() => isPhoneViewport());
// A desktop preference must not leak into the phone sheet. This initializer
// prevents its first rendered frame from flashing open; the media listener
// below handles later viewport changes. On phones the sheet opens only
@@ -95,43 +98,30 @@ const V2Layout: React.FC = ({ selectionMode = 'auto' }) => {
setInviteOpen(true);
}, []);
const closeInvite = useCallback(() => setInviteOpen(false), []);
- // Mobile (<=760px) pods drawer. The sidebar is a fixed slide-over on phones
- // instead of a grid column; this owns its open/closed state. Desktop ignores
- // it entirely (the sidebar is always a visible column there).
- const [mobileNavOpen, setMobileNavOpen] = useState(false);
useEffect(() => {
if (!window.matchMedia) return undefined;
const phoneViewport = window.matchMedia(PHONE_MEDIA_QUERY);
- const closeInspectorOnPhone = () => {
+ const onChange = () => {
+ setPhone(phoneViewport.matches);
if (phoneViewport.matches) setInspectorCollapsed(true);
};
- closeInspectorOnPhone();
- phoneViewport.addEventListener('change', closeInspectorOnPhone);
- return () => phoneViewport.removeEventListener('change', closeInspectorOnPhone);
+ onChange();
+ phoneViewport.addEventListener('change', onChange);
+ return () => phoneViewport.removeEventListener('change', onChange);
}, []);
// Assume visible until the ownership-status probe resolves. This prevents
// the empty-state stack from flashing while the shell-level first-run modal
// decides whether it should open; an established/dismissed user flips it off
// as soon as the probe resolves.
const [firstRunVisible, setFirstRunVisible] = useState(true);
- const openMobileNav = useCallback(() => {
- // The phone has two overlays: rooms drawer and inspector sheet. Opening
- // one must close the other, otherwise the drawer is mounted behind an
- // already-open sheet and has no usable hit target.
- setInspectorCollapsed(true);
- setMobileNavOpen(true);
- }, []);
- const closeMobileNav = useCallback(() => setMobileNavOpen(false), []);
const toggleInspector = useCallback(() => {
setInspectorCollapsed((prev) => {
const next = !prev;
writeInspectorCollapsed(next);
- if (!next) setMobileNavOpen(false);
return next;
});
}, []);
const openInspector = useCallback(() => {
- setMobileNavOpen(false);
setInspectorCollapsed(false);
writeInspectorCollapsed(false);
}, []);
@@ -143,18 +133,22 @@ const V2Layout: React.FC = ({ selectionMode = 'auto' }) => {
if (signed) window.open(signed, '_blank', 'noopener,noreferrer');
});
}, []);
+ // Back from a pod on the phone returns to the list page.
+ const backToPods = useCallback(() => navigate('/v2'), [navigate]);
- // Close the mobile drawer when navigation came from somewhere other than a
- // drawer tap.
+ // Every opened pod is remembered twice: the last one for the next automatic
+ // desktop selection, and the visit log that orders the sidebar's Recent.
useEffect(() => {
- setMobileNavOpen(false);
- if (paramPodId) writeLastPodId(paramPodId);
+ if (!paramPodId) return;
+ writeLastPodId(paramPodId);
+ recordPodVisit(paramPodId);
}, [paramPodId]);
- // Resume the last valid room. A new user without history lands in their
- // self-created invite-only workspace rather than the auto-joined HQ.
+ // Desktop only: resume the last valid pod. A new user without history lands
+ // in their self-created invite-only workspace rather than the auto-joined HQ.
+ // The phone never redirects: `/v2` is the pods list page there.
useEffect(() => {
- if (selectionMode !== 'auto' || paramPodId || loading) return;
+ if (selectionMode !== 'auto' || paramPodId || loading || phone) return;
if (pods.length === 0) return;
const lastPodId = readLastPodId();
@@ -168,14 +162,14 @@ const V2Layout: React.FC = ({ selectionMode = 'auto' }) => {
const destination = lastPod || ownWorkspace || pods[0];
navigate(`/v2/pods/${destination._id}`, { replace: true });
- }, [selectionMode, paramPodId, pods, loading, navigate, currentUser?._id]);
+ }, [selectionMode, paramPodId, pods, loading, navigate, currentUser?._id, phone]);
const selectedPodId = paramPodId || null;
const detail = useV2PodDetail(selectedPodId);
// Agent-room creation happens outside this pod-list hook. After navigation,
// refresh the membership list once if that newly selected room is not in it
- // yet, so it immediately appears in both All and DMs instead of waiting for
- // a later full-shell refresh.
+ // yet, so it immediately appears in the sidebar instead of waiting for a
+ // later full-shell refresh.
const refreshedMissingPodRef = useRef(null);
useEffect(() => {
if (!selectedPodId || loading || pods.some((pod) => pod._id === selectedPodId)) {
@@ -194,30 +188,39 @@ const V2Layout: React.FC = ({ selectionMode = 'auto' }) => {
&& !INVITE_BLOCKED_POD_TYPES.has(String(detail.pod.type)),
);
+
+ // Phone, no pod selected: the pods list IS the page.
+ if (phone && !selectedPodId) {
+ return (
+
+
+
+
+
+ );
+ }
+
// The inspector is a separate column only when expanded. When collapsed,
// it's not rendered at all and the chat extends to the right edge — the
- // entry point is the working-count control in the thread header (see V2Thread).
+ // entry point is the toggle in the pod header (see V2Thread).
const showInspector = Boolean(selectedPodId && !inspectorCollapsed);
const shellClass = ['v2-shell', !showInspector ? 'v2-shell--no-inspector' : ''].filter(Boolean).join(' ');
return (