From 1a8abe6e451946a01f2832a31f80828ced22a1c6 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 5 Sep 2026 06:27:10 -0700 Subject: [PATCH 01/13] feat(v2): rebuild workspace thread surface --- frontend/.eslintrc.js | 2 +- .../i18n/__tests__/translationKeys.test.ts | 4 +- frontend/src/i18n/locales/en.json | 9 +- frontend/src/i18n/locales/zh-CN.json | 8 +- ...tComposer.test.tsx => V2Composer.test.tsx} | 32 +- ...st.tsx => V2ComposerMentionState.test.tsx} | 6 +- .../v2/__tests__/V2ComposerSurface.test.tsx | 52 ++ .../src/v2/__tests__/V2DecisionCard.test.tsx | 54 ++ .../src/v2/__tests__/V2FirstRunHero.test.tsx | 2 +- .../src/v2/__tests__/V2InviteDmGate.test.tsx | 2 +- .../v2/__tests__/V2LayoutSelection.test.tsx | 2 +- ...eBubble.test.tsx => V2MessageRow.test.tsx} | 12 +- ...te.test.tsx => V2MessageRowQuote.test.tsx} | 16 +- .../__tests__/V2ThreadDecisionCard.test.tsx | 96 +++ ...test.tsx => V2ThreadDeliveryHint.test.tsx} | 6 +- ...rter.test.tsx => V2ThreadStarter.test.tsx} | 22 +- .../v2/__tests__/v2-layout-invariants.test.ts | 46 +- frontend/src/v2/components/V2Composer.tsx | 178 +++++ frontend/src/v2/components/V2DecisionCard.tsx | 125 +++ frontend/src/v2/components/V2GithubPrCard.tsx | 2 +- frontend/src/v2/components/V2InviteModal.tsx | 2 +- frontend/src/v2/components/V2Layout.tsx | 6 +- .../src/v2/components/V2MessageActions.tsx | 116 +++ .../{V2MessageBubble.tsx => V2MessageRow.tsx} | 131 +--- .../{V2PodChat.tsx => V2Thread.tsx} | 711 ++++-------------- .../src/v2/components/V2ThreadMessages.tsx | 172 +++++ .../src/v2/components/V2ThreadStarter.tsx | 90 +++ frontend/src/v2/utils/agentKey.ts | 2 +- frontend/src/v2/utils/threadView.ts | 2 +- frontend/src/v2/v2.css | 300 +++++++- 30 files changed, 1499 insertions(+), 709 deletions(-) rename frontend/src/v2/__tests__/{V2PodChatComposer.test.tsx => V2Composer.test.tsx} (93%) rename frontend/src/v2/__tests__/{V2PodChatMentionState.test.tsx => V2ComposerMentionState.test.tsx} (97%) create mode 100644 frontend/src/v2/__tests__/V2ComposerSurface.test.tsx create mode 100644 frontend/src/v2/__tests__/V2DecisionCard.test.tsx rename frontend/src/v2/__tests__/{V2MessageBubble.test.tsx => V2MessageRow.test.tsx} (94%) rename frontend/src/v2/__tests__/{V2MessageBubbleQuote.test.tsx => V2MessageRowQuote.test.tsx} (82%) create mode 100644 frontend/src/v2/__tests__/V2ThreadDecisionCard.test.tsx rename frontend/src/v2/__tests__/{V2PodChatDeliveryHint.test.tsx => V2ThreadDeliveryHint.test.tsx} (98%) rename frontend/src/v2/__tests__/{V2PodChatStarter.test.tsx => V2ThreadStarter.test.tsx} (95%) create mode 100644 frontend/src/v2/components/V2Composer.tsx create mode 100644 frontend/src/v2/components/V2DecisionCard.tsx create mode 100644 frontend/src/v2/components/V2MessageActions.tsx rename frontend/src/v2/components/{V2MessageBubble.tsx => V2MessageRow.tsx} (85%) rename frontend/src/v2/components/{V2PodChat.tsx => V2Thread.tsx} (65%) create mode 100644 frontend/src/v2/components/V2ThreadMessages.tsx create mode 100644 frontend/src/v2/components/V2ThreadStarter.tsx diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 6ba93a0bb4..97ef116926 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -53,7 +53,7 @@ module.exports = { 'src/v2/components/V2InviteRedeem.tsx', 'src/components/RegistrationInviteRequired.tsx', 'src/components/VerifyEmail.tsx', - 'src/v2/components/V2PodChat.tsx', + 'src/v2/components/V2Thread.tsx', 'src/v2/components/V2FirstRunHero.tsx', 'src/v2/components/V2InviteModal.tsx', // Phase 2 — the v2 shell (#719) diff --git a/frontend/src/i18n/__tests__/translationKeys.test.ts b/frontend/src/i18n/__tests__/translationKeys.test.ts index 9a376a2417..71f323d4aa 100644 --- a/frontend/src/i18n/__tests__/translationKeys.test.ts +++ b/frontend/src/i18n/__tests__/translationKeys.test.ts @@ -75,7 +75,9 @@ describe('i18n migration manifest', () => { expect(missing).toEqual([]); }); - it('keeps the deferred message bubble outside the migrated-file manifest', () => { + it('keeps the retired chat and bubble files out of the migrated-file manifest', () => { + expect(migratedFiles).not.toContain('src/v2/components/V2PodChat.tsx'); expect(migratedFiles).not.toContain('src/v2/components/V2MessageBubble.tsx'); + expect(migratedFiles).toContain('src/v2/components/V2Thread.tsx'); }); }); diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 4098934c75..256ac2d6d6 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3,6 +3,7 @@ "brandName": "Commonly", "brandWordmark": "commonly", "agent": "Agent", + "you": "you", "close": "Close", "copy": "Copy", "copied": "Copied!", @@ -439,6 +440,10 @@ "recent_other": "{{formattedCount}} recent heartbeats", "none": "No recent heartbeats" }, + "header": { + "agentsWorking_one": "{{count}} agent working", + "agentsWorking_other": "{{count}} agents working" + }, "team": { "view": "View pod team", "hide": "Hide pod team" @@ -467,7 +472,9 @@ "uploading": "Uploading…", "attachFile": "Attach file", "sending": "Sending…", - "send": "Send message", + "send": "Send", + "sendAria": "Send message", + "postsAs": "posts as {{name}}", "mentionAgent": "mention an agent", "toSend": "to send" }, diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 81d939d43c..2beb2f387c 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -3,6 +3,7 @@ "brandName": "Commonly", "brandWordmark": "commonly", "agent": "智能体", + "you": "你", "close": "关闭", "copy": "复制", "copied": "已复制", @@ -439,6 +440,9 @@ "recent_one": "最近收到 {{formattedCount}} 次心跳", "recent_other": "最近收到 {{formattedCount}} 次心跳" }, + "header": { + "agentsWorking_other": "{{count}} 个智能体正在工作" + }, "team": { "view": "查看 Pod 团队", "hide": "隐藏 Pod 团队" @@ -467,7 +471,9 @@ "uploading": "正在上传…", "attachFile": "添加文件", "sending": "正在发送…", - "send": "发送消息", + "send": "发送", + "sendAria": "发送消息", + "postsAs": "发布身份:{{name}}", "mentionAgent": "提及智能体", "toSend": "发送" }, diff --git a/frontend/src/v2/__tests__/V2PodChatComposer.test.tsx b/frontend/src/v2/__tests__/V2Composer.test.tsx similarity index 93% rename from frontend/src/v2/__tests__/V2PodChatComposer.test.tsx rename to frontend/src/v2/__tests__/V2Composer.test.tsx index c1f205f547..53a9fe601c 100644 --- a/frontend/src/v2/__tests__/V2PodChatComposer.test.tsx +++ b/frontend/src/v2/__tests__/V2Composer.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; -import V2PodChat from '../components/V2PodChat'; +import V2Thread from '../components/V2Thread'; import { AuthContext } from '../../context/AuthContext'; jest.mock('../../context/SocketContext', () => ({ @@ -15,7 +15,11 @@ jest.mock('../../context/SocketContext', () => ({ // Composer behavior does not depend on avatar rendering. Keep this test // isolated from the external DiceBear package graph used by V2Avatar. -jest.mock('../components/V2Avatar', () => () => ); +jest.mock('../components/V2Avatar', () => { + const MockAvatar = () => ; + MockAvatar.displayName = 'MockAvatar'; + return MockAvatar; +}); jest.mock('../utils/avatars', () => ({ initialsFor: (name: string) => name.slice(0, 2) })); // jsdom has no scrollIntoView; the component auto-scrolls on mount. @@ -69,32 +73,34 @@ const makeDetail = (overrides = {}) => ({ const renderChat = (detail) => render( - + , ); -describe('V2PodChat composer send button', () => { - test('only shows a no-heartbeat state when the pod has an installed agent', () => { +describe('V2Composer send button', () => { + test('uses the artboard’s compact working count in the thread header', () => { const view = renderChat(makeDetail({ pod: { _id: 'p1', name: 'My Workspace', type: 'chat', description: 'Team discussion' }, })); - // A human-only pod is not waiting on anyone to heartbeat. - expect(view.container.querySelector('.v2-chat__goal-meta')).toBeNull(); + expect(view.container.querySelector('.v2-thread__working')).toHaveTextContent('0 agents working'); view.rerender( - , ); - expect(view.container.querySelector('.v2-chat__goal-meta')).toHaveTextContent('No recent heartbeats'); + expect(view.container.querySelector('.v2-thread__working')).toHaveTextContent('1 agent working'); }); test('clicking the send button sends the drafted text', async () => { @@ -150,16 +156,16 @@ describe('V2PodChat composer send button', () => { rerender( - + , ); const sendError = screen.getByText('Replies are temporarily unavailable. Please try again shortly.'); - expect(sendError.closest('.v2-chat__composer')).not.toBeNull(); + expect(sendError.closest('.v2-composer')).not.toBeNull(); expect(sendError.closest('.v2-chat__messages')).toBeNull(); expect(screen.getByPlaceholderText(/message my workspace/i)).toHaveValue('I am checking it now.'); - expect(screen.getByRole('button', { name: /cancel reply/i }).closest('.v2-chat__reply-chip')).not.toBeNull(); + expect(screen.getByRole('button', { name: /cancel reply/i }).closest('.v2-composer__target')).not.toBeNull(); }); // W-T 4/4, constraint 4 (docs/design/threading-surface-ruling.md; ux-lead diff --git a/frontend/src/v2/__tests__/V2PodChatMentionState.test.tsx b/frontend/src/v2/__tests__/V2ComposerMentionState.test.tsx similarity index 97% rename from frontend/src/v2/__tests__/V2PodChatMentionState.test.tsx rename to frontend/src/v2/__tests__/V2ComposerMentionState.test.tsx index d6bf22e114..0267d50b04 100644 --- a/frontend/src/v2/__tests__/V2PodChatMentionState.test.tsx +++ b/frontend/src/v2/__tests__/V2ComposerMentionState.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; -import V2PodChat from '../components/V2PodChat'; +import V2Thread from '../components/V2Thread'; import { AuthContext } from '../../context/AuthContext'; jest.mock('../../context/SocketContext', () => ({ @@ -72,14 +72,14 @@ const mockStates = (agents) => { const renderChat = (detail) => render( - + , ); afterEach(() => jest.clearAllMocks()); -describe('V2PodChat mention-state honesty (#891 surface 1)', () => { +describe('V2Composer mention-state honesty (#891 surface 1)', () => { test('mentioning a never-connected agent warns the OWNER with the fix command', async () => { mockStates([{ agentName: 'sam-agent', instanceId: 'default', state: 'never-connected', isOwner: true, fixCommand: 'commonly agent run sam-agent', diff --git a/frontend/src/v2/__tests__/V2ComposerSurface.test.tsx b/frontend/src/v2/__tests__/V2ComposerSurface.test.tsx new file mode 100644 index 0000000000..0c7abf0c12 --- /dev/null +++ b/frontend/src/v2/__tests__/V2ComposerSurface.test.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import V2Composer from '../components/V2Composer'; + +const renderComposer = (overrides: Partial> = {}) => { + const props: React.ComponentProps = { + podName: 'Sharpen', + authorName: 'lily', + draft: '', + sending: false, + uploading: false, + sendError: null, + composerError: null, + replyTarget: null, + threadTarget: null, + mentionOpen: false, + mentionIndex: 0, + mentions: [], + warnings: [], + inputRef: { current: null }, + fileInputRef: { current: null }, + mentionDropdownRef: { current: null }, + onDraftChange: jest.fn(), + onDraftPointer: jest.fn(), + onKeyDown: jest.fn(), + onMentionSelect: jest.fn(), + onSend: jest.fn(), + onAttach: jest.fn(), + onCancelReply: jest.fn(), + onCancelThread: jest.fn(), + ...overrides, + }; + return { props, ...render() }; +}; + +describe('V2Composer', () => { + test('keeps the workspace’s single bordered input and posts-as line independent of the thread controller', () => { + const { props, container } = renderComposer({ draft: 'Ready to ship' }); + + expect(container.querySelector('.v2-composer')).toBeInTheDocument(); + expect(screen.getByText('posts as lily · ⌘↵')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Send message' })).toHaveTextContent('Send'); + + fireEvent.change(screen.getByPlaceholderText('Message Sharpen…'), { + target: { value: 'Next draft', selectionStart: 10 }, + }); + expect(props.onDraftChange).toHaveBeenCalledWith('Next draft', 10); + + fireEvent.click(screen.getByRole('button', { name: 'Send message' })); + expect(props.onSend).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/v2/__tests__/V2DecisionCard.test.tsx b/frontend/src/v2/__tests__/V2DecisionCard.test.tsx new file mode 100644 index 0000000000..a268194224 --- /dev/null +++ b/frontend/src/v2/__tests__/V2DecisionCard.test.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import V2DecisionCard from '../components/V2DecisionCard'; + +const mockPost = jest.fn(); + +jest.mock('../hooks/useV2Api', () => ({ + useV2Api: () => ({ post: mockPost }), +})); + +const decision = { + id: 'decision-1', + title: 'Choose the workspace cutover', + detail: 'Which implementation should ship?', + actorName: 'Sprint impl', + options: [ + { label: 'Ship the rebuilt workspace', recommended: true, description: 'The full artboard cutover.' }, + { label: 'Keep the legacy chat', description: 'Do not ship the new workspace.' }, + ], +}; + +describe('V2DecisionCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('renders a structured fork rather than its prose fallback and posts the chosen option', async () => { + mockPost.mockResolvedValue({ decision: { ruling: { value: 'Ship the rebuilt workspace', by: 'Sam' } } }); + render(); + + expect(screen.getByText('Choose the workspace cutover')).toBeInTheDocument(); + expect(screen.getByText('Which implementation should ship?')).toBeInTheDocument(); + expect(screen.getByText('Sprint impl')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Ship the rebuilt workspace' })).toHaveClass('v2-decision-card__choice--recommended'); + + fireEvent.click(screen.getByRole('button', { name: 'Ship the rebuilt workspace' })); + await waitFor(() => expect(mockPost).toHaveBeenCalledWith( + '/api/activity/decisions/decision-1/choose', + { value: 'Ship the rebuilt workspace' }, + )); + expect(await screen.findByRole('status')).toHaveTextContent('Sam ruled: Ship the rebuilt workspace'); + }); + + test('shows the standing ruling from a racing human instead of presenting a second choice', async () => { + mockPost.mockRejectedValue({ + response: { status: 409, data: { decision: { ruling: { value: 'Keep the legacy chat', by: 'Lily' } } } }, + }); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Keep the legacy chat' })); + expect(await screen.findByRole('status')).toHaveTextContent('Lily ruled: Keep the legacy chat'); + expect(screen.queryByRole('button', { name: 'Ship the rebuilt workspace' })).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/v2/__tests__/V2FirstRunHero.test.tsx b/frontend/src/v2/__tests__/V2FirstRunHero.test.tsx index 1b8e61011f..f4058c592f 100644 --- a/frontend/src/v2/__tests__/V2FirstRunHero.test.tsx +++ b/frontend/src/v2/__tests__/V2FirstRunHero.test.tsx @@ -73,7 +73,7 @@ jest.mock('../components/V2NavRail', () => () => ( jest.mock('../components/V2PodsSidebar', () => () => ); jest.mock('../components/V2Inspector', () => () => ); jest.mock('../components/V2InviteModal', () => () => null); -jest.mock('../components/V2PodChat', () => ({ firstRunVisible }: { firstRunVisible?: boolean }) => ( +jest.mock('../components/V2Thread', () => ({ firstRunVisible }: { firstRunVisible?: boolean }) => (
Normal pod view {!firstRunVisible && Quiet pod empty state} diff --git a/frontend/src/v2/__tests__/V2InviteDmGate.test.tsx b/frontend/src/v2/__tests__/V2InviteDmGate.test.tsx index 1de2c32201..ae1d53066e 100644 --- a/frontend/src/v2/__tests__/V2InviteDmGate.test.tsx +++ b/frontend/src/v2/__tests__/V2InviteDmGate.test.tsx @@ -42,7 +42,7 @@ jest.mock('../components/V2InviteModal', () => function MockV2InviteModal({ open return open ?
{initialTab}
: null; }); jest.mock('../components/V2FirstRunHero', () => () => null); -jest.mock('../components/V2PodChat', () => function MockV2PodChat({ onOpenInvite }) { +jest.mock('../components/V2Thread', () => function MockV2Thread({ onOpenInvite }) { return (
{onOpenInvite && ( diff --git a/frontend/src/v2/__tests__/V2LayoutSelection.test.tsx b/frontend/src/v2/__tests__/V2LayoutSelection.test.tsx index dea9bf4869..6668d50b48 100644 --- a/frontend/src/v2/__tests__/V2LayoutSelection.test.tsx +++ b/frontend/src/v2/__tests__/V2LayoutSelection.test.tsx @@ -38,7 +38,7 @@ jest.mock('../../context/AuthContext', () => ({ jest.mock('../components/V2NavRail', () => () => null); jest.mock('../components/V2PodsSidebar', () => () => null); -jest.mock('../components/V2PodChat', () => () => null); +jest.mock('../components/V2Thread', () => () => null); jest.mock('../components/V2Inspector', () => () => null); jest.mock('../components/V2InviteModal', () => () => null); jest.mock('../components/V2FirstRunHero', () => () => null); diff --git a/frontend/src/v2/__tests__/V2MessageBubble.test.tsx b/frontend/src/v2/__tests__/V2MessageRow.test.tsx similarity index 94% rename from frontend/src/v2/__tests__/V2MessageBubble.test.tsx rename to frontend/src/v2/__tests__/V2MessageRow.test.tsx index 0c2040a660..4135256fed 100644 --- a/frontend/src/v2/__tests__/V2MessageBubble.test.tsx +++ b/frontend/src/v2/__tests__/V2MessageRow.test.tsx @@ -1,13 +1,13 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; -import V2MessageBubble from '../components/V2MessageBubble'; +import V2MessageRow from '../components/V2MessageRow'; jest.mock('../../context/AuthContext', () => ({ useAuth: () => ({ currentUser: { username: 'viewer' } }), })); -describe('V2MessageBubble', () => { +describe('V2MessageRow', () => { const previousApiUrl = process.env.REACT_APP_API_URL; afterEach(() => { @@ -19,7 +19,7 @@ describe('V2MessageBubble', () => { render( - { it('renders no reactions row when a message has no reactions — the trigger lives in the cluster', () => { render( - { it('keeps the reactions row in flow when chips exist', () => { render( - { it('marks a system card with its action state', () => { render( - () => ); +jest.mock('../components/V2Avatar', () => { + const MockAvatar = () => ; + MockAvatar.displayName = 'MockAvatar'; + return MockAvatar; +}); jest.mock('../../context/AuthContext', () => ({ useAuth: () => ({ currentUser: { _id: 'u1', username: 'me' }, token: 't' }), })); @@ -33,7 +37,7 @@ const msg = (replyToId, replyAuthor) => ({ }); const show = (props) => render( - , + , ); describe('the quote is hidden only when the rail already supplies its context', () => { @@ -49,7 +53,7 @@ describe('the quote is hidden only when the rail already supplies its context', // who is being answered, so suppressing it would lose the addressee. render( - + , ); expect(screen.getByText(/the quoted text/)).toBeInTheDocument(); @@ -64,7 +68,7 @@ describe('the quote is hidden only when the rail already supplies its context', test('no quote at all is still no quote', () => { render( - + , ); expect(screen.queryByText(/the quoted text/)).not.toBeInTheDocument(); @@ -75,7 +79,7 @@ describe('the quote is hidden only when the rail already supplies its context', // the view. An === on mixed types would silently never suppress. render( - + , ); expect(screen.queryByText(/the quoted text/)).not.toBeInTheDocument(); diff --git a/frontend/src/v2/__tests__/V2ThreadDecisionCard.test.tsx b/frontend/src/v2/__tests__/V2ThreadDecisionCard.test.tsx new file mode 100644 index 0000000000..1d8894d25b --- /dev/null +++ b/frontend/src/v2/__tests__/V2ThreadDecisionCard.test.tsx @@ -0,0 +1,96 @@ +// @ts-nocheck +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import V2Thread from '../components/V2Thread'; +import { AuthContext } from '../../context/AuthContext'; + +const mockGet = jest.fn(); +const mockPost = jest.fn(); + +jest.mock('axios', () => ({ + __esModule: true, + default: { + get: (...args) => mockGet(...args), + post: (...args) => mockPost(...args), + patch: jest.fn(), + put: jest.fn(), + delete: jest.fn(), + defaults: { baseURL: '', headers: { common: {} } }, + interceptors: { + request: { use: jest.fn(), eject: jest.fn() }, + response: { use: jest.fn(), eject: jest.fn() }, + }, + }, +})); + +jest.mock('../../context/SocketContext', () => ({ + useSocket: () => ({ socket: null, connected: false }), +})); + +jest.mock('../components/V2Avatar', () => { + const MockAvatar = () => ; + MockAvatar.displayName = 'MockAvatar'; + return MockAvatar; +}); + +beforeAll(() => { Element.prototype.scrollIntoView = jest.fn(); }); + +const auth = { + currentUser: { _id: 'human-1', username: 'lily' }, + user: { _id: 'human-1', username: 'lily' }, + token: 'token', loading: false, error: null, isAuthenticated: true, + register: jest.fn(), login: jest.fn(), logout: jest.fn(), updateProfile: jest.fn(), +}; + +const detail = { + pod: { _id: 'pod-1', name: 'Sharpen', description: 'Make the next cut', type: 'chat' }, + members: [{ _id: 'human-1', username: 'lily', isBot: false }], + agents: [], + messages: [{ + id: '42', pod_id: 'pod-1', user_id: 'agent-1', + user: { username: 'sprint-impl', isBot: true }, + content: 'Choose one of the following approaches in prose.', + message_type: 'text', created_at: '2026-09-05T12:00:00.000Z', + }], + sendMessage: jest.fn(), loading: false, error: null, sendError: null, + hasMore: false, loadingOlder: false, loadOlder: jest.fn(), refresh: jest.fn(), +}; + +describe('V2Thread decision cards', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGet.mockImplementation((url) => { + if (url === '/api/activity/decision-queue') { + return Promise.resolve({ data: { items: [{ + id: 'decision-42', kind: 'decision', podId: 'pod-1', messageId: '42', + actorName: 'Sprint impl', title: 'Choose the workspace cutover', + detail: 'Which implementation should ship?', + options: [{ label: 'Ship the rebuilt workspace', recommended: true }], + }] } }); + } + return Promise.resolve({ data: {} }); + }); + mockPost.mockResolvedValue({ data: { decision: { ruling: { value: 'Ship the rebuilt workspace', by: 'Lily' } } } }); + }); + + test('replaces the decision request prose at its durable message position with a live card', async () => { + render( + + + , + ); + + expect(await screen.findByTestId('decision-card')).toBeInTheDocument(); + expect(screen.getByText('Choose the workspace cutover')).toBeInTheDocument(); + expect(screen.queryByText('Choose one of the following approaches in prose.')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Ship the rebuilt workspace' })); + await waitFor(() => expect(mockPost).toHaveBeenCalledWith( + '/api/activity/decisions/decision-42/choose', + { value: 'Ship the rebuilt workspace' }, + expect.anything(), + )); + expect(await screen.findByRole('status')).toHaveTextContent('Lily ruled: Ship the rebuilt workspace'); + }); +}); diff --git a/frontend/src/v2/__tests__/V2PodChatDeliveryHint.test.tsx b/frontend/src/v2/__tests__/V2ThreadDeliveryHint.test.tsx similarity index 98% rename from frontend/src/v2/__tests__/V2PodChatDeliveryHint.test.tsx rename to frontend/src/v2/__tests__/V2ThreadDeliveryHint.test.tsx index a48e1b4a65..1965de3399 100644 --- a/frontend/src/v2/__tests__/V2PodChatDeliveryHint.test.tsx +++ b/frontend/src/v2/__tests__/V2ThreadDeliveryHint.test.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; -import V2PodChat from '../components/V2PodChat'; +import V2Thread from '../components/V2Thread'; import { AuthContext } from '../../context/AuthContext'; let mockSocketValue = { socket: null, connected: false }; @@ -79,7 +79,7 @@ const DeliveryHarness = ({ response, sendSpy, agents = DEFAULT_AGENTS }) => { error: null, refresh: jest.fn(), }; - return ; + return ; }; const renderHarness = (response, sendSpy = jest.fn()) => render( @@ -97,7 +97,7 @@ const sendDraft = (content) => { fireEvent.click(screen.getByRole('button', { name: /send message/i })); }; -describe('V2PodChat agent delivery hint', () => { +describe('V2Thread agent delivery hint', () => { test('shows a real-agent example after a zero-enqueued send', async () => { const response = makeMessage('hello room', { enqueued: 0, implicit: [], agentsInPod: 1, diff --git a/frontend/src/v2/__tests__/V2PodChatStarter.test.tsx b/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx similarity index 95% rename from frontend/src/v2/__tests__/V2PodChatStarter.test.tsx rename to frontend/src/v2/__tests__/V2ThreadStarter.test.tsx index 27b7a9831d..a98f7d3601 100644 --- a/frontend/src/v2/__tests__/V2PodChatStarter.test.tsx +++ b/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx @@ -5,7 +5,7 @@ import { } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import axios from 'axios'; -import V2PodChat from '../components/V2PodChat'; +import V2Thread from '../components/V2Thread'; import { AuthContext } from '../../context/AuthContext'; // SocketContext exports only the provider + hook (no raw context object), @@ -15,10 +15,10 @@ jest.mock('../../context/SocketContext', () => ({ useSocket: () => ({ socket: null, connected: false }), })); -jest.mock('../components/V2MessageBubble', () => { - const MockV2MessageBubble = () =>
Rendered message
; - MockV2MessageBubble.displayName = 'MockV2MessageBubble'; - return MockV2MessageBubble; +jest.mock('../components/V2MessageRow', () => { + const MockV2MessageRow = () =>
Rendered message
; + MockV2MessageRow.displayName = 'MockV2MessageRow'; + return MockV2MessageRow; }); // jsdom has no scrollIntoView; the component auto-scrolls on mount. @@ -75,7 +75,7 @@ const makeDetail = (overrides = {}) => ({ const renderChat = (detail, props = {}) => render( - + , ); @@ -90,7 +90,7 @@ const makeAgentRoom = (overrides = {}) => makeDetail({ ...overrides, }); -describe('V2PodChat teaching empty states', () => { +describe('V2Thread teaching empty states', () => { beforeEach(() => { jest.clearAllMocks(); sessionStorage.clear(); @@ -130,7 +130,7 @@ describe('V2PodChat teaching empty states', () => { }); }); -describe('V2PodChat starter prompts', () => { +describe('V2Thread starter prompts', () => { beforeEach(() => { jest.clearAllMocks(); sessionStorage.clear(); @@ -179,7 +179,7 @@ describe('V2PodChat starter prompts', () => { }); }); -describe('V2PodChat agent-room liveness', () => { +describe('V2Thread agent-room liveness', () => { beforeEach(() => { jest.clearAllMocks(); axios.get.mockResolvedValue({ data: { agents: [] } }); @@ -239,7 +239,7 @@ describe('V2PodChat agent-room liveness', () => { view.rerender( - + , ); @@ -280,7 +280,7 @@ describe('V2PodChat agent-room liveness', () => { }); }); -describe('V2PodChat just-created Pod starter panel', () => { +describe('V2Thread just-created Pod starter panel', () => { beforeEach(() => { jest.clearAllMocks(); sessionStorage.clear(); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 5255502f78..73f7e090cd 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -83,9 +83,14 @@ describe('v2 layout invariants (CSS rule presence)', () => { const tokens = read('../../../design-system/tokens.css'); const aprofile = read('../agents/v2-agent-profile.css'); const landing = read('../landing/v2-landing.css'); - const podChat = read('../components/V2PodChat.tsx'); + const podChat = read('../components/V2Thread.tsx'); const podsSidebar = read('../components/V2PodsSidebar.tsx'); const workspaceInspector = read('../components/V2Inspector.tsx'); + const thread = read('../components/V2Thread.tsx'); + const threadMessages = read('../components/V2ThreadMessages.tsx'); + const messageRow = read('../components/V2MessageRow.tsx'); + const composer = read('../components/V2Composer.tsx'); + const decisionCard = read('../components/V2DecisionCard.tsx'); const v2Layout = read('../components/V2Layout.tsx'); const podBoard = read('../components/V2PodBoard.tsx'); const activityPage = read('../components/V2ActivityPage.tsx'); @@ -231,14 +236,25 @@ describe('v2 layout invariants (CSS rule presence)', () => { test('grouped messages keep the avatar column so text never shifts (craft audit rule 3)', () => { // Consecutive same-author messages within the grouping window drop the - // header row; the ghost cell must match the .v2-msg grid's 38px avatar + // header row; the ghost cell must match the artboard's 28px avatar // column or grouped text mis-aligns with headed text by exactly the // avatar width — a defect jsdom cannot see. - expect(ruleBody(v2, '.v2-msg')).toContain('grid-template-columns: 38px'); - expect(ruleBody(v2, '.v2-msg__avatar-ghost')).toContain('width: 38px'); + expect(ruleBody(v2, '.v2-thread .v2-msg')).toContain('grid-template-columns: 28px'); + expect(ruleBody(v2, '.v2-thread .v2-msg__avatar-ghost')).toContain('width: 28px'); expect(v2).toContain('.v2-msg--grouped'); }); + test('the workspace route uses the small replacement components, never the retired chat or bubble files', () => { + expect(fs.existsSync(path.join(__dirname, '../components/V2PodChat.tsx'))).toBe(false); + expect(fs.existsSync(path.join(__dirname, '../components/V2MessageBubble.tsx'))).toBe(false); + expect(thread).toContain("import V2ThreadMessages from './V2ThreadMessages'"); + expect(threadMessages).toContain("import V2MessageRow from './V2MessageRow'"); + expect(thread).toContain("import V2Composer"); + expect(messageRow).toContain("import V2DecisionCard"); + expect(composer).toContain('const V2Composer'); + expect(decisionCard).toContain('const V2DecisionCard'); + }); + test('direct-agent liveness stays above the composer as a stable, readable row', () => { // A successful DM POST is not proof of an active seat. This row keeps // liveness and the bounded reply wait outside the scrollable transcript @@ -448,7 +464,8 @@ describe('v2 layout invariants (CSS rule presence)', () => { // of rows and recreates the v1 island. expect(ruleBody(v2, '.v2-chat__messages > *')).toContain('width: 100%'); expect(ruleBody(v2, '.v2-chat__messages > *')).toContain('margin-inline: 0'); - expect(ruleBody(v2, '.v2-chat__composer > *')).toContain('margin-inline: 0'); + expect(ruleBody(v2, '.v2-composer')).toContain('border: 1px solid var(--v2-border)'); + expect(ruleBody(v2, '.v2-composer__posts-as')).toContain('font: 500 11px/16px var(--v2-font-mono)'); // Nothing in the column may escape the shared edge (Sam, 2026-08-23: // mentions and threads sat off-grid while messages aligned). // Mentions: the wash bleed must equal the padding (the old -12px against @@ -735,12 +752,11 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(podBoard).not.toContain('+ {t(\'board.newTask\')}'); }); - test('the chat heartbeat subtitle is guarded by an installed agent', () => { - // A zero-agent pod has nobody expected to heartbeat; showing "No recent" - // there was a false failure state. Pin both the predicate and its render - // guard so a later copy-only edit cannot restore the dangling separator. - expect(podChat).toContain('const liveState = agents.length > 0'); - expect(podChat).toContain('{liveState && · {liveState}}'); + 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'); + expect(ruleBody(v2, '.v2-thread__header')).toContain('border-bottom: 1px solid var(--v2-border-soft)'); + expect(ruleBody(v2, '.v2-thread__working')).toContain('font: 500 11px/16px var(--v2-font-mono)'); }); test('Activity cards have shrinkable desktop and mobile layout guards', () => { @@ -949,6 +965,14 @@ describe('v2 layout invariants (CSS rule presence)', () => { } }); + test('the pending human decision is the one cobalt action surface', () => { + const card = ruleBody(v2, '.v2-decision-card'); + const recommended = selectorRuleBody(v2, '.v2-root button.v2-decision-card__choice--recommended'); + expect(card).toContain('box-shadow: 0 0 0 2px var(--v2-accent)'); + expect(recommended).toContain('background: var(--v2-accent)'); + expect(recommended).not.toContain('var(--v2-ink)'); + }); + test('chat and workspace-inspector surfaces do not paint with accent-soft', () => { const prefixes = [ '.v2-chat', diff --git a/frontend/src/v2/components/V2Composer.tsx b/frontend/src/v2/components/V2Composer.tsx new file mode 100644 index 0000000000..6995a4f5c9 --- /dev/null +++ b/frontend/src/v2/components/V2Composer.tsx @@ -0,0 +1,178 @@ +import React from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import V2Avatar from './V2Avatar'; +import type { V2Message } from '../hooks/useV2PodDetail'; + +export interface V2ComposerMention { + id: string; + label: string; + subtitle: string; + avatar?: string | null; + value: string; +} + +export interface V2ComposerWarning { + agentName: string; + state: 'gone-dark' | 'never-connected'; + fixCommand?: string; +} + +interface V2ComposerProps { + podName: string; + authorName: string; + draft: string; + sending: boolean; + uploading: boolean; + sendError?: string | null; + composerError?: string | null; + replyTarget: V2Message | null; + threadTarget: { id: string; preview: string } | null; + mentionOpen: boolean; + mentionIndex: number; + mentions: V2ComposerMention[]; + warnings: V2ComposerWarning[]; + inputRef: React.RefObject; + fileInputRef: React.RefObject; + mentionDropdownRef: React.RefObject; + onDraftChange: (value: string, cursor: number | null) => void; + onDraftPointer: (value: string, cursor: number | null) => void; + onKeyDown: React.KeyboardEventHandler; + onMentionSelect: (item: V2ComposerMention) => void; + onSend: () => void; + onAttach: (file: File | null) => void; + onCancelReply: () => void; + onCancelThread: () => void; +} + +/** The one bounded input surface for a workspace thread. */ +const V2Composer: React.FC = ({ + podName, + authorName, + draft, + sending, + uploading, + sendError, + composerError, + replyTarget, + threadTarget, + mentionOpen, + mentionIndex, + mentions, + warnings, + inputRef, + fileInputRef, + mentionDropdownRef, + onDraftChange, + onDraftPointer, + onKeyDown, + onMentionSelect, + onSend, + onAttach, + onCancelReply, + onCancelThread, +}) => { + const { t } = useTranslation(); + return ( +
+ {threadTarget && ( +
+ {t('podChat.thread.replyingInThread')} {threadTarget.preview.replace(/\[\[upload:[^\]]*\]\]/g, '📎').slice(0, 40)} + +
+ )} + {replyTarget && ( +
+ + }} + />{' '} + {String(replyTarget.content || '').replace(/\[\[upload:[^\]]*\]\]/g, '📎').slice(0, 80)} + + +
+ )} +
+