diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index b25bce5331..27778208db 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -18,7 +18,9 @@ "activity": "Activity", "community": "Community", "settings": "Settings", - "connectors": "Connectors" + "connectors": "Connectors", + "needsYou": "Needs you", + "team": "Team" }, "closePodsList": "Close pods list", "communityRedirect": { @@ -443,7 +445,14 @@ }, "header": { "agentsWorking_one": "{{count}} agent working", - "agentsWorking_other": "{{count}} agents working" + "agentsWorking_other": "{{count}} agents working", + "members_one": "{{count}} member", + "members_other": "{{count}} members", + "agents_one": "{{count}} agent", + "agents_other": "{{count}} agents", + "boardOpen": "board {{count}} open", + "backToPods": "Back to pods", + "compactMeta": "{{members}} · {{count}} agents" }, "team": { "view": "View pod team", @@ -1572,7 +1581,13 @@ "new": "new", "connect": "Connect {{platform}}", "untitled": "Untitled pod", - "needsYouCount": "{{count}} needs you" + "needsYouCount": "{{count}} needs you", + "listTitle": "Pods", + "search": "Search pods", + "noMatch": "no pods match", + "pinnedTitle": "Pinned", + "recent": "Recent", + "everything": "Everything" } }, "feedbackMenu": { diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 2344453080..6e417798b4 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -18,7 +18,9 @@ "activity": "动态", "community": "社区", "settings": "设置", - "connectors": "连接器" + "connectors": "连接器", + "needsYou": "待处理", + "team": "团队" }, "closePodsList": "关闭 Pod 列表", "communityRedirect": { @@ -442,7 +444,14 @@ "recent_other": "最近收到 {{formattedCount}} 次心跳" }, "header": { - "agentsWorking_other": "{{count}} 个智能体正在工作" + "agentsWorking_other": "{{count}} 个智能体正在工作", + "members_one": "{{count}} 名成员", + "members_other": "{{count}} 名成员", + "agents_one": "{{count}} 个智能体", + "agents_other": "{{count}} 个智能体", + "boardOpen": "看板 {{count}} 项待办", + "backToPods": "返回 Pod 列表", + "compactMeta": "{{members}} · {{count}} 个智能体" }, "team": { "view": "查看 Pod 团队", @@ -1564,7 +1573,13 @@ "new": "新建", "connect": "连接 {{platform}}", "untitled": "未命名 Pod", - "needsYouCount": "{{count}} 项待处理" + "needsYouCount": "{{count}} 项待处理", + "listTitle": "Pod", + "search": "搜索 Pod", + "noMatch": "没有匹配的 Pod", + "pinnedTitle": "置顶", + "recent": "最近", + "everything": "全部" } }, "feedbackMenu": { diff --git a/frontend/src/v2/__tests__/V2Composer.test.tsx b/frontend/src/v2/__tests__/V2Composer.test.tsx index c1a537cf9b..0f536b064f 100644 --- a/frontend/src/v2/__tests__/V2Composer.test.tsx +++ b/frontend/src/v2/__tests__/V2Composer.test.tsx @@ -79,30 +79,22 @@ const renderChat = (detail) => render( ); describe('V2Composer send button', () => { - test('uses the artboard’s compact working count in the thread header', () => { + test('the pod header carries the direction-C meta (members · agents), not a working count', () => { const view = renderChat(makeDetail({ pod: { _id: 'p1', name: 'My Workspace', type: 'chat', description: 'Team discussion' }, + members: [{ _id: 'u1', username: 'alice', isBot: false }, { _id: 'u2', username: 'bob', isBot: false }], + agents: [{ agentName: 'scout', instanceId: 'default', displayName: 'Scout' }], })); - expect(view.container.querySelector('.v2-thread__working')).toHaveTextContent('0 agents working'); - expect(view.container.querySelector('.v2-thread__working')).not.toHaveClass('v2-thread__working--active'); - - view.rerender( - - - - - , - ); - - expect(view.container.querySelector('.v2-thread__working')).toHaveTextContent('1 agent working'); - expect(view.container.querySelector('.v2-thread__working')).toHaveClass('v2-thread__working--active'); + const header = view.container.querySelector('.v2-pod-header'); + expect(header).toBeTruthy(); + expect(header.querySelector('h1')).toHaveTextContent('My Workspace'); + expect(header.querySelector('.v2-thread__title p')).toHaveTextContent('Team discussion'); + const meta = header.querySelector('.v2-pod-header__meta:not(.v2-pod-header__meta--compact)'); + expect(meta).toHaveTextContent('2 members'); + expect(meta).toHaveTextContent('1 agent'); + expect(header.querySelector('.v2-pod-header__meta--compact')).toHaveTextContent('2 · 1 agents'); + expect(view.container.querySelector('.v2-thread__working')).toBeNull(); }); test('clicking the send button sends the drafted text', async () => { diff --git a/frontend/src/v2/__tests__/V2LayoutSelection.test.tsx b/frontend/src/v2/__tests__/V2LayoutSelection.test.tsx index 461cc5d49d..19bdca4fe1 100644 --- a/frontend/src/v2/__tests__/V2LayoutSelection.test.tsx +++ b/frontend/src/v2/__tests__/V2LayoutSelection.test.tsx @@ -37,7 +37,9 @@ jest.mock('../../context/AuthContext', () => ({ })); jest.mock('../components/V2NavRail', () => () => null); -jest.mock('../components/V2PodsSidebar', () => () => null); +jest.mock('../components/V2PodsSidebar', () => ({ variant }: { variant?: string }) => ( +
+)); jest.mock('../components/V2Thread', () => ({ onToggleInspector }: { onToggleInspector?: () => void }) => ( )); @@ -100,6 +102,34 @@ describe('V2Layout default pod selection', () => { }); }); + test('on a phone /v2 is the pods list page: no redirect into a pod, no thread, page-variant sidebar', async () => { + Object.defineProperty(window, 'matchMedia', { + configurable: true, + writable: true, + value: jest.fn((query: string) => ({ + matches: query === '(max-width: 760px)', + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + })), + }); + localStorage.setItem('v2:lastPodId', 'hq'); + renderAutoLayout(); + await waitFor(() => expect(screen.getByTestId('pods-sidebar')).toHaveAttribute('data-variant', 'page')); + expect(screen.queryByTestId('current-path')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'toggle inspector' })).not.toBeInTheDocument(); + }); + + test('opening a pod records it in the visit log that orders the sidebar’s Recent', () => { + render( + + + } /> + + , + ); + expect(Object.keys(JSON.parse(localStorage.getItem('v2:podVisits') || '{}'))).toEqual(['workspace']); + }); + test('a phone starts with its inspector sheet closed even when desktop left it open', () => { const phoneViewport = { matches: true, diff --git a/frontend/src/v2/__tests__/V2MobileTabs.test.tsx b/frontend/src/v2/__tests__/V2MobileTabs.test.tsx index edf7841aa9..9a5fa024c7 100644 --- a/frontend/src/v2/__tests__/V2MobileTabs.test.tsx +++ b/frontend/src/v2/__tests__/V2MobileTabs.test.tsx @@ -1,26 +1,44 @@ import React from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { MemoryRouter, useLocation } from 'react-router-dom'; +import i18n, { i18nReady } from '../../i18n'; import V2MobileTabs from '../components/V2MobileTabs'; const Location = () => {useLocation().pathname}; describe('V2MobileTabs', () => { - test('keeps chat, board, needs-you and settings reachable from the phone shell', () => { - const openInspector = jest.fn(); + beforeAll(async () => { + await i18nReady; + await act(async () => { await i18n.changeLanguage('en'); }); + }); + + test('four labelled tiles; the count lives inside the Needs you label; each tile navigates', () => { render( - + , ); - expect(screen.getByText('2')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Board' })); - expect(screen.getByTestId('location')).toHaveTextContent('/v2/pods/pod-1/board'); - fireEvent.click(screen.getByRole('button', { name: 'Needs you' })); - expect(openInspector).toHaveBeenCalledTimes(1); + expect(screen.getAllByRole('button').map((b) => b.textContent)).toEqual(['Pods', 'Needs you · 3', 'Team', 'Settings']); + expect(screen.getByRole('button', { name: 'Pods' })).toHaveAttribute('aria-current', 'page'); + fireEvent.click(screen.getByRole('button', { name: 'Needs you · 3' })); + expect(screen.getByTestId('location')).toHaveTextContent('/v2/activity'); + fireEvent.click(screen.getByRole('button', { name: 'Team' })); + expect(screen.getByTestId('location')).toHaveTextContent('/v2/agents'); fireEvent.click(screen.getByRole('button', { name: 'Settings' })); expect(screen.getByTestId('location')).toHaveTextContent('/v2/settings'); + fireEvent.click(screen.getByRole('button', { name: 'Pods' })); + expect(screen.getByTestId('location')).toHaveTextContent('/v2'); + }); + + test('at zero the label has no count', () => { + render( + + + , + ); + expect(screen.getByRole('button', { name: 'Needs you' })).toBeInTheDocument(); + expect(screen.queryByText(/· 0/)).not.toBeInTheDocument(); }); }); diff --git a/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx b/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx deleted file mode 100644 index 52c47e01b2..0000000000 --- a/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx +++ /dev/null @@ -1,165 +0,0 @@ -// @ts-nocheck -import React from 'react'; -import { - act, fireEvent, render, screen, within, -} from '@testing-library/react'; -import { MemoryRouter, useLocation } from 'react-router-dom'; -import i18n, { i18nReady } from '../../i18n'; -import V2PodsSidebar, { groupWorkspacePods } from '../components/V2PodsSidebar'; - -const mockApiGet = jest.fn(); -const mockCreatePod = jest.fn(); -const mockPinned = new Set(); - -jest.mock('../hooks/useV2Pods', () => ({ - useV2Pods: () => ({ - pods: [], loading: false, error: null, createPod: mockCreatePod, patchLastMessage: jest.fn(), - }), -})); - -jest.mock('../hooks/useV2Api', () => ({ - useV2Api: () => ({ get: mockApiGet, post: jest.fn(), patch: jest.fn(), del: jest.fn() }), -})); - -jest.mock('../hooks/useV2Pinned', () => ({ - useV2Pinned: () => ({ pinned: mockPinned, toggle: jest.fn(), isPinned: (id: string) => mockPinned.has(id) }), -})); - -jest.mock('../../context/AuthContext', () => ({ - useAuth: () => ({ currentUser: { _id: 'me', username: 'me' } }), -})); - -const CurrentPath = () =>
{useLocation().pathname}
; - -const pod = (id, name, type, members, extra = {}) => ({ - _id: id, - name, - type, - members, - updatedAt: '2026-09-05T00:00:00.000Z', - ...extra, -}); - -const human = (id) => ({ _id: id, username: id, isBot: false }); -const agent = (id) => ({ _id: id, username: id, isBot: true }); - -const renderSidebar = (pods, selectedPodId = 'sharpen') => render( - - - - , -); - -describe('V2PodsSidebar workspace groups', () => { - beforeAll(async () => { await i18nReady; }); - - beforeEach(async () => { - jest.clearAllMocks(); - mockPinned.clear(); - mockApiGet.mockImplementation((url) => { - if (url === '/api/integrations/user/all') { - return Promise.resolve([{ - _id: 'telegram', type: 'telegram', status: 'connected', podId: { _id: 'sharpen', name: 'Sharpen' }, - }]); - } - return Promise.resolve([]); - }); - await act(async () => { await i18n.changeLanguage('en'); }); - }); - - test('renders existing member pods under their reviewed pod labels, connectors as channels, and DMs as direct', async () => { - renderSidebar([ - pod('sharpen', 'Sharpen', 'team', [human('me'), human('other')]), - pod('admin', 'Agent Admin', 'agent-admin', [human('me'), agent('ops')]), - pod('study', 'Study Group', 'study', [human('me'), human('other')]), - pod('agent-room', 'Build Agent', 'agent-room', [human('me'), agent('builder')]), - pod('human-dm', 'Design Partner', 'chat', [human('me'), human('designer')]), - pod('room-chat', 'Project Chat', 'chat', [human('me'), human('one'), human('two')]), - pod('future-type', 'Unreviewed Type', 'future-type', [human('me'), human('other')]), - ]); - - const pods = screen.getByRole('heading', { name: 'pods' }).closest('section'); - const direct = screen.getByRole('heading', { name: 'direct' }).closest('section'); - expect(within(pods).getByRole('button', { name: /^Sharpen/ })).toBeInTheDocument(); - expect(within(pods).getByRole('button', { name: 'Agent Admin' })).toBeInTheDocument(); - expect(within(pods).getByRole('button', { name: 'Study Group' })).toBeInTheDocument(); - expect(within(pods).getByRole('button', { name: 'Project Chat' })).toBeInTheDocument(); - expect(within(direct).getByRole('button', { name: 'Build Agent' })).toBeInTheDocument(); - expect(within(direct).getByRole('button', { name: 'Design Partner' })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Unreviewed Type' })).not.toBeInTheDocument(); - - expect(await screen.findByRole('button', { name: 'Telegram · Sharpen' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: '+ Connect Slack' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Telegram · Sharpen' }).querySelector('.v2-pods__channel-dot--live')).toBeTruthy(); - }); - - test('groups each workspace pod exactly once with pinned and community ahead of its type', async () => { - mockPinned.add('pinned-team'); - mockPinned.add('pinned-community'); - renderSidebar([ - pod('pinned-team', 'Pinned team', 'team', [human('me'), human('one')]), - pod('pinned-community', 'Pinned community', 'team', [human('me'), human('one')], { communityListed: true }), - pod('community-team', 'Commonly HQ', 'team', [human('me'), human('two')], { communityListed: true }), - pod('team', 'Team', 'team', [human('me'), human('three')]), - pod('chat', 'Chat', 'chat', [human('me'), human('four'), human('five')]), - pod('study', 'Study', 'study', [human('me'), human('six')]), - pod('games', 'Games', 'games', [human('me'), human('seven')]), - pod('ensemble', 'Ensemble', 'agent-ensemble', [human('me'), agent('eight')]), - pod('admin', 'Admin', 'agent-admin', [human('me'), agent('nine')]), - ], null); - - expect(screen.getAllByRole('heading', { level: 3 }).map((heading) => heading.textContent)).toEqual([ - 'pinned', 'community', 'team', 'chat', 'study', 'games', 'ensemble', 'admin', - ]); - expect(within(screen.getByRole('heading', { name: 'pinned' }).closest('section')).getByRole('button', { name: 'Pinned team' })).toBeInTheDocument(); - expect(within(screen.getByRole('heading', { name: 'pinned' }).closest('section')).getByRole('button', { name: 'Pinned community' })).toBeInTheDocument(); - expect(within(screen.getByRole('heading', { name: 'community' }).closest('section')).getByRole('button', { name: 'Commonly HQ' })).toBeInTheDocument(); - - ['Pinned team', 'Pinned community', 'Commonly HQ', 'Team', 'Chat', 'Study', 'Games', 'Ensemble', 'Admin'].forEach((name) => { - expect(screen.getAllByRole('button', { name })).toHaveLength(1); - }); - }); - - test('maps every non-DM stored type to one category label', () => { - const workspacePods = [ - pod('team', 'Team', 'team', []), - pod('chat', 'Chat', 'chat', [human('me'), human('one'), human('two')]), - pod('study', 'Study', 'study', []), - pod('games', 'Games', 'games', []), - pod('ensemble', 'Ensemble', 'agent-ensemble', []), - pod('admin', 'Admin', 'agent-admin', []), - ]; - - const groups = groupWorkspacePods(workspacePods, new Set()); - - expect(groups.map((group) => group.label)).toEqual(['team', 'chat', 'study', 'games', 'ensemble', 'admin']); - expect(groups.flatMap((group) => group.pods.map((item) => item._id)).sort()) - .toEqual(workspacePods.map((item) => item._id).sort()); - }); - - test('uses the decision queue count only on the selected room and retains no legacy controls', async () => { - renderSidebar([pod('sharpen', 'Sharpen', 'team', [human('me'), human('other')])]); - - expect(await screen.findByLabelText('91 needs you')).toHaveTextContent('91'); - expect(screen.queryByPlaceholderText('Search pods...')).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'All' })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Community' })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Join HQ' })).not.toBeInTheDocument(); - }); - - test('keeps the connector row navigable to its bound room', async () => { - renderSidebar([pod('sharpen', 'Sharpen', 'team', [human('me'), human('other')])]); - fireEvent.click(await screen.findByRole('button', { name: 'Telegram · Sharpen' })); - expect(screen.getByTestId('current-path')).toHaveTextContent('/v2/pods/sharpen'); - }); -}); diff --git a/frontend/src/v2/__tests__/V2PodsSidebar.scale.test.tsx b/frontend/src/v2/__tests__/V2PodsSidebar.scale.test.tsx new file mode 100644 index 0000000000..8396bbb32b --- /dev/null +++ b/frontend/src/v2/__tests__/V2PodsSidebar.scale.test.tsx @@ -0,0 +1,275 @@ +// @ts-nocheck +import React from 'react'; +import { + act, fireEvent, render, screen, within, +} from '@testing-library/react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; +import i18n, { i18nReady } from '../../i18n'; +import V2PodsSidebar, { + groupPodsByKind, podKind, recentPods, +} from '../components/V2PodsSidebar'; +import { POD_VISITS_KEY } from '../lib/podRecency'; + +const mockCreatePod = jest.fn(); +const mockPinned = new Set(); +const mockTogglePin = jest.fn((id: string) => { if (mockPinned.has(id)) mockPinned.delete(id); else mockPinned.add(id); }); + +jest.mock('../hooks/useV2Pods', () => ({ + useV2Pods: () => ({ + pods: [], loading: false, error: null, createPod: mockCreatePod, patchLastMessage: jest.fn(), + }), +})); + +jest.mock('../hooks/useV2Api', () => ({ + useV2Api: () => ({ get: jest.fn(() => Promise.resolve([])), post: jest.fn(), patch: jest.fn(), del: jest.fn() }), +})); + +jest.mock('../hooks/useV2Pinned', () => ({ + useV2Pinned: () => ({ pinned: mockPinned, toggle: mockTogglePin, isPinned: (id: string) => mockPinned.has(id) }), +})); + +jest.mock('../../context/AuthContext', () => ({ + useAuth: () => ({ currentUser: { _id: 'me', username: 'me' } }), +})); + +const CurrentPath = () =>
{useLocation().pathname}
; + +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 (
- - {mobileNavOpen && ( - - - - + {tiles.map((tile) => ( + + ))} ); }; diff --git a/frontend/src/v2/components/V2PodsSidebar.tsx b/frontend/src/v2/components/V2PodsSidebar.tsx index 95a76c8585..a38c5cd475 100644 --- a/frontend/src/v2/components/V2PodsSidebar.tsx +++ b/frontend/src/v2/components/V2PodsSidebar.tsx @@ -1,32 +1,29 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { + useCallback, useEffect, useMemo, useRef, useState, +} from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import V2Avatar from './V2Avatar'; import { UseV2PodsResult, V2Pod, V2PodMember, useV2Pods } from '../hooks/useV2Pods'; -import { useV2Api } from '../hooks/useV2Api'; import { useV2Pinned } from '../hooks/useV2Pinned'; import { useAuth } from '../../context/AuthContext'; - -interface Connector { - _id: string; - type: string; - status: string; - podId?: { _id: string; name?: string } | string | null; -} +import { + RECENT_LIMIT, podInitials, readPodVisits, relativeTime, +} from '../lib/podRecency'; interface V2PodsSidebarProps { selectedPodId: string | null; podsState?: UseV2PodsResult; attentionCountByPod?: Record; - // The drawer only exists below 760px. Desktop always shows this column. - mobileOpen?: boolean; - onMobileClose?: () => void; + // 'column' is the desktop grid column; 'page' is the phone's full-screen + // pods list (direction C: the list is a page, the drawer is gone). + variant?: 'column' | 'page'; } const DM_POD_TYPES = new Set(['agent-room', 'agent-dm']); // A new stored type must be deliberately placed in the workspace grammar. // Do not turn this into "not direct": that would silently surface any future -// type as a room before product has decided how it belongs in the sidebar. +// type as a pod before product has decided how it belongs in the sidebar. const ROOM_POD_TYPES = new Set([ 'chat', 'study', @@ -36,16 +33,17 @@ const ROOM_POD_TYPES = new Set([ 'team', ]); -type PodListGroup = 'pinned' | 'community' | 'team' | 'chat' | 'study' | 'games' | 'ensemble' | 'admin'; +export type PodKind = 'community' | 'team' | 'chat' | 'study' | 'games' | 'ensemble' | 'admin' | 'direct'; -const POD_LIST_GROUP_ORDER: PodListGroup[] = [ - 'pinned', 'community', 'team', 'chat', 'study', 'games', 'ensemble', 'admin', +// Kinds only appear inside the Everything fold (walk-1 miss 3). Order is the +// order a person scans them: work first, direct last. +export const POD_KIND_ORDER: PodKind[] = [ + 'team', 'community', 'chat', 'study', 'games', 'ensemble', 'admin', 'direct', ]; -// Every non-DM Pod.type has a deliberate, human-readable sidebar label. The -// community label is not a type: it is an admin-curated placement that wins -// over kind but never over a person's persisted pin. -export const ROOM_POD_TYPE_LABELS: Record> = { +// Every non-DM Pod.type has a deliberate, human-readable kind. The community +// kind is not a type: it is an admin-curated placement that wins over type. +export const ROOM_POD_TYPE_LABELS: Record> = { team: 'team', chat: 'chat', study: 'study', @@ -54,27 +52,70 @@ export const ROOM_POD_TYPE_LABELS: Record ({ - telegram: 'Telegram', - discord: 'Discord', - slack: 'Slack', - whatsapp: 'WhatsApp', - messenger: 'Messenger', - groupme: 'GroupMe', - x: 'X', - instagram: 'Instagram', -}[type] || type); - -const podTimestamp = (pod: V2Pod): number => { +const SECTION_PINNED = 'pinned'; +const SECTION_RECENT = 'recent'; +const SECTION_EVERYTHING = 'everything'; +const EVERYTHING_OPEN_KEY = 'v2:pods.everythingOpen'; +const CLOSED_SECTIONS_KEY = 'v2:pods.closedSections'; +const KIND_OPEN_KEY = 'v2:pods.kindsOpen'; + +const readSessionFlag = (key: string): boolean => { + try { + return sessionStorage.getItem(key) === '1'; + } catch { + return false; + } +}; + +const writeSessionFlag = (key: string, value: boolean) => { + try { + sessionStorage.setItem(key, value ? '1' : '0'); + } catch { + // Session storage unavailable: the fold simply starts closed next time. + } +}; + +// Pinned and Recent start open and remember a collapse for the session. +const readClosedSections = (): Set => { + try { + const raw = sessionStorage.getItem(CLOSED_SECTIONS_KEY); + return new Set(raw ? (JSON.parse(raw) as string[]) : []); + } catch { + return new Set(); + } +}; + +const writeClosedSections = (sections: Set) => { + try { + sessionStorage.setItem(CLOSED_SECTIONS_KEY, JSON.stringify([...sections])); + } catch { + // Same fallback as above. + } +}; + +const readOpenKinds = (): Set => { + try { + const raw = sessionStorage.getItem(KIND_OPEN_KEY); + return new Set(raw ? (JSON.parse(raw) as PodKind[]) : []); + } catch { + return new Set(); + } +}; + +const writeOpenKinds = (kinds: Set) => { + try { + sessionStorage.setItem(KIND_OPEN_KEY, JSON.stringify([...kinds])); + } catch { + // Same fallback as above. + } +}; + +export const podMessageTime = (pod: V2Pod): number => { const value = pod.lastMessage?.createdAt || pod.updatedAt || pod.createdAt; const timestamp = value ? new Date(value).getTime() : 0; return Number.isNaN(timestamp) ? 0 : timestamp; }; -const sortPods = (pods: V2Pod[]): V2Pod[] => ( - [...pods].sort((left, right) => podTimestamp(right) - podTimestamp(left)) -); - const isHumanPair = (pod: V2Pod): boolean => { const members = pod.members || []; return pod.type === 'chat' @@ -82,9 +123,8 @@ const isHumanPair = (pod: V2Pod): boolean => { && members.every((member) => typeof member === 'object' && !member.isBot); }; -// The stored types stay unchanged. The workspace grammar puts agent DMs and -// human two-person chat under direct. See ROOM_POD_TYPES for the explicitly -// reviewed room types; unknown types do not silently join a list. +// The stored types stay unchanged. Agent DMs and human two-person chat are +// "direct" pods: ordinary rows carrying the peer's avatar instead of initials. export const isDirectPod = (pod: V2Pod): boolean => ( DM_POD_TYPES.has(String(pod.type || '')) || isHumanPair(pod) ); @@ -93,89 +133,150 @@ export const isRoomPod = (pod: V2Pod): boolean => ( ROOM_POD_TYPES.has(String(pod.type || '')) && !isHumanPair(pod) ); -export const groupWorkspacePods = ( - pods: V2Pod[], - pinnedPodIds: Set, -): Array<{ label: PodListGroup; pods: V2Pod[] }> => { - const groups = new Map(); - POD_LIST_GROUP_ORDER.forEach((label) => groups.set(label, [])); +export const podKind = (pod: V2Pod): PodKind | null => { + if (isDirectPod(pod)) return 'direct'; + if (!isRoomPod(pod)) return null; + if (pod.communityListed === true) return 'community'; + return ROOM_POD_TYPE_LABELS[String(pod.type || '')] || null; +}; +// Everything: every pod the person is in, grouped by kind, each group sorted by +// last touched. Pinned pods appear here too; the fold is the full inventory. +export const groupPodsByKind = (pods: V2Pod[]): Array<{ kind: PodKind; pods: V2Pod[] }> => { + const groups = new Map(); + POD_KIND_ORDER.forEach((kind) => groups.set(kind, [])); pods.forEach((pod) => { - const label = pinnedPodIds.has(pod._id) - ? 'pinned' - : pod.communityListed === true - ? 'community' - : ROOM_POD_TYPE_LABELS[String(pod.type || '')]; - if (label) groups.get(label)?.push(pod); + const kind = podKind(pod); + if (kind) groups.get(kind)?.push(pod); }); - - return POD_LIST_GROUP_ORDER - .map((label) => ({ label, pods: sortPods(groups.get(label) || []) })) + return POD_KIND_ORDER + .map((kind) => ({ kind, pods: sortByMessageTime(groups.get(kind) || []) })) .filter((group) => group.pods.length > 0); }; +export const sortByMessageTime = (pods: V2Pod[]): V2Pod[] => ( + [...pods].sort((left, right) => podMessageTime(right) - podMessageTime(left)) +); + +// Recent = the pods I opened last (visit log), falling back to last-message +// time for pods never opened on this device. Pinned pods are excluded because +// they already sit above Recent. +export const recentPods = ( + pods: V2Pod[], + pinnedPodIds: Set, + visits: Record, + limit: number = RECENT_LIMIT, +): V2Pod[] => { + const touched = (pod: V2Pod) => visits[pod._id] || podMessageTime(pod); + return pods + .filter((pod) => !pinnedPodIds.has(pod._id)) + .sort((left, right) => touched(right) - touched(left)) + .slice(0, limit); +}; + const directMemberFor = (pod: V2Pod, currentUserId?: string): V2PodMember | undefined => ( (pod.members || []).find((member): member is V2PodMember => ( typeof member === 'object' && member._id !== currentUserId )) || (pod.members || []).find((member): member is V2PodMember => typeof member === 'object') ); +const matchesQuery = (pod: V2Pod, query: string): boolean => { + const needle = query.trim().toLowerCase(); + if (!needle) return true; + return (pod.name || '').toLowerCase().includes(needle) + || (pod.description || '').toLowerCase().includes(needle); +}; + const V2PodsSidebar: React.FC = ({ - selectedPodId, podsState, attentionCountByPod = {}, mobileOpen = false, onMobileClose, + selectedPodId, podsState, attentionCountByPod = {}, variant = 'column', }) => { const { t } = useTranslation(); const navigate = useNavigate(); - const api = useV2Api(); const { currentUser } = useAuth(); - const { pinned } = useV2Pinned(); + const { pinned, toggle: togglePin } = useV2Pinned(); const ownPodsState = useV2Pods(); const { pods, loading, error, createPod, } = podsState || ownPodsState; - const [connectors, setConnectors] = useState([]); + const searchRef = useRef(null); + const [query, setQuery] = useState(''); + const [visits, setVisits] = useState>(() => readPodVisits()); + const [everythingOpen, setEverythingOpen] = useState(() => readSessionFlag(EVERYTHING_OPEN_KEY)); + const [openKinds, setOpenKinds] = useState>(() => readOpenKinds()); + const [closedSections, setClosedSections] = useState>(() => readClosedSections()); const [creating, setCreating] = useState(false); const [showCreate, setShowCreate] = useState(false); const [newPodName, setNewPodName] = useState(''); const [newPodGoal, setNewPodGoal] = useState(''); const [createError, setCreateError] = useState(null); + const [now, setNow] = useState(() => Date.now()); + + // The visit log is written by the layout when a pod opens; re-read it here so + // Recent reorders without a reload. Times refresh once a minute. + useEffect(() => { + setVisits(readPodVisits()); + }, [selectedPodId]); + useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 60 * 1000); + return () => window.clearInterval(timer); + }, []); - // Channels are the caller's existing bindings, never a discovery surface. + // ⌘K / Ctrl+K focuses the search box from anywhere in the shell. useEffect(() => { - let active = true; - api.get('/api/integrations/user/all') - .then((data) => { - if (active) setConnectors(Array.isArray(data) ? data : []); - }) - .catch(() => { - if (active) setConnectors([]); - }); - return () => { active = false; }; - }, [api]); - - const { podGroups, direct } = useMemo(() => { - const roomPods = pods.filter(isRoomPod); - const directPods = pods.filter(isDirectPod); - return { podGroups: groupWorkspacePods(roomPods, pinned), direct: sortPods(directPods) }; - }, [pods, pinned]); - - const channels = useMemo(() => connectors.map((connector) => { - const podId = typeof connector.podId === 'object' ? connector.podId?._id : connector.podId; - const boundPod = pods.find((pod) => pod._id === podId); - const name = typeof connector.podId === 'object' - ? (connector.podId?.name || boundPod?.name || t('podsSidebar.workspace.untitled')) - : (boundPod?.name || t('podsSidebar.workspace.untitled')); - return { - ...connector, - podId: podId || null, - label: `${connectorLabel(connector.type)} · ${name}`, + const onKey = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { + event.preventDefault(); + searchRef.current?.focus(); + searchRef.current?.select(); + } }; - }), [connectors, pods, t]); + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); - const nextPlatform = connectors.some((connector) => connector.type === 'slack') ? 'Telegram' : 'Slack'; + const listablePods = useMemo(() => pods.filter((pod) => podKind(pod) !== null), [pods]); + const pinnedPods = useMemo( + () => sortByMessageTime(listablePods.filter((pod) => pinned.has(pod._id))), + [listablePods, pinned], + ); + const recent = useMemo(() => recentPods(listablePods, pinned, visits), [listablePods, pinned, visits]); + const everything = useMemo(() => groupPodsByKind(listablePods), [listablePods]); + const searching = query.trim().length > 0; + const matches = useMemo(() => { + if (!searching) return []; + const touched = (pod: V2Pod) => visits[pod._id] || podMessageTime(pod); + return listablePods + .filter((pod) => matchesQuery(pod, query)) + .sort((left, right) => touched(right) - touched(left)); + }, [listablePods, query, searching, visits]); - const selectPod = (podId: string) => { + const selectPod = useCallback((podId: string) => { navigate(`/v2/pods/${podId}`); - onMobileClose?.(); + }, [navigate]); + + const toggleEverything = () => { + setEverythingOpen((open) => { + writeSessionFlag(EVERYTHING_OPEN_KEY, !open); + return !open; + }); + }; + + const toggleSection = (key: string) => { + setClosedSections((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); else next.add(key); + writeClosedSections(next); + return next; + }); + }; + + const toggleKind = (kind: PodKind) => { + setOpenKinds((current) => { + const next = new Set(current); + if (next.has(kind)) next.delete(kind); else next.add(kind); + writeOpenKinds(next); + return next; + }); }; const handleCreatePod = async (event: React.FormEvent) => { @@ -204,133 +305,202 @@ const V2PodsSidebar: React.FC = ({ } }; - const renderPodRow = (pod: V2Pod, kind: 'room' | 'direct') => { + const renderRow = (pod: V2Pod, inFold = false) => { const selected = pod._id === selectedPodId; - const attentionCount = attentionCountByPod[pod._id] || 0; - const directMember = kind === 'direct' ? directMemberFor(pod, currentUser?._id) : undefined; + const count = attentionCountByPod[pod._id] || 0; + const direct = isDirectPod(pod); + const peer = direct ? directMemberFor(pod, currentUser?._id) : undefined; + const time = relativeTime(podMessageTime(pod) || null, now); + const isPinned = pinned.has(pod._id); return ( +
+
+ ); + }; + + const renderSection = ( + key: string, + label: string, + rows: React.ReactNode, + options: { count?: number; open?: boolean; onToggle?: () => void; nested?: boolean } = {}, + ) => { + const { + count, open = true, onToggle, nested = false, + } = options; + const headingId = `v2-pods-section-${key}`; + const header = ( + <> + {label} + {typeof count === 'number' && ( + <> + {' '} + {count} + + )} + {onToggle &&