From 07d897667dd54e6d93e7281482c6a5aeb782448b Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:40:53 -0700 Subject: [PATCH 1/6] feat(v2): rebuild workspace pods sidebar --- frontend/src/i18n/locales/en.json | 9 + frontend/src/i18n/locales/zh-CN.json | 9 + .../V2PodsSidebar.community.test.tsx | 315 ++----- .../v2/__tests__/V2PodsSidebarCreate.test.tsx | 4 +- .../v2/__tests__/v2-layout-invariants.test.ts | 163 ++-- frontend/src/v2/components/V2PodsSidebar.tsx | 880 +++++------------- frontend/src/v2/v2.css | 208 ++++- 7 files changed, 583 insertions(+), 1005 deletions(-) diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index a95f156ed1..dad1e2bf2a 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1497,6 +1497,15 @@ "title": "Join Commonly HQ", "copy": "Meet the builders and their agents.", "button": "Join HQ" + }, + "workspace": { + "rooms": "rooms", + "channels": "channels", + "direct": "direct", + "new": "new", + "connect": "Connect {{platform}}", + "untitled": "Untitled pod", + "needsYouCount": "{{count}} needs you" } }, "feedbackMenu": { diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 46266e0c0d..de222b0319 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1491,6 +1491,15 @@ "title": "加入 Commonly HQ", "copy": "结识开发者和他们的智能体。", "button": "加入 HQ" + }, + "workspace": { + "rooms": "房间", + "channels": "频道", + "direct": "私信", + "new": "新建", + "connect": "连接 {{platform}}", + "untitled": "未命名 Pod", + "needsYouCount": "{{count}} 项待处理" } }, "feedbackMenu": { diff --git a/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx b/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx index 6b776da248..ce9c8bfa46 100644 --- a/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx +++ b/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx @@ -1,285 +1,114 @@ // @ts-nocheck import React from 'react'; -import { act, fireEvent, render, screen, waitFor } from '@testing-library/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 from '../components/V2PodsSidebar'; -const COMMUNITY_POD_ID = 'community-pod'; -const mockJoinPod = jest.fn(); -const mockSeedFromExisting = jest.fn(); -const mockRefresh = jest.fn(); const mockApiGet = jest.fn(); -const mockApi = { - get: mockApiGet, - post: jest.fn(), - patch: jest.fn(), - del: jest.fn(), -}; +const mockCreatePod = jest.fn(); jest.mock('../hooks/useV2Pods', () => ({ useV2Pods: () => ({ - pods: [], - loading: false, - error: null, - refresh: mockRefresh, - createPod: jest.fn(), - deletePod: jest.fn(), - patchLastMessage: jest.fn(), - }), -})); - -jest.mock('../hooks/useV2Pinned', () => ({ - useV2Pinned: () => ({ - pinned: new Set(), - toggle: jest.fn(), - isPinned: () => false, + pods: [], loading: false, error: null, createPod: mockCreatePod, patchLastMessage: jest.fn(), }), })); jest.mock('../hooks/useV2Api', () => ({ - useV2Api: () => mockApi, -})); - -jest.mock('../hooks/useV2Unread', () => ({ - useV2Unread: () => ({ - isUnread: () => false, - bumpLatest: jest.fn(), - seedFromExisting: mockSeedFromExisting, - }), -})); - -jest.mock('../../context/SocketContext', () => ({ - useSocket: () => ({ socket: null, connected: false, joinPod: mockJoinPod }), + useV2Api: () => ({ get: mockApiGet, post: jest.fn(), patch: jest.fn(), del: jest.fn() }), })); jest.mock('../../context/AuthContext', () => ({ - useAuth: () => ({ currentUser: { _id: 'human-1', username: 'new-human' } }), + useAuth: () => ({ currentUser: { _id: 'me', username: 'me' } }), })); const CurrentPath = () =>
{useLocation().pathname}
; -const makePod = (id: string, memberIds: string[], overrides = {}) => ({ +const pod = (id, name, type, members) => ({ _id: id, - name: id === COMMUNITY_POD_ID ? 'Commonly HQ' : 'My Workspace', - type: 'team', - members: memberIds.map((_id) => ({ _id, username: _id, isBot: false })), - createdAt: '2026-07-21T12:00:00.000Z', - updatedAt: '2026-07-21T12:00:00.000Z', - ...overrides, + name, + type, + members, + updatedAt: '2026-09-05T00:00:00.000Z', }); -const renderSidebar = (pods) => { - const podsState = { - pods, - loading: false, - error: null, - refresh: mockRefresh, - createPod: jest.fn(), - deletePod: jest.fn(), - patchLastMessage: jest.fn(), - }; - return render( - - - - , - ); -}; - -describe('V2PodsSidebar Community offer', () => { - const originalCommunityPodId = process.env.REACT_APP_COMMUNITY_POD_ID; - - beforeAll(async () => { - await i18nReady; - }); +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(); - mockApiGet.mockReset(); - mockApi.post.mockReset(); - mockApiGet.mockResolvedValue([]); - mockApi.post.mockResolvedValue(null); - mockRefresh.mockResolvedValue(undefined); - process.env.REACT_APP_COMMUNITY_POD_ID = COMMUNITY_POD_ID; - await act(async () => { - await i18n.changeLanguage('en'); - }); - }); - - afterAll(async () => { - if (originalCommunityPodId === undefined) { - delete process.env.REACT_APP_COMMUNITY_POD_ID; - } else { - process.env.REACT_APP_COMMUNITY_POD_ID = originalCommunityPodId; - } - await i18n.changeLanguage('en'); - }); - - test('shows for a configured Community pod the human has not joined and navigates to the redirect', () => { - renderSidebar([makePod('workspace', ['human-1'])]); - - expect(screen.getByText('Meet the builders and their agents.')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Join HQ' })); - expect(screen.getByTestId('current-path')).toHaveTextContent('/v2/community'); - }); - - test('stays hidden once the Community pod is in memberPodIds', () => { - renderSidebar([ - makePod('workspace', ['human-1']), - makePod(COMMUNITY_POD_ID, ['human-1']), - ]); - - expect(screen.queryByRole('button', { name: 'Join HQ' })).not.toBeInTheDocument(); - }); - - test('stays hidden for self-hosted instances without Community config', () => { - delete process.env.REACT_APP_COMMUNITY_POD_ID; - renderSidebar([makePod('workspace', ['human-1'])]); - - expect(screen.queryByRole('button', { name: 'Join HQ' })).not.toBeInTheDocument(); - }); - - test('splits joined Community pods from discoverable non-members without leaking them into All', async () => { - const joinedPod = makePod('joined-space', ['human-1'], { - name: 'Joined Builders', - publicRead: true, - }); - const hqPod = makePod(COMMUNITY_POD_ID, ['human-1'], { publicRead: true }); mockApiGet.mockImplementation((url) => { - if (url === '/api/pods?scope=community') { - return Promise.resolve([joinedPod, hqPod]); + if (url === '/api/integrations/user/all') { + return Promise.resolve([{ + _id: 'telegram', type: 'telegram', status: 'connected', podId: { _id: 'sharpen', name: 'Sharpen' }, + }]); } - if (url === '/api/pods?scope=discover') { - return Promise.resolve([ - makePod('public-space', [], { - name: 'Open Builders', - description: 'Build in public with the community.', - publicRead: true, - }), - makePod('forced-public-dm', [], { - name: 'Private agent room', - type: 'agent-room', - publicRead: true, - }), - ]); + if (url === '/api/activity/decision-queue') { + return Promise.resolve({ items: [{ id: 'decision-1', podId: 'sharpen' }] }); } return Promise.resolve([]); }); - renderSidebar([ - makePod('workspace', ['human-1']), - joinedPod, - hqPod, - ]); - - const communityFilter = screen.getByRole('button', { name: 'Community' }); - fireEvent.click(communityFilter); - expect(communityFilter).toHaveClass('v2-pods__filter--active'); - expect(screen.getByRole('button', { name: 'All' })) - .not.toHaveClass('v2-pods__filter--active'); - expect(screen.getByRole('button', { name: 'Joined' })) - .toHaveClass('v2-pods__community-tab--active'); - expect(await screen.findByText('Joined Builders')).toBeInTheDocument(); - expect(screen.getByText('Commonly HQ')).toBeInTheDocument(); - expect(screen.queryByText('Open Builders')).not.toBeInTheDocument(); - expect(mockApiGet).not.toHaveBeenCalledWith('/api/pods?scope=discover'); - - const discoverView = screen.getByRole('button', { name: 'Discover' }); - fireEvent.click(discoverView); - expect(discoverView).toHaveClass('v2-pods__community-tab--active'); - expect(screen.getByRole('button', { name: 'Joined' })) - .not.toHaveClass('v2-pods__community-tab--active'); - expect(await screen.findByText('Open Builders')).toBeInTheDocument(); - expect(screen.getByText('Build in public with the community.')).toBeInTheDocument(); - expect(screen.getByText('0 members')).toBeInTheDocument(); - expect(screen.queryByText('Private agent room')).not.toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'All' })); - expect(screen.getByText('My Workspace')).toBeInTheDocument(); - expect(screen.queryByText('Open Builders')).not.toBeInTheDocument(); - await waitFor(() => { - expect(mockApiGet).toHaveBeenCalledWith('/api/pods?scope=community'); - expect(mockApiGet).toHaveBeenCalledWith('/api/pods?scope=discover'); - }); + await act(async () => { await i18n.changeLanguage('en'); }); }); - test('joins a discovered pod, moves it to Joined, refreshes memberships, and navigates in', async () => { - const discoveredPod = makePod('bug-reports', [], { - name: 'Bug Reports', - description: 'Help make Commonly better.', - publicRead: true, - }); - const joinedPod = makePod('bug-reports', ['human-1'], { - name: 'Bug Reports', - description: 'Help make Commonly better.', - publicRead: true, - }); - mockApiGet.mockImplementation((url) => ( - url === '/api/pods?scope=discover' - ? Promise.resolve([discoveredPod]) - : Promise.resolve([]) - )); - mockApi.post.mockResolvedValue(joinedPod); - renderSidebar([makePod('workspace', ['human-1'])]); - - fireEvent.click(screen.getByRole('button', { name: 'Community' })); - fireEvent.click(screen.getByRole('button', { name: 'Discover' })); - fireEvent.click(await screen.findByRole('button', { name: 'Join' })); + test('renders existing member pods as rooms, 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')]), + ]); - await waitFor(() => { - expect(mockApi.post).toHaveBeenCalledWith('/api/pods/bug-reports/join'); - expect(mockRefresh).toHaveBeenCalledTimes(1); - expect(screen.getByTestId('current-path')).toHaveTextContent('/v2/pods/bug-reports'); - }); - expect(screen.getByRole('button', { name: 'Joined' })).toHaveAttribute('aria-pressed', 'true'); - expect(screen.getByText('Bug Reports')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Join' })).not.toBeInTheDocument(); + const rooms = screen.getByRole('heading', { name: 'rooms' }).closest('section'); + const direct = screen.getByRole('heading', { name: 'direct' }).closest('section'); + expect(within(rooms).getByRole('button', { name: 'Sharpen' })).toBeInTheDocument(); + expect(within(rooms).getByRole('button', { name: 'Agent Admin' })).toBeInTheDocument(); + expect(within(rooms).getByRole('button', { name: 'Study Group' })).toBeInTheDocument(); + expect(within(rooms).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(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.each([ - [403, 'This Pod needs an invite link to join.'], - [429, 'Too many join attempts. Wait a moment and try again.'], - ])('keeps a discovered pod visible and explains a %s join refusal', async (status, message) => { - const discoveredPod = makePod('feature-requests', [], { - name: 'Feature Requests', - publicRead: true, - }); - mockApiGet.mockImplementation((url) => ( - url === '/api/pods?scope=discover' - ? Promise.resolve([discoveredPod]) - : Promise.resolve([]) - )); - mockApi.post.mockRejectedValue({ response: { status } }); - renderSidebar([makePod('workspace', ['human-1'])]); - - fireEvent.click(screen.getByRole('button', { name: 'Community' })); - fireEvent.click(screen.getByRole('button', { name: 'Discover' })); - fireEvent.click(await screen.findByRole('button', { name: 'Join' })); + 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.findByRole('alert')).toHaveTextContent(message); - expect(screen.getByText('Feature Requests')).toBeInTheDocument(); - expect(screen.getByTestId('current-path')).toHaveTextContent('/v2/pods/workspace'); + expect(await screen.findByLabelText('1 needs you')).toHaveTextContent('1'); + 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('renders the Community discovery controls from both locale catalogs', async () => { - mockApiGet.mockImplementation((url) => ( - url === '/api/pods?scope=discover' - ? Promise.resolve([makePod('public-space', [], { name: 'Open Builders' })]) - : Promise.resolve([]) - )); - renderSidebar([makePod('workspace', ['human-1'])]); - expect(screen.getByRole('button', { name: 'Community' })).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Community' })); - expect(screen.getByRole('button', { name: 'Joined' })).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Discover' })); - expect(await screen.findByRole('button', { name: 'Join' })).toBeInTheDocument(); - - await act(async () => { - await i18n.changeLanguage('zh-CN'); - }); - expect(screen.getByRole('button', { name: '社区' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: '已加入' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: '发现' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: '加入' })).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__/V2PodsSidebarCreate.test.tsx b/frontend/src/v2/__tests__/V2PodsSidebarCreate.test.tsx index cea47b61db..dfb12acc9b 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 pod' })); + fireEvent.click(screen.getByRole('button', { name: '+ new' })); // 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: '新建 Pod' })); + fireEvent.click(screen.getByRole('button', { name: '+ 新建' })); // 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__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index f4dc7fb7dc..d6ac24350f 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -36,6 +36,16 @@ const ruleBody = (css: string, selector: string): string => { return end === -1 ? '' : css.slice(start, end); }; +// TASK-129 supersedes a large sidebar block in place. The current artboard +// rules intentionally live later in the same stylesheet until the chat pass +// removes the retired selectors, so inspect the last exact rule for overrides. +const lastRuleBody = (css: string, selector: string): string => { + const start = css.lastIndexOf(`\n${selector} {`); + if (start === -1) return ''; + const end = css.indexOf('}', start); + return end === -1 ? '' : css.slice(start, end); +}; + // Some component rules deliberately share a declaration block with an // element variant (for example, button + link CTAs). The guard still needs to // inspect that one block without splitting the production selector just for a @@ -74,6 +84,7 @@ describe('v2 layout invariants (CSS rule presence)', () => { const aprofile = read('../agents/v2-agent-profile.css'); const landing = read('../landing/v2-landing.css'); const podChat = read('../components/V2PodChat.tsx'); + const podsSidebar = read('../components/V2PodsSidebar.tsx'); const podBoard = read('../components/V2PodBoard.tsx'); const activityPage = read('../components/V2ActivityPage.tsx'); const v2App = read('../V2App.tsx'); @@ -99,27 +110,65 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(rule).not.toContain('white-space: nowrap'); }); - test('sidebar pod name WRAPS — it never loses to its own timestamp (craft audit finding 8)', () => { - // "Sharpen — pod m…", "Team Orchestra…": the name shared its line with the - // relative time and ellipsized at desktop width. Same primary-identifier - // rule as the team-card name: wrap to two lines, and the timestamp lives - // on the snippet row so it never competes with the name at all. - const title = ruleBody(v2, '.v2-pods__item-title'); - expect(title).toContain('-webkit-line-clamp: 2'); - expect(title).not.toContain('white-space: nowrap'); - expect(v2).toContain('.v2-pods__item-snippet-row'); + 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. + 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(lastRuleBody(v2, '.v2-shell')).toContain('padding: 14px'); + }); + + test('sidebar is the artboard’s three-list grammar, not the retired search/filter surface', () => { + const labels = lastRuleBody(v2, '.v2-pods__group-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(selected).toContain('background: var(--v2-accent)'); + expect(selected).toContain('color: #fff'); + expect(ruleBody(v2, '.v2-root button.v2-pods__row')).toContain('border-radius: 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.rooms'"); + expect(podsSidebar).toContain("'podsSidebar.workspace.channels'"); + expect(podsSidebar).toContain("'podsSidebar.workspace.direct'"); + expect(podsSidebar).not.toContain('v2-pods__search'); + expect(podsSidebar).not.toContain('v2-pods__filter'); + expect(podsSidebar).not.toContain('v2-pods__community'); + }); + + test('the selected room 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 is derived from the decision queue, not a second unread path', () => { + expect(podsSidebar).toContain("'/api/activity/decision-queue'"); + expect(podsSidebar).toContain('attentionCountByPod'); + expect(podsSidebar).toContain('selected && attentionCount > 0'); + expect(podsSidebar).not.toContain('useV2Unread'); }); - test('the unread dot stays clear of the rounded row corner (name-wrap side effect)', () => { - // The title-row is align-items: flex-start (for the 2-line name wrap), - // which parks the dot flush at the row's top-right — inside the row's - // border-radius + overflow:hidden clip, where the rounding visibly - // shaved it (reported live 2026-08-22). Both margins are load-bearing: - // top centers it on the first text line, right clears the 10px curve. - // jsdom cannot see corner clipping — presence pin per this file's rule. - const dot = ruleBody(v2, '.v2-pods__item-dot'); - expect(dot).toContain('margin-top: 6px'); - expect(dot).toContain('margin-right: 4px'); + 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('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('grouped messages keep the avatar column so text never shifts (craft audit rule 3)', () => { @@ -529,41 +578,6 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(ruleBody(v2, '.v2-settings .apps-management--settings .apps-management__integration')).toContain('border: 1px solid var(--v2-border)'); }); - test('the Community offer stays visible below the independently scrolling pod list', () => { - // The list owns overflow-y; the offer is its flex sibling. Preventing the - // card from shrinking is what keeps the Join HQ action reachable when the - // sidebar contains many pods. - expect(ruleBody(v2, '.v2-pods__community')).toContain('flex-shrink: 0'); - }); - - test('the four pod filters stay on one row in the narrow sidebar', () => { - // Third mechanism (Sam, 2026-08-23): content-sized chips. The equal-grid - // versions allocated width backwards (wide boxes for short words, a - // capped box for the longest) and scattered zh-CN's four 2-character - // labels across phantom cells. Chips hug their labels with uniform - // padding, so both locales read as one segmented control. The intent - // Superseded 2026-08-23 (Sam): equal cells beat one row. Four equal - // chips can't share the 239px rail (EN "Community" needs 84px alone), - // so equal means the same 2×2 grid the community-tabs below use. - const rule = ruleBody(v2, '.v2-pods__filters'); - expect(rule).toContain('display: grid'); - expect(rule).toContain('grid-template-columns: repeat(2, minmax(0, 1fr))'); - expect(rule).toContain('overflow: visible'); - // The `.v2-root button.` prefix is load-bearing: the global button reset - // (0-1-1) zeroes padding on a bare-class rule (0-1-0), which shipped as - // 25px chips hugging bare text — the third hit of this exact trap. - const chip = ruleBody(v2, '.v2-root button.v2-pods__filter'); - expect(chip).toContain('white-space: nowrap'); - expect(chip).toContain('padding: 0 10px'); - // The active rule must FOLLOW the base chip rule: both weigh 0-2-1, so - // source order decides the selected chip's color — the wrong order - // shipped grey-on-blue (measured live 2026-08-23). - const active = ruleBody(v2, '.v2-root button.v2-pods__filter--active'); - expect(active).toContain('color: var(--v2-surface)'); - expect(v2.indexOf('.v2-root button.v2-pods__filter--active')) - .toBeGreaterThan(v2.indexOf('.v2-root button.v2-pods__filter {')); - }); - test('mention treatment is a neutral wash, never a message rail', () => { // Accent rails made otherwise ordinary cards look like generic callouts. // Message rows retain a neutral semantic wash, while quote edges stay @@ -580,25 +594,6 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(ruleBody(v2, '.v2-board__detail-updates li')).toContain('border-left: 3px solid var(--v2-border)'); }); - test('pod rows reserve one preview line and a locale-shaped time slot', () => { - const row = ruleBody(v2, '.v2-pods__item'); - const snippet = ruleBody(v2, '.v2-pods__item-snippet'); - const time = ruleBody(v2, '.v2-pods__item-time'); - - expect(row).toContain('height: 64px'); - expect(row).toContain('overflow: hidden'); - expect(snippet).toContain('height: 16px'); - expect(snippet).toContain('white-space: nowrap'); - expect(snippet).toContain('text-overflow: ellipsis'); - // Relative time comes from the viewer's browser locale, so a fixed English - // pixel width will eventually clip a clock. Keep its intrinsic width and - // let the already-ellipsized title absorb the remaining line. - expect(time).toContain('flex-shrink: 0'); - expect(time).toContain('white-space: nowrap'); - expect(time).not.toContain('width:'); - expect(v2).toContain('.v2-pods__row--pinned .v2-pods__item-time'); - }); - test('the inspector activity pulse uses semantic tokens, not raw color literals', () => { const pulse = ruleBody(v2, '.v2-inspector__now-pulse'); expect(pulse).toContain('var(--v2-success-ring-strong)'); @@ -653,18 +648,6 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(podChat).toContain('{liveState && · {liveState}}'); }); - test('the Community sub-tabs and Discover rows shrink inside the narrow sidebar', () => { - // Joined/Discover adds a second segmented row beneath the four main - // filters. Equal minmax(0, 1fr) tracks keep both locale labels on one line, - // while the Discover card gives its copy column the only shrinkable track. - const tabs = ruleBody(v2, '.v2-pods__community-tabs'); - const row = ruleBody(v2, '.v2-pods__discover-row'); - expect(tabs).toContain('display: grid'); - expect(tabs).toContain('repeat(2, minmax(0, 1fr))'); - expect(tabs).toContain('overflow: hidden'); - expect(row).toContain('34px minmax(0, 1fr) auto'); - }); - test('Activity cards have shrinkable desktop and mobile layout guards', () => { // The recap is a feature-wide page, but it is still reachable at 390px. // The zero-min grid tracks are the load-bearing no-horizontal-overflow @@ -697,16 +680,6 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-root \.v2-activity__queue-actions button \{ min-height: 44px; \}/); }); - test('the shared filter segment uses an unmistakable token-backed selected state', () => { - const active = ruleBody(v2, '.v2-root button.v2-filter-segment__item--active'); - - expect(active).toContain('background: var(--v2-accent)'); - expect(active).toContain('border-color: var(--v2-accent)'); - expect(active).toContain('color: var(--v2-surface)'); - expect(active).toContain('font-weight: 700'); - expect(active).not.toContain('var(--v2-accent-soft)'); - }); - test('starter prompts wrap within the mobile chat pane', () => { // At 390px the rail leaves a narrow main pane. Both the row and each chip // need explicit shrink/wrap rules or the longest prompt creates horizontal diff --git a/frontend/src/v2/components/V2PodsSidebar.tsx b/frontend/src/v2/components/V2PodsSidebar.tsx index fcd536cd93..e95f6638e6 100644 --- a/frontend/src/v2/components/V2PodsSidebar.tsx +++ b/frontend/src/v2/components/V2PodsSidebar.tsx @@ -1,97 +1,73 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -import { UseV2PodsResult, V2Pod, useV2Pods } from '../hooks/useV2Pods'; -import { groupPods, formatRelativeTime } from '../utils/grouping'; -import { initialsFor } from '../utils/avatars'; -import { useV2Pinned } from '../hooks/useV2Pinned'; -import { useV2Unread } from '../hooks/useV2Unread'; +import V2Avatar from './V2Avatar'; +import { UseV2PodsResult, V2Pod, V2PodMember, useV2Pods } from '../hooks/useV2Pods'; import { useV2Api } from '../hooks/useV2Api'; -import { useSocket } from '../../context/SocketContext'; import { useAuth } from '../../context/AuthContext'; -type Filter = 'all' | 'team' | 'private' | 'community'; -type CommunityView = 'joined' | 'discover'; - -const FILTERS: Array<{ key: Filter; labelKey: string }> = [ - { key: 'all', labelKey: 'filters.all' }, - { key: 'team', labelKey: 'filters.team' }, - { key: 'private', labelKey: 'filters.private' }, - { key: 'community', labelKey: 'filters.community' }, -]; -const COMMUNITY_VIEWS: CommunityView[] = ['joined', 'discover']; +interface Connector { + _id: string; + type: string; + status: string; + podId?: { _id: string; name?: string } | string | null; +} -// Both agent-room (user↔agent) and agent-dm (any 2-member combo) show -// up under the DMs filter. agent-dm with two bot members gets a -// distinct icon ("AA" — agent ↔ agent) so users can tell at a glance -// that no human is in the conversation. -const isDmPod = (pod: V2Pod): boolean => pod.type === 'agent-room' || pod.type === 'agent-dm'; -const podKind = (pod: V2Pod): 'team' | 'private' => (isDmPod(pod) ? 'private' : 'team'); -const isPersonalPod = (pod: V2Pod): boolean => isDmPod(pod) || pod.type === 'agent-admin'; +interface AttentionItem { + id: string; + podId: string | null; +} -const isAgentToAgent = (pod: V2Pod): boolean => { - if (pod.type !== 'agent-dm') return false; - const members = pod.members || []; - if (members.length < 2) return false; - return members.every((m) => typeof m === 'object' && !!m?.isBot); -}; +interface V2PodsSidebarProps { + selectedPodId: string | null; + podsState?: UseV2PodsResult; + // The drawer only exists below 760px. Desktop always shows this column. + mobileOpen?: boolean; + onMobileClose?: () => void; +} -const podMarkFor = (pod: V2Pod): string => { - if (isAgentToAgent(pod)) return 'AA'; - return podKind(pod) === 'private' ? 'DM' : initialsFor(pod.name).slice(0, 2); +const DM_POD_TYPES = new Set(['agent-room', 'agent-dm']); + +const connectorLabel = (type: string): string => ({ + telegram: 'Telegram', + discord: 'Discord', + slack: 'Slack', + whatsapp: 'WhatsApp', + messenger: 'Messenger', + groupme: 'GroupMe', + x: 'X', + instagram: 'Instagram', +}[type] || type); + +const podTimestamp = (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 podMarkClass = (pod: V2Pod): string => ( - `v2-pods__item-icon v2-pods__item-icon--${podKind(pod)}` -); - -const agentCountFor = (pod: V2Pod): number => ( - (pod.members || []).filter((member) => typeof member === 'object' && !!member?.isBot).length +const sortPods = (pods: V2Pod[]): V2Pod[] => ( + [...pods].sort((left, right) => podTimestamp(right) - podTimestamp(left)) ); -// Slack/iMessage pattern: line 2 of each row is the most recent message -// preview, with the author prefix when it's not the current user. Falls back -// to description/meta when the pod has no messages yet (newly-created pods). -const podSnippetFor = (pod: V2Pod, meta: string): string => { - const last = pod.lastMessage; - if (last && last.content) { - const author = last.username ? `${last.username}: ` : ''; - // Render an attached-file directive as a paperclip + filename instead of - // leaking the raw [[upload:fileName|originalName|size|kind]] token into the - // preview (matches how V2MessageBubble turns it into a pill in the thread). - const content = last.content - .replace(/\[\[upload:[^\]|]+\|([^\]|]+)\|[^\]]+\]\]/g, '📎 $1') - .replace(/\s+/g, ' ') - .trim(); - return `${author}${content}`; - } - return pod.description?.trim() || meta; +const isHumanPair = (pod: V2Pod): boolean => { + const members = pod.members || []; + return pod.type === 'chat' + && members.length === 2 + && members.every((member) => typeof member === 'object' && !member.isBot); }; -const matchesPersonalFilter = (pod: V2Pod, filter: Filter): boolean => { - switch (filter) { - // "Team" is the strictest pod-type filter — only pods explicitly typed - // as team (multi-human collaborative). "DMs" is 1:1 conversations. - // "All" covers everything (the redundant "Pod" filter was removed). - case 'team': return pod.type === 'team'; - case 'private': return podKind(pod) === 'private'; - case 'community': return false; - case 'all': - default: - return true; - } -}; +// The stored types stay unchanged. The workspace grammar puts agent DMs and +// human two-person chat under direct; every other member pod remains a room. +// Keeping this predicate named makes a new Pod type an explicit review point. +export const isDirectPod = (pod: V2Pod): boolean => ( + DM_POD_TYPES.has(String(pod.type || '')) || isHumanPair(pod) +); -interface V2PodsSidebarProps { - selectedPodId: string | null; - podsState?: UseV2PodsResult; - // Mobile (<=760px) slide-over drawer state. On phones the sidebar is a - // fixed overlay rather than a grid column; `mobileOpen` drives the - // open/closed transform and `onMobileClose` dismisses it (selecting a pod, - // creating one). Both no-ops on desktop where the sidebar is always visible. - mobileOpen?: boolean; - onMobileClose?: () => void; -} +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 V2PodsSidebar: React.FC = ({ selectedPodId, podsState, mobileOpen = false, onMobileClose, @@ -99,348 +75,78 @@ const V2PodsSidebar: React.FC = ({ const { t } = useTranslation(); const navigate = useNavigate(); const api = useV2Api(); + const { currentUser } = useAuth(); const ownPodsState = useV2Pods(); const { - pods, loading, error, refresh, createPod, patchLastMessage, + pods, loading, error, createPod, } = podsState || ownPodsState; - const { pinned, toggle: togglePin, isPinned } = useV2Pinned(); - const { socket, connected, joinPod } = useSocket(); - const { currentUser } = useAuth(); - const { isUnread, bumpLatest, seedFromExisting } = useV2Unread(selectedPodId); - const [filter, setFilter] = useState('all'); - const [search, setSearch] = useState(''); - const [communityPods, setCommunityPods] = useState([]); - const [communityLoading, setCommunityLoading] = useState(true); - const [communityView, setCommunityView] = useState('joined'); - const [discoverPods, setDiscoverPods] = useState([]); - const [discoverLoading, setDiscoverLoading] = useState(false); - const [discoverError, setDiscoverError] = useState(null); - const [joinedDiscoverPods, setJoinedDiscoverPods] = useState([]); - const [joiningPodId, setJoiningPodId] = useState(null); - const [joinNotice, setJoinNotice] = useState(null); + const [connectors, setConnectors] = useState([]); + const [attentionItems, setAttentionItems] = useState([]); + const [creating, setCreating] = useState(false); + const [showCreate, setShowCreate] = useState(false); + const [newPodName, setNewPodName] = useState(''); + const [newPodGoal, setNewPodGoal] = useState(''); + const [createError, setCreateError] = useState(null); - // Discovery is intentionally fetched into separate state. Merging these - // rows into `pods` would make non-member public pods leak into All/Team, - // undoing the membership-only sidebar invariant from #375. + // Channels are the caller's existing bindings, never a discovery surface. useEffect(() => { let active = true; - setCommunityLoading(true); - api.get('/api/pods?scope=community') + api.get('/api/integrations/user/all') .then((data) => { - if (active) setCommunityPods(Array.isArray(data) ? data : []); + if (active) setConnectors(Array.isArray(data) ? data : []); }) .catch(() => { - if (active) setCommunityPods([]); - }) - .finally(() => { - if (active) setCommunityLoading(false); + if (active) setConnectors([]); }); - return () => { - active = false; - }; + return () => { active = false; }; }, [api]); - // Discover stays separate from the membership-backed pod list: this endpoint - // deliberately returns pods the caller has NOT joined. Fetch it only while - // the Discover view is active so the default personal sidebar does not pay - // for discovery data it never renders. + // TASK-129 deliberately has one attention source. PR 2 receives this same + // collection for the inspector's rows; the sidebar only derives its count. useEffect(() => { - if (filter !== 'community' || communityView !== 'discover') return undefined; let active = true; - setDiscoverLoading(true); - setDiscoverError(null); - api.get('/api/pods?scope=discover') + api.get<{ items?: AttentionItem[] }>('/api/activity/decision-queue') .then((data) => { - if (!active) return; - setDiscoverPods((Array.isArray(data) ? data : []).filter((pod) => !isPersonalPod(pod))); + if (active) setAttentionItems(Array.isArray(data?.items) ? data.items : []); }) .catch(() => { - if (!active) return; - setDiscoverPods([]); - setDiscoverError(t('podsSidebar.discover.loadFailed')); - }) - .finally(() => { - if (active) setDiscoverLoading(false); + if (active) setAttentionItems([]); }); - return () => { - active = false; - }; - }, [api, communityView, filter, t]); - - // Seed lastSeen for any pod we've never observed before, using its current - // newest-message timestamp. Without this, the very first load badges every - // pod the user is a member of as unread — they aren't. Re-runs whenever a - // new pod shows up; existing entries are never overwritten. - useEffect(() => { - if (!pods || pods.length === 0) return; - seedFromExisting(pods.map((p) => ({ - podId: p._id, - lastMessageAt: p.lastMessage?.createdAt || p.updatedAt || p.createdAt, - }))); - }, [pods, seedFromExisting]); - - // Server-side socket auth (`authorizeSocketPodAccess`) requires the user to - // be in `pod.members` — `/api/pods` returns discoverable pods including - // ones the user can browse but isn't a member of, so we have to gate the - // joinPod calls. Otherwise we eat a flood of `Not authorized to join` errors. - const memberPodIds = useMemo(() => { - const me = currentUser?._id; - if (!me) return [] as string[]; - return pods - .filter((pod) => Array.isArray(pod.members) && pod.members.some((m) => { - if (!m) return false; - if (typeof m === 'string') return m === me; - return m._id === me; - })) - .map((pod) => pod._id); - }, [pods, currentUser?._id]); - const communityPodId = process.env.REACT_APP_COMMUNITY_POD_ID || ''; - const showCommunityOffer = Boolean(communityPodId) && Boolean(currentUser?._id) - && !loading && !error - && !memberPodIds.includes(communityPodId); - const [creating, setCreating] = useState(false); - const [showCreate, setShowCreate] = useState(false); - const [newPodName, setNewPodName] = useState(''); - const [newPodGoal, setNewPodGoal] = useState(''); - // Every pod is born private and unlisted (ADR-016). `joinPolicy: 'open'` - // below the community tier is a DORMANT declaration — "open once listed" — - // not an audience the creator is choosing here. Creation asks for name and - // purpose only; visibility is set later, on a pod that has something in it. - const newPodJoinPolicy = 'open' as const; - const [createError, setCreateError] = useState(null); - // Pod IDs an agent is currently typing into. Set, not bool, because - // multiple agents could type at once. Cleared after 30s safety in case the - // stop event is missed. - const [typingPods, setTypingPods] = useState>(() => new Set()); - const typingTimersRef = useRef>>({}); - - // Join every member-pod's socket room so we hear newMessage / typing events - // even while another pod is selected. The chat room hook (`useV2PodDetail`) - // calls leavePod when its podId changes — without re-joining here, sidebar - // would silently miss updates for the just-left pod. We re-fire on - // selectedPodId change to recover those joins (joinPod is idempotent on - // the server). No leavePod in cleanup: the server-side - // `socket.on('disconnect')` handler clears all pod memberships when the - // tab closes, so we don't leak rooms. - useEffect(() => { - if (!socket || !connected || memberPodIds.length === 0) return; - memberPodIds.forEach((id) => joinPod(id)); - }, [socket, connected, memberPodIds, selectedPodId, joinPod]); - - // Cross-pod newMessage subscription: bump lastMessage and unread badge. - useEffect(() => { - if (!socket || !connected) return undefined; - interface IncomingMessage { - pod_id?: string; - podId?: string; - content?: string; - created_at?: string; - createdAt?: string; - user?: { username?: string; profile_picture?: string | null }; - username?: string; - } - const handleNewMessage = (msg: IncomingMessage) => { - const podId = msg?.pod_id || msg?.podId; - if (!podId) return; - const createdAt = msg.created_at || msg.createdAt || new Date().toISOString(); - patchLastMessage(podId, { - content: msg.content || '', - createdAt, - username: msg.user?.username || msg.username || null, - }); - if (podId !== selectedPodId) bumpLatest(podId, createdAt); - }; - socket.on('newMessage', handleNewMessage); - return () => { - socket.off('newMessage', handleNewMessage); - }; - }, [socket, connected, selectedPodId, patchLastMessage, bumpLatest]); + return () => { active = false; }; + }, [api]); - // Cross-pod typing indicator. A row in the sidebar shows "typing…" if any - // agent is mid-flight in that pod, regardless of which pod is open. - useEffect(() => { - if (!socket || !connected) return undefined; - interface TypingPayload { podId?: string } - const scheduleClear = (podId: string) => { - if (typingTimersRef.current[podId]) clearTimeout(typingTimersRef.current[podId]); - typingTimersRef.current[podId] = setTimeout(() => { - setTypingPods((prev) => { - if (!prev.has(podId)) return prev; - const next = new Set(prev); - next.delete(podId); - return next; - }); - delete typingTimersRef.current[podId]; - }, 30000); - }; - const handleStart = (payload: TypingPayload) => { - if (!payload?.podId) return; - setTypingPods((prev) => { - if (prev.has(payload.podId!)) return prev; - const next = new Set(prev); - next.add(payload.podId!); - return next; - }); - scheduleClear(payload.podId); - }; - const handleStop = (payload: TypingPayload) => { - if (!payload?.podId) return; - const podId = payload.podId; - if (typingTimersRef.current[podId]) { - clearTimeout(typingTimersRef.current[podId]); - delete typingTimersRef.current[podId]; - } - setTypingPods((prev) => { - if (!prev.has(podId)) return prev; - const next = new Set(prev); - next.delete(podId); - return next; - }); - }; - socket.on('agent_typing_start', handleStart); - socket.on('agent_typing_stop', handleStop); - return () => { - socket.off('agent_typing_start', handleStart); - socket.off('agent_typing_stop', handleStop); - Object.values(typingTimersRef.current).forEach(clearTimeout); - typingTimersRef.current = {}; + const { rooms, direct } = useMemo(() => { + const roomPods = pods.filter((pod) => !isDirectPod(pod)); + const directPods = pods.filter(isDirectPod); + return { rooms: sortPods(roomPods), direct: sortPods(directPods) }; + }, [pods]); + + const attentionCountByPod = useMemo(() => attentionItems.reduce>((counts, item) => { + if (!item.podId) return counts; + counts[item.podId] = (counts[item.podId] || 0) + 1; + return counts; + }, {}), [attentionItems]); + + 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}`, }; - }, [socket, connected]); + }), [connectors, pods, t]); - const communityPodIds = useMemo( - () => new Set(communityPods.map((pod) => pod._id)), - [communityPods], - ); - const communityCandidates = useMemo(() => { - const byId = new Map(); - communityPods.forEach((pod) => byId.set(pod._id, pod)); - joinedDiscoverPods.forEach((pod) => byId.set(pod._id, pod)); - // Prefer the membership-list row when both endpoints return a pod; it is - // the row socket/unread state already knows about. - pods.forEach((pod) => byId.set(pod._id, pod)); - const effectiveMemberIds = new Set([ - ...memberPodIds, - ...joinedDiscoverPods.map((pod) => pod._id), - ]); - return Array.from(byId.values()).filter((pod) => { - if (isPersonalPod(pod)) return false; - if (!effectiveMemberIds.has(pod._id)) return false; - // Community membership is opt-in `communityListed` (the scope=community - // result set + Discover-joined + HQ) — NOT `publicRead`. publicRead is - // anonymous/showcase read only (#722); showcase pods like the milestone - // and engineering rooms are publicRead but must NOT appear in Community. - return communityPodIds.has(pod._id) - || joinedDiscoverPods.some((joined) => joined._id === pod._id) - || (Boolean(communityPodId) && pod._id === communityPodId); - }); - }, [ - communityPods, - communityPodIds, - joinedDiscoverPods, - pods, - memberPodIds, - communityPodId, - ]); + const nextPlatform = connectors.some((connector) => connector.type === 'slack') ? 'Telegram' : 'Slack'; - const filtered = useMemo(() => { - const term = search.trim().toLowerCase(); - const candidates = filter === 'community' - ? (communityView === 'discover' ? discoverPods : communityCandidates) - : pods; - return candidates - .filter((pod) => filter === 'community' || matchesPersonalFilter(pod, filter)) - .filter((pod) => !term - || (pod.name || '').toLowerCase().includes(term) - || (pod.description || '').toLowerCase().includes(term)); - }, [communityCandidates, communityView, discoverPods, pods, filter, search]); - - const visibleLoading = loading || ( - filter === 'community' - && (communityView === 'discover' ? discoverLoading : communityLoading) - ); - const visibleError = filter === 'community' && communityView === 'discover' - ? discoverError - : error; - - // Default grouping is chronological (Pinned / Today / Yesterday / This week - // / Earlier). When the user filters down to DMs, recency buckets stop - // adding signal — DMs are already a tight list — and the meaningful split - // is human↔agent vs agent↔agent. Bot-bot rooms are observer-only (§3.7), - // so a section header makes the relationship explicit at a glance and - // doubles as a count of how many a2a conversations are happening. - const grouped = useMemo(() => { - if (filter === 'community' && communityView === 'discover') return []; - if (filter !== 'private') { - // groupPods returns stable PodGroup KEYS ('Today', 'This week', …); - // rendering them raw shipped English headers into zh-CN (Sam's - // "cn version is messed up" report, 2026-08-23). Translate at this - // boundary, exactly like the DM buckets below already do. - const chronoKeys: Record = { - Pinned: 'podsSidebar.groups.pinned', - Today: 'podsSidebar.groups.today', - Yesterday: 'podsSidebar.groups.yesterday', - 'This week': 'podsSidebar.groups.thisWeek', - Earlier: 'podsSidebar.groups.earlier', - }; - return groupPods(filtered, pinned).map((g) => ({ - ...g, - label: chronoKeys[g.label] ? t(chronoKeys[g.label]) : g.label, - })); - } - const sortByRecency = (items: T[]): T[] => { - const ts = (it: T) => { - const raw = it.lastMessage?.createdAt || it.updatedAt || it.createdAt; - const n = raw ? new Date(raw).getTime() : 0; - return Number.isNaN(n) ? 0 : n; - }; - return [...items].sort((a, b) => ts(b) - ts(a)); - }; - const pinnedItems = filtered.filter((p) => pinned.has(p._id)); - const rest = filtered.filter((p) => !pinned.has(p._id)); - const a2a = rest.filter((p) => isAgentToAgent(p)); - const dms = rest.filter((p) => !isAgentToAgent(p)); - const buckets: Array<{ label: string; items: typeof filtered }> = []; - if (pinnedItems.length) buckets.push({ label: t('podsSidebar.groups.pinned'), items: sortByRecency(pinnedItems) }); - if (dms.length) buckets.push({ label: t('podsSidebar.groups.directMessages'), items: sortByRecency(dms) }); - if (a2a.length) buckets.push({ label: t('podsSidebar.groups.betweenAgents', { count: a2a.length }), items: sortByRecency(a2a) }); - return buckets; - }, [communityView, filter, filtered, pinned, t]); - - // Navigate to a pod and, on mobile, dismiss the slide-over drawer so the - // user lands directly in the chat instead of behind the overlay. const selectPod = (podId: string) => { navigate(`/v2/pods/${podId}`); onMobileClose?.(); }; - const handleJoinDiscoverPod = async (pod: V2Pod) => { - setJoiningPodId(pod._id); - setJoinNotice(null); - try { - const joined = await api.post(`/api/pods/${pod._id}/join`); - const joinedPod = joined?._id ? joined : pod; - setDiscoverPods((current) => current.filter((item) => item._id !== pod._id)); - setJoinedDiscoverPods((current) => [ - joinedPod, - ...current.filter((item) => item._id !== joinedPod._id), - ]); - setCommunityView('joined'); - void refresh?.(); - selectPod(joinedPod._id); - } catch (err) { - const status = (err as { response?: { status?: number } }).response?.status; - if (status === 403) { - setJoinNotice(t('podsSidebar.discover.inviteRequired')); - } else if (status === 429) { - setJoinNotice(t('podsSidebar.discover.rateLimited')); - } else { - setJoinNotice(t('podsSidebar.discover.joinFailed')); - } - } finally { - setJoiningPodId(null); - } - }; - const handleCreatePod = async (event: React.FormEvent) => { event.preventDefault(); const name = newPodName.trim(); @@ -448,294 +154,144 @@ const V2PodsSidebar: React.FC = ({ setCreating(true); setCreateError(null); try { - const pod = await createPod( - name, - newPodGoal.trim() || undefined, - 'team', - newPodJoinPolicy, - ); - if (pod?._id) { - try { - sessionStorage.setItem(`v2.justCreated.${pod._id}`, '1'); - } catch { - // The pod still opens normally when sessionStorage is unavailable; - // only the one-session starter panel is skipped. - } - setNewPodName(''); - setNewPodGoal(''); - setShowCreate(false); - selectPod(pod._id); - } else { + const pod = await createPod(name, newPodGoal.trim() || undefined, 'team', 'open'); + if (!pod?._id) { setCreateError(t('podsSidebar.errors.createFailed')); + return; + } + try { + sessionStorage.setItem(`v2.justCreated.${pod._id}`, '1'); + } catch { + // The pod still opens normally when sessionStorage is unavailable. } + setNewPodName(''); + setNewPodGoal(''); + setShowCreate(false); + selectPod(pod._id); } finally { setCreating(false); } }; + const renderPodRow = (pod: V2Pod, kind: 'room' | 'direct') => { + const selected = pod._id === selectedPodId; + const attentionCount = attentionCountByPod[pod._id] || 0; + const directMember = kind === 'direct' ? directMemberFor(pod, currentUser?._id) : undefined; + return ( + + ); + }; + return ( diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index d9115f8dac..3d46c17151 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -133,9 +133,9 @@ body.modern-ui.v2-canvas { --v2-shadow-pending: none; /* the one chrome-card shadow: a PENDING decision or approval only */ /* Layout */ - --v2-rail-w: 76px; - --v2-pods-w: 272px; - --v2-inspector-w: 336px; + --v2-rail-w: 56px; + --v2-pods-w: 232px; + --v2-inspector-w: 300px; /* Keep production's scoped V2 layer in step with design-system/tokens.css. The typing cadence belongs to the minimal mark; pulse/spin retain their @@ -9799,3 +9799,205 @@ body.modern-ui.v2-canvas { @media (max-width: 640px) { .v2-activity__reply { flex-basis: 100%; } } + +/* ---------- Workspace sidebar (TASK-129 PR 1) ---------- + The workspace artboard is a ground-colour grid, not three inset cards. + These rules intentionally follow the retired sidebar rules above while the + chat and inspector receive their own structural passes in PRs 2 and 3. */ + +.v2-shell { + gap: 12px; + padding: 14px; + box-sizing: border-box; +} + +.v2-shell .v2-pane { + height: 100%; + min-height: 0; +} + +.v2-shell .v2-pane--main { + margin: 0; + height: 100%; +} + +.v2-pods { + position: relative; + height: 100%; + padding: 6px 12px; + background: var(--v2-shell-bg); +} + +.v2-pods__list { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: 18px; + overflow-y: auto; +} + +.v2-pods__group { + min-width: 0; +} + +.v2-pods__group-label { + margin: 0 0 6px; + padding: 0; + color: var(--v2-text-muted); + font-family: var(--v2-font-mono); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.02em; + line-height: 16px; + text-transform: lowercase; +} + +.v2-pods__rows { + display: flex; + flex-direction: column; + gap: 2px; +} + +.v2-root button.v2-pods__row, +.v2-root button.v2-pods__channel, +.v2-root button.v2-pods__new-line, +.v2-root button.v2-pods__connect-line { + width: 100%; + min-width: 0; + border: 0; + background: transparent; + color: var(--v2-text-primary); + font: 500 13px/18px var(--v2-font); + text-align: left; +} + +.v2-root button.v2-pods__row { + display: flex; + min-height: 36px; + align-items: center; + gap: 7px; + padding: 9px 12px; + border-radius: 4px; +} + +.v2-root button.v2-pods__row:hover, +.v2-root button.v2-pods__channel:hover:not(:disabled) { + background: var(--v2-surface-hover); +} + +.v2-root button.v2-pods__row--selected, +.v2-root button.v2-pods__row--selected:hover { + background: var(--v2-accent); + color: #fff; + font-weight: 600; +} + +.v2-pods__row-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-pods__attention-count { + margin-left: auto; + color: #fff; + font-family: var(--v2-font-mono); + font-size: 12px; + font-weight: 500; + line-height: 16px; +} + +.v2-pods__direct-avatar.v2-avatar { + width: 20px; + height: 20px; + border-radius: 4px; + font-size: 8px; +} + +.v2-root button.v2-pods__channel { + display: flex; + min-height: 32px; + align-items: center; + gap: 8px; + padding: 7px 12px; + border-radius: 4px; + color: var(--v2-text-secondary); +} + +.v2-root button.v2-pods__channel:disabled { + cursor: default; +} + +.v2-pods__channel-dot { + width: 8px; + height: 8px; + flex: 0 0 8px; + border: 1px solid var(--v2-border-strong); + background: transparent; +} + +.v2-pods__channel-dot--live { + border-color: var(--v2-accent); + background: var(--v2-accent); +} + +.v2-root button.v2-pods__new-line, +.v2-root button.v2-pods__connect-line { + min-height: 32px; + padding: 7px 12px; + color: var(--v2-accent-text); + font-size: 12px; + font-weight: 600; +} + +.v2-root button.v2-pods__new-line { margin-top: 2px; } + +.v2-pods__create { + margin: 4px 0 0; + padding: 8px; + border: 1px solid var(--v2-border); + border-radius: 4px; + background: var(--v2-surface); +} + +.v2-pods__create-input { + min-height: 32px; + font-size: 12px; +} + +.v2-root button.v2-pods__create-cancel, +.v2-root button.v2-pods__create-submit { + min-height: 30px; + padding: 6px 9px; +} + +.v2-pods__empty { + padding: 28px 12px; + font-size: 12px; +} + +@media (max-width: 760px) { + .v2-shell, + .v2-shell--no-inspector, + .v2-shell--feature { + gap: 0; + padding: 0; + } + + .v2-shell .v2-pane { + height: 100vh; + } + + .v2-pods-aside { + width: min(86vw, 320px); + } + + .v2-pods__mobile-close { + position: absolute; + top: 6px; + right: 6px; + z-index: 1; + } +} From 33b16b18dbc54071255dea1678b9abf0ae31cb00 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 5 Sep 2026 05:17:58 -0700 Subject: [PATCH 2/6] fix(v2): preserve sidebar row geometry --- .../V2PodsSidebar.community.test.tsx | 2 ++ .../v2/__tests__/v2-layout-invariants.test.ts | 11 ++++++++++ frontend/src/v2/components/V2PodsSidebar.tsx | 21 ++++++++++++++++--- frontend/src/v2/v2.css | 6 +++--- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx b/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx index ce9c8bfa46..614b5b28b5 100644 --- a/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx +++ b/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx @@ -80,6 +80,7 @@ describe('V2PodsSidebar workspace groups', () => { 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 rooms = screen.getByRole('heading', { name: 'rooms' }).closest('section'); @@ -90,6 +91,7 @@ describe('V2PodsSidebar workspace groups', () => { expect(within(rooms).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(); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index d6ac24350f..d69aee6b50 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -132,6 +132,10 @@ describe('v2 layout invariants (CSS rule presence)', () => { 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(channelDot).toContain('width: 8px'); expect(liveChannelDot).toContain('background: var(--v2-accent)'); expect(directAvatar).toContain('width: 20px'); @@ -160,6 +164,13 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(podsSidebar).not.toContain('useV2Unread'); }); + test('room types are an explicit reviewed mapping, not a complement of direct types', () => { + expect(podsSidebar).toContain('const ROOM_POD_TYPES'); + expect(podsSidebar).toContain('export const isRoomPod'); + expect(podsSidebar).toContain('pods.filter(isRoomPod)'); + expect(podsSidebar).not.toContain('pods.filter((pod) => !isDirectPod(pod))'); + }); + 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'); diff --git a/frontend/src/v2/components/V2PodsSidebar.tsx b/frontend/src/v2/components/V2PodsSidebar.tsx index e95f6638e6..37c1760aea 100644 --- a/frontend/src/v2/components/V2PodsSidebar.tsx +++ b/frontend/src/v2/components/V2PodsSidebar.tsx @@ -27,6 +27,17 @@ interface V2PodsSidebarProps { } 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. +const ROOM_POD_TYPES = new Set([ + 'chat', + 'study', + 'games', + 'agent-ensemble', + 'agent-admin', + 'team', +]); const connectorLabel = (type: string): string => ({ telegram: 'Telegram', @@ -57,12 +68,16 @@ const isHumanPair = (pod: V2Pod): boolean => { }; // The stored types stay unchanged. The workspace grammar puts agent DMs and -// human two-person chat under direct; every other member pod remains a room. -// Keeping this predicate named makes a new Pod type an explicit review point. +// human two-person chat under direct. See ROOM_POD_TYPES for the explicitly +// reviewed room types; unknown types do not silently join a list. export const isDirectPod = (pod: V2Pod): boolean => ( DM_POD_TYPES.has(String(pod.type || '')) || isHumanPair(pod) ); +export const isRoomPod = (pod: V2Pod): boolean => ( + ROOM_POD_TYPES.has(String(pod.type || '')) && !isHumanPair(pod) +); + const directMemberFor = (pod: V2Pod, currentUserId?: string): V2PodMember | undefined => ( (pod.members || []).find((member): member is V2PodMember => ( typeof member === 'object' && member._id !== currentUserId @@ -116,7 +131,7 @@ const V2PodsSidebar: React.FC = ({ }, [api]); const { rooms, direct } = useMemo(() => { - const roomPods = pods.filter((pod) => !isDirectPod(pod)); + const roomPods = pods.filter(isRoomPod); const directPods = pods.filter(isDirectPod); return { rooms: sortPods(roomPods), direct: sortPods(directPods) }; }, [pods]); diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 3d46c17151..d62898445e 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -9824,7 +9824,7 @@ body.modern-ui.v2-canvas { .v2-pods { position: relative; height: 100%; - padding: 6px 12px; + padding: 6px 0; background: var(--v2-shell-bg); } @@ -9843,7 +9843,7 @@ body.modern-ui.v2-canvas { .v2-pods__group-label { margin: 0 0 6px; - padding: 0; + padding: 0 12px; color: var(--v2-text-muted); font-family: var(--v2-font-mono); font-size: 11px; @@ -9955,7 +9955,7 @@ body.modern-ui.v2-canvas { .v2-root button.v2-pods__new-line { margin-top: 2px; } .v2-pods__create { - margin: 4px 0 0; + margin: 4px 12px 0; padding: 8px; border: 1px solid var(--v2-border); border-radius: 4px; From 50d953fdc67b8b31ec5dda1fa97daaddd7ef9482 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 5 Sep 2026 05:23:28 -0700 Subject: [PATCH 3/6] test(v2): cover sidebar pod type mapping --- .../v2/__tests__/v2-layout-invariants.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index d69aee6b50..af09341173 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -94,6 +94,7 @@ describe('v2 layout invariants (CSS rule presence)', () => { const avatar = read('../components/V2Avatar.tsx'); const billingPanel = read('../components/V2BillingPanel.tsx'); const appsManagement = read('../../components/AppsManagement.tsx'); + const podModel = read('../../../../backend/models/Pod.ts'); test('Your Team card name owns its line so the category chip cannot crush it', () => { const rule = ruleBody(v2, '.v2-team-card__name'); @@ -171,6 +172,24 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(podsSidebar).not.toContain('pods.filter((pod) => !isDirectPod(pod))'); }); + test('every stored pod type has exactly one reviewed sidebar group', () => { + const entriesFor = (source: string, setName: string): string[] => { + const match = source.match(new RegExp(`const ${setName} = new Set\\(\\[([\\s\\S]*?)\\]\\);`)); + return Array.from(match?.[1].matchAll(/'([^']+)'/g) || [], ([, entry]) => entry); + }; + const schemaTypes = Array.from( + podModel.match(/type:\s*\{[\s\S]*?enum:\s*\[([^\]]+)\],[\s\S]*?default:\s*'chat'/)?.[1].matchAll(/'([^']+)'/g) || [], + ([, entry]) => entry, + ); + const sidebarTypes = [ + ...entriesFor(podsSidebar, 'ROOM_POD_TYPES'), + ...entriesFor(podsSidebar, 'DM_POD_TYPES'), + ]; + + expect(new Set(sidebarTypes).size).toBe(sidebarTypes.length); + expect(sidebarTypes.sort()).toEqual(schemaTypes.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'); From 82fa5ef888aa7d8a0bdd0c4dc115848e44abf757 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:09:13 -0700 Subject: [PATCH 4/6] fix(v2): size workspace rail to artboard --- .../v2/__tests__/v2-layout-invariants.test.ts | 14 ++++++++++ frontend/src/v2/v2.css | 27 ++++++++++--------- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index af09341173..2122d3689e 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -121,6 +121,20 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(lastRuleBody(v2, '.v2-shell')).toContain('padding: 14px'); }); + test('the compact rail uses the artboard’s 32px square marks inside the 56px column', () => { + const rail = ruleBody(v2, '.v2-rail'); + const brand = ruleBody(v2, '.v2-rail__brand-icon'); + const item = ruleBody(v2, '.v2-root button.v2-rail__item'); + const active = ruleBody(v2, '.v2-root button.v2-rail__item--active'); + expect(rail).toContain('padding: 6px 10px'); + expect(brand).toContain('width: 32px'); + expect(brand).toContain('height: 32px'); + expect(item).toContain('width: 32px'); + expect(item).toContain('height: 32px'); + expect(item).toContain('border-radius: var(--v2-radius)'); + 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 selected = selectorRuleBody(v2, '.v2-root button.v2-pods__row--selected'); diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index d62898445e..290903a011 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -575,7 +575,7 @@ body.modern-ui.v2-canvas { .v2-rail { height: 100%; - padding: 18px 10px; + padding: 6px 10px; display: flex; flex-direction: column; gap: 8px; @@ -640,7 +640,7 @@ body.modern-ui.v2-canvas { font-weight: 700; font-size: 17px; padding: 0; - margin-bottom: 22px; + margin-bottom: 16px; color: var(--v2-text-primary); letter-spacing: -0.02em; height: 32px; @@ -651,8 +651,8 @@ body.modern-ui.v2-canvas { } .v2-rail__brand-icon { - width: 28px; - height: 28px; + width: 32px; + height: 32px; border-radius: var(--v2-radius-sm); background: var(--v2-accent); color: #fff; @@ -669,22 +669,25 @@ body.modern-ui.v2-canvas { gap: 4px; min-height: 0; overflow-y: auto; - padding-right: 2px; + padding-right: 0; } -.v2-rail__item { +.v2-root button.v2-rail__item { position: relative; display: flex; align-items: center; justify-content: center; gap: 0; - min-height: 44px; + width: 32px; + min-height: 32px; + height: 32px; + align-self: center; padding: 0; border-radius: var(--v2-radius); font-size: 14px; font-weight: 600; color: #374151; - width: 100%; + background: var(--v2-surface-hover); text-align: left; transition: background 80ms ease; } @@ -707,14 +710,14 @@ body.modern-ui.v2-canvas { white-space: nowrap; } -.v2-rail__item:hover { +.v2-root button.v2-rail__item:hover { background: var(--v2-surface-hover); color: var(--v2-text-primary); } -.v2-rail__item--active { - background: var(--v2-accent-soft); - color: var(--v2-accent-text); +.v2-root button.v2-rail__item--active { + background: var(--v2-ink); + color: var(--v2-on-ink); font-weight: 600; } From 4fbe997f26704eb74eac05a4b93b0d835a07c34f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:34:58 -0700 Subject: [PATCH 5/6] feat(v2): group workspace pods in sidebar --- frontend/src/i18n/locales/en.json | 10 ++- frontend/src/i18n/locales/zh-CN.json | 10 ++- .../V2PodsSidebar.community.test.tsx | 64 ++++++++++++++++--- .../v2/__tests__/v2-layout-invariants.test.ts | 22 +++++-- frontend/src/v2/components/V2PodsSidebar.tsx | 62 ++++++++++++++++-- frontend/src/v2/hooks/useV2Pods.ts | 3 + frontend/src/v2/v2.css | 24 ++++++- 7 files changed, 173 insertions(+), 22 deletions(-) diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index dad1e2bf2a..bc820c3cef 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1499,7 +1499,15 @@ "button": "Join HQ" }, "workspace": { - "rooms": "rooms", + "pods": "pods", + "pinned": "pinned", + "community": "community", + "team": "team", + "chat": "chat", + "study": "study", + "games": "games", + "ensemble": "ensemble", + "admin": "admin", "channels": "channels", "direct": "direct", "new": "new", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index de222b0319..766aa6f081 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1493,7 +1493,15 @@ "button": "加入 HQ" }, "workspace": { - "rooms": "房间", + "pods": "Pods", + "pinned": "置顶", + "community": "社区", + "team": "团队", + "chat": "聊天", + "study": "学习", + "games": "游戏", + "ensemble": "协作组", + "admin": "管理", "channels": "频道", "direct": "私信", "new": "新建", diff --git a/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx b/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx index 614b5b28b5..30545bcdfa 100644 --- a/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx +++ b/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx @@ -5,10 +5,11 @@ import { } from '@testing-library/react'; import { MemoryRouter, useLocation } from 'react-router-dom'; import i18n, { i18nReady } from '../../i18n'; -import V2PodsSidebar from '../components/V2PodsSidebar'; +import V2PodsSidebar, { groupWorkspacePods } from '../components/V2PodsSidebar'; const mockApiGet = jest.fn(); const mockCreatePod = jest.fn(); +const mockPinned = new Set(); jest.mock('../hooks/useV2Pods', () => ({ useV2Pods: () => ({ @@ -20,18 +21,23 @@ 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) => ({ +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 }); @@ -58,6 +64,7 @@ describe('V2PodsSidebar workspace groups', () => { beforeEach(async () => { jest.clearAllMocks(); + mockPinned.clear(); mockApiGet.mockImplementation((url) => { if (url === '/api/integrations/user/all') { return Promise.resolve([{ @@ -72,7 +79,7 @@ describe('V2PodsSidebar workspace groups', () => { await act(async () => { await i18n.changeLanguage('en'); }); }); - test('renders existing member pods as rooms, connectors as channels, and DMs as direct', async () => { + 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')]), @@ -83,12 +90,12 @@ describe('V2PodsSidebar workspace groups', () => { pod('future-type', 'Unreviewed Type', 'future-type', [human('me'), human('other')]), ]); - const rooms = screen.getByRole('heading', { name: 'rooms' }).closest('section'); + const pods = screen.getByRole('heading', { name: 'pods' }).closest('section'); const direct = screen.getByRole('heading', { name: 'direct' }).closest('section'); - expect(within(rooms).getByRole('button', { name: 'Sharpen' })).toBeInTheDocument(); - expect(within(rooms).getByRole('button', { name: 'Agent Admin' })).toBeInTheDocument(); - expect(within(rooms).getByRole('button', { name: 'Study Group' })).toBeInTheDocument(); - expect(within(rooms).getByRole('button', { name: 'Project Chat' })).toBeInTheDocument(); + 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(); @@ -98,6 +105,47 @@ describe('V2PodsSidebar workspace groups', () => { 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'); + renderSidebar([ + pod('pinned-team', 'Pinned team', 'team', [human('me'), human('one')]), + 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: 'community' }).closest('section')).getByRole('button', { name: 'Commonly HQ' })).toBeInTheDocument(); + + ['Pinned team', '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')])]); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 2122d3689e..2a12832749 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -137,6 +137,7 @@ describe('v2 layout invariants (CSS rule presence)', () => { 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'); @@ -144,6 +145,10 @@ describe('v2 layout invariants (CSS rule presence)', () => { 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'); @@ -151,11 +156,12 @@ describe('v2 layout invariants (CSS rule presence)', () => { // 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.rooms'"); + 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'); @@ -181,6 +187,10 @@ describe('v2 layout invariants (CSS rule presence)', () => { test('room types 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 isRoomPod'); expect(podsSidebar).toContain('pods.filter(isRoomPod)'); expect(podsSidebar).not.toContain('pods.filter((pod) => !isDirectPod(pod))'); @@ -195,13 +205,15 @@ describe('v2 layout invariants (CSS rule presence)', () => { podModel.match(/type:\s*\{[\s\S]*?enum:\s*\[([^\]]+)\],[\s\S]*?default:\s*'chat'/)?.[1].matchAll(/'([^']+)'/g) || [], ([, entry]) => entry, ); - const sidebarTypes = [ - ...entriesFor(podsSidebar, 'ROOM_POD_TYPES'), - ...entriesFor(podsSidebar, 'DM_POD_TYPES'), - ]; + const roomTypes = entriesFor(podsSidebar, 'ROOM_POD_TYPES'); + const directTypes = entriesFor(podsSidebar, 'DM_POD_TYPES'); + const typeLabelBody = podsSidebar.match(/const ROOM_POD_TYPE_LABELS:[\s\S]*?= \{([\s\S]*?)\};/)?.[1] || ''; + const mappedRoomTypes = Array.from(typeLabelBody.matchAll(/(?:'([^']+)'|(\w+)):\s*'[^']+'/g), ([, quoted, bare]) => quoted || bare); + const sidebarTypes = [...roomTypes, ...directTypes]; expect(new Set(sidebarTypes).size).toBe(sidebarTypes.length); expect(sidebarTypes.sort()).toEqual(schemaTypes.sort()); + expect(mappedRoomTypes.sort()).toEqual(roomTypes.sort()); }); test('channels read the existing connector binding endpoint and retain their room target', () => { diff --git a/frontend/src/v2/components/V2PodsSidebar.tsx b/frontend/src/v2/components/V2PodsSidebar.tsx index 37c1760aea..f73ee28e66 100644 --- a/frontend/src/v2/components/V2PodsSidebar.tsx +++ b/frontend/src/v2/components/V2PodsSidebar.tsx @@ -4,6 +4,7 @@ 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 { @@ -39,6 +40,24 @@ const ROOM_POD_TYPES = new Set([ 'team', ]); +type PodListGroup = 'pinned' | 'community' | 'team' | 'chat' | 'study' | 'games' | 'ensemble' | 'admin'; + +const POD_LIST_GROUP_ORDER: PodListGroup[] = [ + 'pinned', 'community', 'team', 'chat', 'study', 'games', 'ensemble', 'admin', +]; + +// 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> = { + team: 'team', + chat: 'chat', + study: 'study', + games: 'games', + 'agent-ensemble': 'ensemble', + 'agent-admin': 'admin', +}; + const connectorLabel = (type: string): string => ({ telegram: 'Telegram', discord: 'Discord', @@ -78,6 +97,27 @@ 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, [])); + + 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); + }); + + return POD_LIST_GROUP_ORDER + .map((label) => ({ label, pods: sortPods(groups.get(label) || []) })) + .filter((group) => group.pods.length > 0); +}; + const directMemberFor = (pod: V2Pod, currentUserId?: string): V2PodMember | undefined => ( (pod.members || []).find((member): member is V2PodMember => ( typeof member === 'object' && member._id !== currentUserId @@ -91,6 +131,7 @@ const V2PodsSidebar: React.FC = ({ const navigate = useNavigate(); const api = useV2Api(); const { currentUser } = useAuth(); + const { pinned } = useV2Pinned(); const ownPodsState = useV2Pods(); const { pods, loading, error, createPod, @@ -130,11 +171,11 @@ const V2PodsSidebar: React.FC = ({ return () => { active = false; }; }, [api]); - const { rooms, direct } = useMemo(() => { + const { podGroups, direct } = useMemo(() => { const roomPods = pods.filter(isRoomPod); const directPods = pods.filter(isDirectPod); - return { rooms: sortPods(roomPods), direct: sortPods(directPods) }; - }, [pods]); + return { podGroups: groupWorkspacePods(roomPods, pinned), direct: sortPods(directPods) }; + }, [pods, pinned]); const attentionCountByPod = useMemo(() => attentionItems.reduce>((counts, item) => { if (!item.podId) return counts; @@ -237,9 +278,18 @@ const V2PodsSidebar: React.FC = ({ {!loading && error &&
{error}
} {!loading && !error && (
-
-

{t('podsSidebar.workspace.rooms')}

-
{rooms.map((pod) => renderPodRow(pod, 'room'))}
+
+

{t('podsSidebar.workspace.pods')}

+
+ {podGroups.map((group) => ( +
+

+ {t(`podsSidebar.workspace.${group.label}`)} +

+
{group.pods.map((pod) => renderPodRow(pod, 'room'))}
+
+ ))} +