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
4 changes: 3 additions & 1 deletion frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const NUMERAL_PLACEHOLDERS = [
// shell proves either way.
const OPAQUE_PLACEHOLDERS = [
'age', 'by', 'pod', 'email', 'provider', 'podName', 'creator', 'date', 'agentName',
'name', 'second', 'first', 'handle', 'author', 'affiliations', 'username', 'code',
'name', 'second', 'first', 'last', 'handle', 'author', 'affiliations', 'username', 'code',
'label', 'taskId', 'agent', 'machine', 'time', 'section', 'platform', 'projects',
'connector', 'user', 'workspace', 'channel', 'tool', 'tools', 'member', 'agents', 'os',
// common.aboutPage receives a page title, currently Latin, not a count.
Expand All @@ -52,6 +52,8 @@ const OPAQUE_PLACEHOLDERS = [
'id',
// podChat.deliveryHintOthers names the agent that was woken.
'woken',
// podChat.claimRun.label names the first and last task of a claim run (Latin task ids).
'last',
];

// The exception runs the safe way: a name that is usually opaque and is a number
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,9 @@
"systemActions": {
"taskClaimedVerb": "claimed"
},
"claimRun": {
"label": "{{count}} claims · {{first}} to {{last}}"
},
"starts": {
"label": "Ways to start",
"openByo": "Open Bring your own",
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,9 @@
"systemActions": {
"taskClaimedVerb": "认领了"
},
"claimRun": {
"label": "{{count}}次认领 · {{first}} 至 {{last}}"
},
"starts": {
"label": "开始方式",
"openByo": "去接入自己的智能体",
Expand Down
101 changes: 101 additions & 0 deletions frontend/src/v2/__tests__/V2ClaimRun.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import React from 'react';
import { act, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import i18n from '../../i18n';
import V2ThreadMessages from '../components/V2ThreadMessages';
import { buildThreadView } from '../utils/threadView';

jest.mock('../../context/AuthContext', () => ({
useAuth: () => ({ currentUser: { username: 'viewer' } }),
}));
jest.mock('../hooks/useV2Api', () => ({
useV2Api: () => ({ get: jest.fn(), post: jest.fn(), patch: jest.fn(), del: jest.fn() }),
}));

const claim = (id: number, taskId: string) => ({
id: String(id),
pod_id: 'pod-1',
user_id: 'u1',
content: '',
message_type: 'system',
created_at: `2026-10-31T10:0${id}:00.000Z`,
thread_root_id: null,
user: { username: 'scout' },
payload: { kind: 'system-action', action: 'task-claimed', taskId, actorName: 'Scout' },
});

const claims = [claim(1, 'TASK-674'), claim(2, 'TASK-675'), claim(3, 'TASK-676'), claim(4, 'TASK-678')];

const renderClaims = (messages = claims) => {
const threadView = buildThreadView(messages as any, new Map());
return render(
<MemoryRouter>
<V2ThreadMessages
messages={messages as any}
threadView={threadView}
threadState={{ byRoot: new Map(), toggleCollapsed: jest.fn(), toggleFollowing: jest.fn() }}
decisionByMessageId={new Map()}
settledDecisionByMessageId={new Map()}
agentDisplayNames={new Map()}
agentAuthorKeys={new Set()}
onAimAtThread={jest.fn()}
hasMore={false}
loadingOlder={false}
onLoadOlder={jest.fn()}
loading={false}
error={null}
messagesContainerRef={React.createRef()}
messagesEndRef={React.createRef()}
/>
</MemoryRouter>,
);
};

// The claim rows render as V2MessageRow system lines with a task id each.
const claimRowTaskIds = (container: HTMLElement) => Array.from(
container.querySelectorAll('.v2-system-action-line__object'),
).map((node) => node.textContent);

describe('claim run summary control (TASK-692)', () => {
afterEach(() => i18n.changeLanguage('en'));

test('shows the count and the first and last task ids, collapsed by default', () => {
const { container } = renderClaims();
const toggle = screen.getByRole('button', { name: '4 claims · TASK-674 to TASK-678' });
expect(toggle).toHaveAttribute('aria-expanded', 'false');
expect(claimRowTaskIds(container)).toEqual([]);
});

test('clicking expands the claim rows in order and clicking again collapses them', () => {
const { container } = renderClaims();
const toggle = screen.getByRole('button', { name: /4 claims/ });
act(() => toggle.click());
expect(toggle).toHaveAttribute('aria-expanded', 'true');
expect(claimRowTaskIds(container)).toEqual(['TASK-674', 'TASK-675', 'TASK-676', 'TASK-678']);
act(() => toggle.click());
expect(toggle).toHaveAttribute('aria-expanded', 'false');
expect(claimRowTaskIds(container)).toEqual([]);
});

test('Enter and Space on the focused toggle expand and collapse it', async () => {
const user = userEvent.setup();
const { container } = renderClaims();
const toggle = screen.getByRole('button', { name: /4 claims/ });
toggle.focus();
await user.keyboard('{Enter}');
expect(toggle).toHaveAttribute('aria-expanded', 'true');
expect(claimRowTaskIds(container)).toHaveLength(4);
await user.keyboard(' ');
expect(toggle).toHaveAttribute('aria-expanded', 'false');
expect(claimRowTaskIds(container)).toEqual([]);
});

test('the expanded rows sit inside the summary group, below the toggle', () => {
const { container } = renderClaims();
act(() => screen.getByRole('button', { name: /4 claims/ }).click());
const group = container.querySelector('.v2-claim-run') as HTMLElement;
expect(within(group).getByRole('button', { name: /4 claims/ })).toBeInTheDocument();
expect(group.querySelectorAll('.v2-system-action-line')).toHaveLength(4);
});
});
57 changes: 57 additions & 0 deletions frontend/src/v2/__tests__/claimRun.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { buildThreadView } from '../utils/threadView';

const claim = (id: number, taskId: string, opts: Partial<any> = {}) => ({
id: String(id),
pod_id: 'pod-1',
user_id: 'u1',
content: '',
message_type: 'system',
created_at: `2026-10-31T10:0${id % 10}:00Z`,
thread_root_id: opts.thread_root_id ?? null,
payload: { kind: 'system-action', action: 'task-claimed', taskId, actorName: 'Scout' },
} as any);

const text = (id: number) => ({
id: String(id),
pod_id: 'pod-1',
user_id: 'u2',
content: `m${id}`,
message_type: 'text',
created_at: `2026-10-31T10:0${id % 10}:00Z`,
thread_root_id: null,
} as any);

const state = (ids: number[]) => new Map(
ids.map((i) => [String(i), { threadRootId: i, following: null, collapsed: true }]),
) as any;

describe('consecutive claim lines fold into one summary item', () => {
test('two adjacent claims become one claims item, in order', () => {
const items = buildThreadView([claim(1, 'TASK-674'), claim(2, 'TASK-675')], state([]));
expect(items.map((i) => i.kind)).toEqual(['claims']);
expect(items[0]).toMatchObject({ kind: 'claims', key: '1' });
expect((items[0] as any).messages.map((m: any) => m.id)).toEqual(['1', '2']);
});

test('a single claim stays a plain message', () => {
const items = buildThreadView([text(1), claim(2, 'TASK-674'), text(3)], state([]));
expect(items.map((i) => i.kind)).toEqual(['message', 'message', 'message']);
});

test('a normal message between claims ends the run', () => {
const items = buildThreadView(
[claim(1, 'TASK-674'), claim(2, 'TASK-675'), text(3), claim(4, 'TASK-676'), claim(5, 'TASK-677')],
state([]),
);
expect(items.map((i) => i.kind)).toEqual(['claims', 'message', 'claims']);
});

test('a claim that roots a thread stays a message so its card still renders', () => {
const items = buildThreadView(
[claim(1, 'TASK-674'), claim(2, 'TASK-675'), claim(3, 'TASK-676', {}), claim(4, 'TASK-677', { thread_root_id: 3 })],
state([3]),
);
expect(items.map((i) => i.kind)).toEqual(['claims', 'message', 'card']);
expect(items[1]).toMatchObject({ kind: 'message', message: { id: '3' } });
});
});
10 changes: 10 additions & 0 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5248,6 +5248,16 @@ describe('v2 board on a phone (TASK-576)', () => {
});
});

describe('claim run summary (TASK-692)', () => {
test('the toggle is a real button styled as a control, with its state on aria-expanded', () => {
const css = fs.readFileSync(path.join(__dirname, '../v2.css'), 'utf8');
const toggle = css.match(/\.v2-root button\.v2-claim-run__toggle \{[^}]*\}/);
expect(toggle).not.toBeNull();
expect(toggle![0]).toMatch(/box-shadow: inset 0 0 0 1px var\(--v2-border\)/);
expect(css).toMatch(/\.v2-claim-run__toggle\[aria-expanded='true'\]::after/);
});
});

describe('pod board column scroll (TASK-698)', () => {
const css = read('../v2.css');
test('the board route scrolls inside its columns, not the page', () => {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/v2/components/V2Thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,8 @@ const V2Thread: React.FC<V2ThreadProps> = ({ detail, firstRunVisible = false, in
// Only rows that render with their starts count: a flat message or a thread
// root. A reply folded into a thread card renders without starts.
const byoOfferShown = useMemo(() => threadView.some((item) => {
// A folded claim run carries no starts, so only a flat message or a root can offer one.
if (item.kind === 'claims') return false;
const id = item.kind === 'message' ? String(item.message.id) : item.rootId;
return (startsByMessageId.get(id) || []).some((start) => start.id === 'open-byo');
}), [threadView, startsByMessageId]);
Expand Down
32 changes: 32 additions & 0 deletions frontend/src/v2/components/V2ThreadMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,16 @@ const V2ThreadMessages: React.FC<V2ThreadMessagesProps> = ({
// row is written to match on each open/close (it still drives the wake
// path), but a fresh mount always shows chips.
const [openRoots, setOpenRoots] = useState<Set<string>>(() => new Set());
// A run of claim lines folds to one summary row; this is the open ones.
const [openClaimRuns, setOpenClaimRuns] = useState<Set<string>>(() => new Set());
const toggleClaimRun = useCallback((key: string) => {
setOpenClaimRuns((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}, []);
const setOpen = useCallback((rootId: string, open: boolean) => {
setOpenRoots((current) => {
const next = new Set(current);
Expand Down Expand Up @@ -362,6 +372,28 @@ const V2ThreadMessages: React.FC<V2ThreadMessagesProps> = ({
);
};

if (item.kind === 'claims') {
const open = openClaimRuns.has(item.key);
const first = item.messages[0].payload?.taskId ?? '';
const last = item.messages[item.messages.length - 1].payload?.taskId ?? '';
return (
<div className="v2-claim-run" key={`claims-${item.key}`}>
<button
type="button"
className="v2-claim-run__toggle"
aria-expanded={open}
onClick={() => toggleClaimRun(item.key)}
>
<span>{t('podChat.claimRun.label', { count: item.messages.length, first, last })}</span>
</button>
{open && item.messages.map((message, claimIndex) => renderMessage(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
message,
claimIndex === 0 ? view[index - 1] : { kind: 'message', message: item.messages[claimIndex - 1] },
))}
</div>
);
}

if (item.kind === 'message') {
const next = view[index + 1];
// A root with a live thread renders INSIDE its band (below), so the
Expand Down
33 changes: 32 additions & 1 deletion frontend/src/v2/utils/threadView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { V2ThreadParticipant } from '../components/V2ThreadCard';
*/
export type ThreadViewItem =
| { kind: 'message'; message: V2Message }
| { kind: 'claims'; key: string; messages: V2Message[] }
| {
kind: 'card';
rootId: string;
Expand All @@ -26,6 +27,36 @@ const rootOf = (m: V2Message): string | null => (
m.thread_root_id === null || m.thread_root_id === undefined ? null : String(m.thread_root_id)
);
const whenOf = (m: V2Message): string => String(m.created_at || m.createdAt || '');
const isClaimLine = (m: V2Message): boolean => (
(m.message_type === 'system' || m.messageType === 'system')
&& m.payload?.kind === 'system-action'
&& m.payload.action === 'task-claimed'
);

/**
* Collapse each run of two or more consecutive claim lines into one summary
* item. A claim that roots a thread stays a message so its card still renders,
* and any other item ends the run, so only adjacent claims fold together.
*/
const groupClaimRuns = (items: ThreadViewItem[]): ThreadViewItem[] => {
const out: ThreadViewItem[] = [];
let run: V2Message[] = [];
const flush = () => {
if (run.length >= 2) out.push({ kind: 'claims', key: idOf(run[0]), messages: run });
else run.forEach((message) => out.push({ kind: 'message', message }));
run = [];
};
items.forEach((item, index) => {
if (item.kind === 'message' && isClaimLine(item.message) && items[index + 1]?.kind !== 'card') {
run.push(item.message);
return;
}
flush();
out.push(item);
});
flush();
return out;
};

/**
* Keep replies that were visible before their root arrived flat for the life
Expand Down Expand Up @@ -115,7 +146,7 @@ export const buildThreadView = (
replies,
});
}
return out;
return groupClaimRuns(out);
};

export default buildThreadView;
31 changes: 31 additions & 0 deletions frontend/src/v2/v2.css
Original file line number Diff line number Diff line change
Expand Up @@ -2969,6 +2969,37 @@ body.modern-ui.v2-canvas {
font: 400 11px/16px var(--v2-font-mono);
}

/* A run of claim lines folds to one row. The toggle is a control, so it
carries the same chevron pattern as the long-message Show more. */
.v2-root button.v2-claim-run__toggle {
display: inline-flex;
align-items: center;
gap: 5px;
height: 24px;
margin: 4px 0;
padding: 0 8px;
border: 0;
border-radius: 7px;
background: var(--v2-surface);
color: var(--v2-text-secondary);
box-shadow: inset 0 0 0 1px var(--v2-border);
font: 500 12px/1 var(--v2-font);
cursor: pointer;
}
.v2-root button.v2-claim-run__toggle:hover { background: var(--v2-surface-hover); }
.v2-root button.v2-claim-run__toggle:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--v2-accent-text); }
.v2-claim-run__toggle::after {
content: '';
width: 6px;
height: 6px;
margin-left: 2px;
border-right: 1.5px solid currentColor;
border-bottom: 1.5px solid currentColor;
transform: rotate(45deg) translateY(-2px);
transition: transform 160ms;
}
.v2-claim-run__toggle[aria-expanded='true']::after { transform: rotate(225deg) translateY(-2px); }

/* §3.8 system event card — replaces the regular author/avatar bubble for
commonly-bot announcements like "Pixel and codex started a DM". One-row,
chrome-light, with a primary CTA that navigates internally. */
Expand Down
Loading