Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/i18n/__tests__/translationKeys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
10 changes: 9 additions & 1 deletion frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"brandName": "Commonly",
"brandWordmark": "commonly",
"agent": "Agent",
"you": "you",
"close": "Close",
"copy": "Copy",
"copied": "Copied!",
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
},
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"brandName": "Commonly",
"brandWordmark": "commonly",
"agent": "智能体",
"you": "你",
"close": "关闭",
"copy": "复制",
"copied": "已复制",
Expand Down Expand Up @@ -109,6 +110,7 @@
"otherPlaceholder": "写下你的裁定…",
"sendOther": "发送裁定",
"ruled": "✓ {{by}} 已裁定:{{value}}",
"ruledShort": "已裁定",
"actionFailed": "无法保存该裁定,请重试。"
},
"mention": {
Expand Down Expand Up @@ -439,6 +441,9 @@
"recent_one": "最近收到 {{formattedCount}} 次心跳",
"recent_other": "最近收到 {{formattedCount}} 次心跳"
},
"header": {
"agentsWorking_other": "{{count}} 个智能体正在工作"
},
"team": {
"view": "查看 Pod 团队",
"hide": "隐藏 Pod 团队"
Expand Down Expand Up @@ -467,7 +472,9 @@
"uploading": "正在上传…",
"attachFile": "添加文件",
"sending": "正在发送…",
"send": "发送消息",
"send": "发送",
"sendAria": "发送消息",
"postsAs": "发布身份:{{name}}",
"mentionAgent": "提及智能体",
"toSend": "发送"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -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', () => () => <span data-testid="avatar" />);
jest.mock('../components/V2Avatar', () => {
const MockAvatar = () => <span data-testid="avatar" />;
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.
Expand Down Expand Up @@ -69,32 +73,36 @@ const makeDetail = (overrides = {}) => ({
const renderChat = (detail) => render(
<AuthContext.Provider value={authValue}>
<MemoryRouter>
<V2PodChat detail={detail} />
<V2Thread detail={detail} />
</MemoryRouter>
</AuthContext.Provider>,
);

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(
<AuthContext.Provider value={authValue}>
<MemoryRouter>
<V2PodChat detail={makeDetail({
<V2Thread detail={makeDetail({
pod: { _id: 'p1', name: 'My Workspace', type: 'chat', description: 'Team discussion' },
agents: [{ agentName: 'scout', instanceId: 'default', displayName: 'Scout' }],
agents: [{
agentName: 'scout', instanceId: 'default', displayName: 'Scout',
lastHeartbeatAt: new Date().toISOString(),
}],
})} />
</MemoryRouter>
</AuthContext.Provider>,
);

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 () => {
Expand Down Expand Up @@ -150,16 +158,16 @@ describe('V2PodChat composer send button', () => {
rerender(
<AuthContext.Provider value={authValue}>
<MemoryRouter>
<V2PodChat detail={{ ...detail, sendError: 'Replies are temporarily unavailable. Please try again shortly.' }} />
<V2Thread detail={{ ...detail, sendError: 'Replies are temporarily unavailable. Please try again shortly.' }} />
</MemoryRouter>
</AuthContext.Provider>,
);

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -72,14 +72,14 @@ const mockStates = (agents) => {
const renderChat = (detail) => render(
<AuthContext.Provider value={authValue}>
<MemoryRouter>
<V2PodChat detail={detail} />
<V2Thread detail={detail} />
</MemoryRouter>
</AuthContext.Provider>,
);

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',
Expand Down
52 changes: 52 additions & 0 deletions frontend/src/v2/__tests__/V2ComposerSurface.test.tsx
Original file line number Diff line number Diff line change
@@ -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<React.ComponentProps<typeof V2Composer>> = {}) => {
const props: React.ComponentProps<typeof V2Composer> = {
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(<V2Composer {...props} />) };
};

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);
});
});
55 changes: 55 additions & 0 deletions frontend/src/v2/__tests__/V2DecisionCard.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<V2DecisionCard decision={decision} />);

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(<V2DecisionCard decision={decision} />);

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();
});
});
10 changes: 5 additions & 5 deletions frontend/src/v2/__tests__/V2FirstRunHero.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ jest.mock('../components/V2NavRail', () => () => (
jest.mock('../components/V2PodsSidebar', () => () => <aside>Pods</aside>);
jest.mock('../components/V2Inspector', () => () => <aside>Inspector</aside>);
jest.mock('../components/V2InviteModal', () => () => null);
jest.mock('../components/V2PodChat', () => ({ firstRunVisible }: { firstRunVisible?: boolean }) => (
jest.mock('../components/V2Thread', () => ({ firstRunVisible }: { firstRunVisible?: boolean }) => (
<main data-testid="pod-chat">
<span>Normal pod view</span>
{!firstRunVisible && <span>Quiet pod empty state</span>}
Expand Down Expand Up @@ -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');
});

/*
Expand Down Expand Up @@ -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'));
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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' }));

Expand Down
Loading
Loading