diff --git a/desktop/src/features/chats/lib/chatActivity.test.mjs b/desktop/src/features/chats/lib/chatActivity.test.mjs index 0407ffbfd5..8362105c88 100644 --- a/desktop/src/features/chats/lib/chatActivity.test.mjs +++ b/desktop/src/features/chats/lib/chatActivity.test.mjs @@ -283,3 +283,117 @@ test("buildChatActivityPlacement keeps separate agent turns visible", () => { assert.deepEqual([...placement.hiddenAgentMessageIds], []); }); + +// ── Time-based fallback placement ──────────────────────────────────────── + +function unmatchedTurnTranscript(promptIso, toolIso) { + return [ + { + id: "prompt-x", + type: "message", + renderClass: "message", + role: "user", + title: "User", + text: "A replayed ask", + timestamp: promptIso, + turnId: "turn-x", + channelId: "chat-1", + sessionId: "session-1", + // Not present in the fetched message window — the id can't match. + messageId: "missing-from-window", + acpSource: "session/prompt:user", + authorPubkey: self, + }, + { + id: "tool-x", + type: "tool", + renderClass: "shell", + descriptor: { + renderClass: "shell", + label: "Ran command", + preview: "pnpm test", + }, + title: "pnpm test", + toolName: "shell", + buzzToolName: null, + status: "completed", + args: { command: "pnpm test" }, + result: "ok", + isError: false, + timestamp: toolIso, + startedAt: toolIso, + completedAt: toolIso, + turnId: "turn-x", + channelId: "chat-1", + sessionId: "session-1", + }, + ]; +} + +const seconds = (iso) => Math.floor(Date.parse(iso) / 1_000); + +test("a turn with an unmatched prompt id places by time, not at the bottom", () => { + const messages = [ + message({ + id: "user-1", + content: "First ask", + created_at: seconds("2026-07-02T07:00:00Z"), + }), + message({ + id: "user-2", + content: "Second ask", + created_at: seconds("2026-07-02T08:00:00Z"), + }), + ]; + const placement = buildChatActivityPlacement({ + agentPubkey: agent, + messages, + transcript: unmatchedTurnTranscript( + "2026-07-02T07:00:05.000Z", + "2026-07-02T07:00:06.000Z", + ), + }); + + const attached = placement.blocksByMessageId.get("user-1") ?? []; + assert.equal(attached.length, 1, "the turn must follow its era's message"); + assert.equal( + attached[0].suppressPromptMessage, + false, + "a time-placed block did not match the message by id", + ); + assert.equal(placement.unplacedBlocks.length, 0); +}); + +test("an unmatched turn newer than every message follows the latest one", () => { + const messages = [ + message({ + id: "user-1", + content: "First ask", + created_at: seconds("2026-07-02T07:00:00Z"), + }), + ]; + const placement = buildChatActivityPlacement({ + agentPubkey: agent, + messages, + transcript: unmatchedTurnTranscript( + "2026-07-02T09:00:00.000Z", + "2026-07-02T09:00:01.000Z", + ), + }); + + assert.equal((placement.blocksByMessageId.get("user-1") ?? []).length, 1); + assert.equal(placement.unplacedBlocks.length, 0); +}); + +test("an unmatched turn with no messages stays in the trailing bucket", () => { + const placement = buildChatActivityPlacement({ + agentPubkey: agent, + messages: [], + transcript: unmatchedTurnTranscript( + "2026-07-02T09:00:00.000Z", + "2026-07-02T09:00:01.000Z", + ), + }); + + assert.equal(placement.unplacedBlocks.length, 1); +}); diff --git a/desktop/src/features/chats/lib/chatActivity.ts b/desktop/src/features/chats/lib/chatActivity.ts index 707df8d65c..e8ffb16369 100644 --- a/desktop/src/features/chats/lib/chatActivity.ts +++ b/desktop/src/features/chats/lib/chatActivity.ts @@ -41,15 +41,22 @@ export function buildChatActivityPlacement({ for (const block of blocks) { const promptMessageId = getPromptMessageId(block); - const attachedMessageId = + const exactMessageId = promptMessageId && messageIds.has(promptMessageId) ? promptMessageId : null; + // A block whose prompt message can't be matched (replayed backlog id + // outside the fetch window, prompt text that didn't carry the id, …) + // must NOT drop to the trailing bucket — that pins it below every + // message that arrives later, forever. Place it at its chronological + // spot instead: after the latest message that predates the block. + const attachedMessageId = + exactMessageId ?? findMessageIdByTime(messages, getBlockStartMs(block)); const renderBlock = { id: getBlockId(block), block, attachedMessageId, - suppressPromptMessage: attachedMessageId !== null, + suppressPromptMessage: exactMessageId !== null, }; if (attachedMessageId) { @@ -211,6 +218,74 @@ function normalizeMessageText(text: string) { return normalizeAssistantMessageTextForMatching(text); } +// Transcript timestamps come from the agent host's clock while message +// created_at comes from the sender's; a little leeway keeps a block from +// slipping one message early when the agent clock runs slightly behind. +const BLOCK_TIME_SKEW_MS = 5_000; + +/** + * Latest message at or before the block's start (with skew tolerance), for + * blocks whose prompt message couldn't be matched by id. A block older than + * every message attaches to the earliest one; null only when there are no + * messages or the block has no usable timestamp. + */ +function findMessageIdByTime( + messages: RelayEvent[], + blockStartMs: number | null, +): string | null { + if (blockStartMs === null || messages.length === 0) { + return null; + } + let bestId: string | null = null; + let bestMs = Number.NEGATIVE_INFINITY; + let earliestId: string | null = null; + let earliestMs = Number.POSITIVE_INFINITY; + for (const message of messages) { + const ms = message.created_at * 1_000; + if (ms < earliestMs) { + earliestMs = ms; + earliestId = message.id; + } + if (ms <= blockStartMs + BLOCK_TIME_SKEW_MS && ms >= bestMs) { + bestMs = ms; + bestId = message.id; + } + } + return bestId ?? earliestId; +} + +function getBlockStartMs(block: TranscriptDisplayBlock): number | null { + if (block.kind === "single") { + return parseTimestampMs(block.item.timestamp); + } + for (const segment of block.segments) { + let ms: number | null = null; + if (segment.kind === "prompt") { + ms = parseTimestampMs(segment.user.timestamp); + } else if (segment.kind === "item") { + ms = parseTimestampMs(segment.item.timestamp); + } else if (segment.kind === "summary") { + ms = + parseTimestampMs(segment.summary.items[0]?.timestamp) ?? + parseTimestampMs(segment.summary.timestamp); + } else if (segment.kind === "setup") { + ms = parseTimestampMs(segment.items[0]?.timestamp); + } + if (ms !== null) { + return ms; + } + } + return null; +} + +function parseTimestampMs(timestamp: string | undefined): number | null { + if (!timestamp) { + return null; + } + const ms = Date.parse(timestamp); + return Number.isFinite(ms) ? ms : null; +} + function getBlockId(block: TranscriptDisplayBlock) { if (block.kind === "single") { return block.item.id;