= ({
);
};
+ 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 (
+
+
+ {open && item.messages.map((message, claimIndex) => renderMessage(
+ message,
+ claimIndex === 0 ? view[index - 1] : { kind: 'message', message: item.messages[claimIndex - 1] },
+ ))}
+
+ );
+ }
+
if (item.kind === 'message') {
const next = view[index + 1];
// A root with a live thread renders INSIDE its band (below), so the
diff --git a/frontend/src/v2/utils/threadView.ts b/frontend/src/v2/utils/threadView.ts
index e4cf8e8196..a064719329 100644
--- a/frontend/src/v2/utils/threadView.ts
+++ b/frontend/src/v2/utils/threadView.ts
@@ -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;
@@ -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
@@ -115,7 +146,7 @@ export const buildThreadView = (
replies,
});
}
- return out;
+ return groupClaimRuns(out);
};
export default buildThreadView;
diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css
index d918c0c58f..5bbc5cf13d 100644
--- a/frontend/src/v2/v2.css
+++ b/frontend/src/v2/v2.css
@@ -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. */