diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 6ba93a0bb..97ef11692 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 9a376a241..71f323d4a 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 4098934c7..1ad3589ca 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!", @@ -109,6 +110,7 @@ "otherPlaceholder": "Write your ruling…", "sendOther": "Send ruling", "ruled": "✓ {{by}} ruled: {{value}}", + "ruledShort": "ruled", "actionFailed": "That ruling could not be saved. Try again." }, "mention": { @@ -439,6 +441,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 +473,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 81d939d43..03d5c2a24 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": "已复制", @@ -109,6 +110,7 @@ "otherPlaceholder": "写下你的裁定…", "sendOther": "发送裁定", "ruled": "✓ {{by}} 已裁定:{{value}}", + "ruledShort": "已裁定", "actionFailed": "无法保存该裁定,请重试。" }, "mention": { @@ -439,6 +441,9 @@ "recent_one": "最近收到 {{formattedCount}} 次心跳", "recent_other": "最近收到 {{formattedCount}} 次心跳" }, + "header": { + "agentsWorking_other": "{{count}} 个智能体正在工作" + }, "team": { "view": "查看 Pod 团队", "hide": "隐藏 Pod 团队" @@ -467,7 +472,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 92% rename from frontend/src/v2/__tests__/V2PodChatComposer.test.tsx rename to frontend/src/v2/__tests__/V2Composer.test.tsx index c1f205f54..c1a537cf9 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,36 @@ 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'); + expect(view.container.querySelector('.v2-thread__working')).not.toHaveClass('v2-thread__working--active'); 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'); + expect(view.container.querySelector('.v2-thread__working')).toHaveClass('v2-thread__working--active'); }); test('clicking the send button sends the drafted text', async () => { @@ -150,16 +158,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 d6bf22e11..0267d50b0 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 000000000..0c7abf0c1 --- /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 000000000..1d9cfd949 --- /dev/null +++ b/frontend/src/v2/__tests__/V2DecisionCard.test.tsx @@ -0,0 +1,55 @@ +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', description: 'The full artboard cutover.' }, + { label: 'Keep the legacy chat', recommended: true, 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--primary'); + expect(screen.getByRole('button', { name: 'Keep the legacy chat' })).not.toHaveClass('v2-decision-card__choice--primary'); + + 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 1b8e61011..3e9faff96 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} @@ -212,7 +212,7 @@ describe('V2FirstRunHero', () => { await flush(); expect(screen.queryByRole('heading', { name: 'Bring your agent into the room' })).not.toBeInTheDocument(); - expect(mockGet).not.toHaveBeenCalled(); + expect(mockGet).not.toHaveBeenCalledWith('/api/users/me/agent-connection'); }); /* @@ -308,7 +308,7 @@ describe('V2FirstRunHero', () => { await flush(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(mockGet).not.toHaveBeenCalled(); + expect(mockGet).not.toHaveBeenCalledWith('/api/users/me/agent-connection'); act(() => { window.dispatchEvent(new Event('commonly:reopen-first-run')); @@ -398,7 +398,7 @@ describe('V2Layout first-run placement', () => { expect(screen.getByText('Quiet pod empty state')).toBeInTheDocument(); }); expect(screen.queryByRole('dialog', { name: 'Bring your agent into the room' })).not.toBeInTheDocument(); - expect(mockGet).not.toHaveBeenCalled(); + expect(mockGet).not.toHaveBeenCalledWith('/api/users/me/agent-connection'); }); test('reopens the dismissed guide from the rail and restores onboarding suppression', async () => { @@ -414,7 +414,7 @@ describe('V2Layout first-run placement', () => { await waitFor(() => { expect(screen.getByText('Quiet pod empty state')).toBeInTheDocument(); }); - expect(mockGet).not.toHaveBeenCalled(); + expect(mockGet).not.toHaveBeenCalledWith('/api/users/me/agent-connection'); fireEvent.click(screen.getByRole('button', { name: 'Guide' })); diff --git a/frontend/src/v2/__tests__/V2Inspector.test.tsx b/frontend/src/v2/__tests__/V2Inspector.test.tsx index bd564a519..12539080b 100644 --- a/frontend/src/v2/__tests__/V2Inspector.test.tsx +++ b/frontend/src/v2/__tests__/V2Inspector.test.tsx @@ -33,7 +33,14 @@ const detail = { const renderInspector = (props: Partial> = {}) => render( - + , ); @@ -41,11 +48,6 @@ describe('V2Inspector', () => { beforeEach(() => { jest.clearAllMocks(); mockGet.mockImplementation((url: string) => { - if (url === '/api/activity/decision-queue') { - return Promise.resolve({ items: [{ - id: 'decision-1', kind: 'decision', title: 'Slack default mode', actorName: 'Wren', podId: 'pod-1', messageId: 'message-7', - }] }); - } if (url === '/api/v1/tasks/pod-1') { return Promise.resolve({ tasks: [ { taskId: 'TASK-131', title: 'Build the card', status: 'in_progress', assignee: 'kai' }, @@ -70,6 +72,13 @@ describe('V2Inspector', () => { expect(screen.queryByRole('tab')).not.toBeInTheDocument(); }); + test('uses the short lowercase room name in the single-line agents label', () => { + renderInspector({ detail: { ...detail, pod: { ...detail.pod, name: 'Sharpen · decision loop' } } as any }); + + expect(screen.getByRole('heading', { name: 'agents in sharpen' })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: /decision loop/i })).not.toBeInTheDocument(); + }); + test('links attention, board, profile, invite, and manage exits without legacy tabs', async () => { const onOpenInvite = jest.fn(); renderInspector({ onOpenInvite }); @@ -90,18 +99,15 @@ describe('V2Inspector', () => { }); test('does not render a stale attention item from another pod', async () => { - mockGet.mockImplementation((url: string) => { - if (url === '/api/activity/decision-queue') return Promise.resolve({ items: [{ id: 'other', kind: 'decision', title: 'Other pod', podId: 'pod-2' }] }); - return Promise.resolve({ tasks: [] }); - }); - renderInspector(); + mockGet.mockResolvedValue({ tasks: [] }); + renderInspector({ attentionItems: [{ id: 'other', kind: 'decision', title: 'Other pod', podId: 'pod-2' }] }); await waitFor(() => expect(screen.getByText('Nothing. Wren is working.')).toBeInTheDocument()); expect(screen.queryByText('Other pod')).not.toBeInTheDocument(); }); test('uses the settled-workspace empty copy when no agent is working', async () => { mockGet.mockImplementation(() => Promise.resolve({ items: [], tasks: [] })); - renderInspector({ detail: { ...detail, agents: [] } as any }); + renderInspector({ detail: { ...detail, agents: [] } as any, attentionItems: [] }); expect(await screen.findByText('Nothing open.')).toBeInTheDocument(); }); diff --git a/frontend/src/v2/__tests__/V2InviteDmGate.test.tsx b/frontend/src/v2/__tests__/V2InviteDmGate.test.tsx index 1de2c3220..ae1d53066 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 dea9bf486..52ca5fb54 100644 --- a/frontend/src/v2/__tests__/V2LayoutSelection.test.tsx +++ b/frontend/src/v2/__tests__/V2LayoutSelection.test.tsx @@ -1,6 +1,6 @@ /* eslint-disable react/display-name */ import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter, Route, Routes, useLocation, } from 'react-router-dom'; @@ -38,8 +38,10 @@ jest.mock('../../context/AuthContext', () => ({ jest.mock('../components/V2NavRail', () => () => null); jest.mock('../components/V2PodsSidebar', () => () => null); -jest.mock('../components/V2PodChat', () => () => null); -jest.mock('../components/V2Inspector', () => () => null); +jest.mock('../components/V2Thread', () => ({ onToggleInspector }: { onToggleInspector?: () => void }) => ( + +)); +jest.mock('../components/V2Inspector', () => () =>