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
7 changes: 6 additions & 1 deletion frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1367,7 +1367,12 @@
"allFiles": "All {{count}} files",
"agents": "Agents",
"files": "Files",
"joinOpen": "Open"
"joinOpen": "Open",
"viewLabel": "Panel view",
"viewPod": "Pod",
"viewThreads": "Threads",
"hidePanel": "Hide panel",
"noThreads": "No threads in the loaded messages."
},
"goal": {
"title": "Goal",
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1362,7 +1362,12 @@
"allFiles": "全部{{count}}个文件",
"agents": "智能体",
"files": "文件",
"joinOpen": "开放"
"joinOpen": "开放",
"viewLabel": "面板视图",
"viewPod": "Pod",
"viewThreads": "话题",
"hidePanel": "收起面板",
"noThreads": "已加载的消息里没有话题。"
},
"goal": {
"title": "目标",
Expand Down
34 changes: 29 additions & 5 deletions frontend/src/v2/__tests__/V2Composer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ 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', () => {
const MockAvatar = () => <span data-testid="avatar" />;
const MockAvatar = ({ name }: { name?: string }) => <span data-testid="avatar" data-name={name} />;
MockAvatar.displayName = 'MockAvatar';
return MockAvatar;
});
Expand Down Expand Up @@ -85,7 +85,7 @@ const renderChat = (detail) => render(
);

describe('V2Composer send button', () => {
test('the pod header carries the direction-C meta (members · agents), not a working count', () => {
test('the pod header carries member faces labelled with the counts (D215), not a working count', () => {
const view = renderChat(makeDetail({
pod: { _id: 'p1', name: 'My Workspace', type: 'chat', description: 'Team discussion' },
members: [{ _id: 'u1', username: 'alice', isBot: false }, { _id: 'u2', username: 'bob', isBot: false }],
Expand All @@ -97,12 +97,36 @@ describe('V2Composer send button', () => {
expect(header.querySelector('h1')).toHaveTextContent('My Workspace');
expect(header.querySelector('.v2-hint__content')).toHaveTextContent('Team discussion');
const meta = header.querySelector('.v2-pod-header__meta:not(.v2-pod-header__meta--compact)');
expect(meta).toHaveTextContent('2 members');
expect(meta).toHaveTextContent('1 agent');
expect(header.querySelector('.v2-pod-header__meta--compact')).toHaveTextContent('2 members');
const faces = meta.querySelector('.v2-pod-header__faces');
expect(faces).toHaveAttribute('aria-label', '2 members, 1 agent');
expect(faces.querySelector('.v2-pod-header__stack').children).toHaveLength(2);
expect(meta.querySelector('.v2-pod-header__more')).toBeNull();
// With no board count there is nothing for the phone head to carry.
expect(header.querySelector('.v2-pod-header__meta--compact')).toBeNull();
expect(view.container.querySelector('.v2-thread__working')).toBeNull();
});

test('the header shows three faces, people before agents, and counts the rest as +N', () => {
const view = renderChat(makeDetail({
members: [
{ _id: 'b1', username: 'bot-one', isBot: true },
{ _id: 'b2', username: 'bot-two', isBot: true },
{ _id: 'h1', username: 'ana', isBot: false },
{ _id: 'h2', username: 'ben', isBot: false },
{ _id: 'h3', username: 'cai', isBot: false },
{ _id: 'h4', username: 'dee', isBot: false },
],
agents: [{ agentName: 'scout', instanceId: 'default', displayName: 'Scout' }],
}));

const meta = view.container.querySelector('.v2-pod-header__meta:not(.v2-pod-header__meta--compact)');
const faces = meta.querySelector('.v2-pod-header__faces');
expect(faces).toHaveAttribute('aria-label', '4 members, 1 agent');
const titles = Array.from(faces.querySelector('.v2-pod-header__stack').children).map((face: Element) => face.getAttribute('data-name'));
expect(titles).toEqual(['ana', 'ben', 'cai']);
expect(meta.querySelector('.v2-pod-header__more')).toHaveTextContent('+3');
});

test('clicking the send button sends the drafted text', async () => {
const detail = makeDetail();
renderChat(detail);
Expand Down
69 changes: 66 additions & 3 deletions frontend/src/v2/__tests__/V2Inspector.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/* eslint-disable react/display-name */
import React from 'react';
import {
fireEvent, render, screen, waitFor,
fireEvent, render, screen, waitFor, within,
} from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import V2Inspector, { INSPECTOR_OPEN_KEY } from '../components/V2Inspector';
import V2Inspector, { INSPECTOR_OPEN_KEY, threadRowsFrom } from '../components/V2Inspector';
import V2Avatar from '../components/V2Avatar';

const mockNavigate = jest.fn();
Expand Down Expand Up @@ -148,12 +148,75 @@ describe('V2Inspector', () => {
currentUserId: 'owner-1',
detail: { ...detail, pod: { ...detail.pod, createdBy: { _id: 'owner-1' }, joinPolicy: 'open' } } as any,
});
expect(screen.getAllByRole('radio').map((radio) => radio.getAttribute('aria-label') || radio.textContent)).toEqual([
const join = screen.getByRole('radiogroup', { name: 'Who can join' });
expect(within(join).getAllByRole('radio').map((radio) => radio.getAttribute('aria-label') || radio.textContent)).toEqual([
'Open to join',
'Invite-only',
]);
});

test('the head switches between the pod and its threads, newest activity first (D215, TASK-734)', () => {
const messages = [
{ id: '10', content: 'Older root', created_at: '2026-10-31T01:00:00Z', user: { username: 'ada' }, thread_root_id: null },
{ id: '11', content: 'reply', created_at: '2026-10-31T01:05:00Z', user: { username: 'bo' }, thread_root_id: 10 },
{ id: '20', content: 'Newer root\nsecond line', created_at: '2026-10-31T02:00:00Z', user: { username: 'cy' }, thread_root_id: null },
{ id: '21', content: 'reply', created_at: '2026-10-31T02:10:00Z', user: { username: 'ada' }, thread_root_id: '20' },
// A reply whose root is outside the loaded window has no text to show.
{ id: '31', content: 'orphan', created_at: '2026-10-31T03:00:00Z', user: { username: 'ada' }, thread_root_id: 30 },
];
expect(threadRowsFrom(messages as any).map((row) => [row.rootId, row.author, row.snippet])).toEqual([
['20', 'cy', 'Newer root second line'],
['10', 'ada', 'Older root'],
]);

renderInspector({ detail: { ...detail, messages } as any });
expect(screen.getByRole('heading', { name: /Needs you/ })).toBeInTheDocument();
fireEvent.click(screen.getByRole('radio', { name: 'Threads' }));
expect(screen.queryByRole('heading', { name: /Needs you/ })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^cy/ }));
expect(mockNavigate).toHaveBeenCalledWith('/v2/pods/pod-1#message-20');
});

test('a reconciled root is found by its Postgres id and listed by the id its anchor uses', () => {
const messages = [
{ id: '6a1f00000000000000000001', pgId: '40', content: 'Migrated root', created_at: '2026-10-31T01:00:00Z', user: { username: 'ada' }, thread_root_id: null },
{ id: '41', pgId: '41', content: 'reply', created_at: '2026-10-31T01:05:00Z', user: { username: 'bo' }, thread_root_id: 40 },
];
expect(threadRowsFrom(messages as any).map((row) => [row.rootId, row.snippet])).toEqual([
['6a1f00000000000000000001', 'Migrated root'],
]);
});

test('the threads view says so when the pod has none', () => {
renderInspector();
fireEvent.click(screen.getByRole('radio', { name: 'Threads' }));
expect(screen.getByText('No threads in the loaded messages.')).toBeInTheDocument();
});

test('opening another pod puts the panel back on the pod view', () => {
const view = renderInspector();
fireEvent.click(screen.getByRole('radio', { name: 'Threads' }));
expect(screen.getByRole('radio', { name: 'Threads' })).toBeChecked();
view.rerender(
<MemoryRouter>
<V2Inspector
detail={{ ...detail, pod: { ...detail.pod, _id: 'pod-2' } } as any}
attentionCount={0}
attentionItems={[]}
onOpenInvite={jest.fn()}
/>
</MemoryRouter>,
);
expect(screen.getByRole('radio', { name: 'Pod' })).toBeChecked();
});

test('the open panel carries its own Hide panel key', () => {
const onClose = jest.fn();
renderInspector({ onClose });
fireEvent.click(screen.getByRole('button', { name: 'Hide panel' }));
expect(onClose).toHaveBeenCalledTimes(1);
});

test('non-owner cannot see the join-policy controls', () => {
renderInspector({
currentUserId: 'another-user',
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/v2/__tests__/V2MessageRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,8 @@ it('the approval-card branch links to its server-bound original pod', () => {
},
}} /></MemoryRouter>);
expect(screen.getByTestId('approval-card')).toBeInTheDocument();
// A Threads row lands on this card by its standard anchor.
expect(screen.getByTestId('approval-card').closest('#message-approval-origin-run')).toHaveAttribute('tabindex', '-1');
expect(screen.getByRole('link', { name: 'View run trace' })).toHaveAttribute('href', `/v2/pods/${runPodId}/runs/${runId}`);
});

Expand Down
8 changes: 3 additions & 5 deletions frontend/src/v2/__tests__/V2ThreadStarter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,16 +117,14 @@ describe('V2Thread teaching empty states', () => {
expect(document.querySelector('.v2-teammate')).toBeNull();
});

test('compact header shows the member count; the agent count stays in the desktop meta', () => {
test('the desktop meta labels the member faces with both counts; the phone head carries no counts', () => {
const detail = makeDetail({
agents: [{ agentName: 'openclaw', instanceId: 'aria', displayName: 'Aria' }],
});
renderChat(detail);

const compact = document.querySelector('.v2-pod-header__meta--compact');
expect(compact).toHaveTextContent('1 member');
expect(compact).not.toHaveTextContent('agent');
expect(document.querySelector('.v2-pod-header__meta:not(.v2-pod-header__meta--compact)')).toHaveTextContent('1 agent');
expect(document.querySelector('.v2-pod-header__meta--compact')).toBeNull();
expect(document.querySelector('.v2-pod-header__faces')).toHaveAttribute('aria-label', '1 member, 1 agent');
});

test('multi-member regular pods use the same teaching state', () => {
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/v2/__tests__/v2-copy-budget.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ describe('page head copy budget', () => {
expect(css).toContain('.v2-page-head__identity .v2-hint--open,\n .v2-thread__title-line .v2-hint--open { display: contents; }');
expect(css).toContain('.v2-thread__title-line { display: flex; flex-wrap: wrap; align-items: center; }');
expect(css).toContain('.v2-thread__title-line .v2-hint__content { width: 100%; flex-basis: 100%; order: 1; }');
expect(css).toMatch(/\.v2-pod-header \.v2-thread__header-row \{[^}]*padding: 11px 0;[^}]*align-items: flex-start;/);
expect(css).toMatch(/\.v2-pod-header \.v2-thread__header-row \{[^}]*padding: 10px 0;[^}]*align-items: flex-start;/);
expect(css).toContain('.v2-pod-header .v2-thread__title { display: flex; min-height: 32px; flex-direction: column; justify-content: center; }');
expect(css).toContain('.v2-pod-header__meta.v2-pod-header__meta--compact { display: flex; min-height: 28px; flex: 0 0 auto; white-space: nowrap; }');
expect(css).toContain('box-shadow: var(--v2-focus-ring);');
});
Expand Down
Loading
Loading