From 343e150deacc77e30c265bedbf8bf9908674ec7a Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 28 May 2026 19:08:22 +0000 Subject: [PATCH 1/9] =?UTF-8?q?=F0=9F=A4=96=20fix:=20bundle=20failed=20too?= =?UTF-8?q?ls=20in=20hyper=20density?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hyper transcript density now treats failed, interrupted, redacted, and partial tool rows as bundleable so noisy validation failures collapse under the same operational summaries. Assistant text rows continue to split operation bundles inside an expanded work bundle so narrative checkpoints remain visible. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$13.89`_ --- src/browser/components/ChatPane/ChatPane.tsx | 8 +- .../Messages/TranscriptDensity.stories.tsx | 79 ++++--- .../transcriptRenderProjection.test.ts | 182 +++++++++++++++- .../messages/transcriptRenderProjection.ts | 197 +++++++++++++++--- tests/ui/chat/transcriptDensity.test.tsx | 64 +++++- 5 files changed, 456 insertions(+), 74 deletions(-) diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index 15d19a2aa7..717e267f54 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -1239,7 +1239,13 @@ const ChatPaneContent: React.FC = (props) => { ? (workBundleOverride ?? workBundle.defaultExpanded) : false; - if (workBundle?.position === "member") { + const keepCollapsedWorkBundleMemberVisible = + msg.type === "user" || + (msg.type === "assistant" && msg.isSideAnswer === true); + if ( + workBundle?.position === "member" && + (isWorkBundleExpanded || !keepCollapsedWorkBundleMemberVisible) + ) { return null; } diff --git a/src/browser/features/Messages/TranscriptDensity.stories.tsx b/src/browser/features/Messages/TranscriptDensity.stories.tsx index a5cb139106..8f374dde0e 100644 --- a/src/browser/features/Messages/TranscriptDensity.stories.tsx +++ b/src/browser/features/Messages/TranscriptDensity.stories.tsx @@ -36,6 +36,7 @@ function setupTranscriptDensityStory(density: TranscriptDensity) { createAssistantMessage("density-assistant-1", "I'll gather context first.", { historySequence: 2, timestamp: STABLE_TIMESTAMP - 55_000, + partial: true, reasoning: "Need to inspect auth code, search related validation guidance, and then make a minimal patch.", toolCalls: [ @@ -43,38 +44,56 @@ function setupTranscriptDensityStory(density: TranscriptDensity) { createWebSearchTool("density-search-1", "JWT validation best practices", 3), createAgentSkillReadTool("density-skill-1", "react-effects", { scope: "global" }), createBashTool("density-rg-1", 'rg "verify" src', "src/auth.ts:1:verify"), - { - type: "text", - text: "I found the relevant code and will patch it.", - timestamp: STABLE_TIMESTAMP - 35_000, - }, - createFileEditTool( - "density-edit-1", - "src/auth.ts", - [ - "--- src/auth.ts", - "+++ src/auth.ts", - "@@ -1,3 +1,4 @@", - "+import { timingSafeEqual } from 'crypto';", - " export function verify() {}", - ].join("\n") - ), - createBashTool( - "density-test-1", - "make test", - "42 tests passed", - 0, - 30, - 500, - "Running tests" - ), - { - type: "text", - text: "Implemented the auth audit fix and validated it.", - timestamp: STABLE_TIMESTAMP - 15_000, - }, ], }), + createUserMessage("density-user-2", "Please validate with typecheck too", { + historySequence: 3, + timestamp: STABLE_TIMESTAMP - 40_000, + }), + createAssistantMessage( + "density-assistant-2", + "I found the relevant code and will patch it.", + { + historySequence: 4, + timestamp: STABLE_TIMESTAMP - 35_000, + toolCalls: [ + createFileEditTool( + "density-edit-1", + "src/auth.ts", + [ + "--- src/auth.ts", + "+++ src/auth.ts", + "@@ -1,3 +1,4 @@", + "+import { timingSafeEqual } from 'crypto';", + " export function verify() {}", + ].join("\n") + ), + createBashTool( + "density-test-1", + "make test", + "42 tests passed", + 0, + 30, + 500, + "Running tests" + ), + createBashTool( + "density-fail-1", + "make typecheck", + "Type error in src/auth.ts", + 1, + 30, + 500, + "Failing validation" + ), + { + type: "text", + text: "Implemented the auth audit fix and validated it.", + timestamp: STABLE_TIMESTAMP - 15_000, + }, + ], + } + ), ], }); } diff --git a/src/browser/utils/messages/transcriptRenderProjection.test.ts b/src/browser/utils/messages/transcriptRenderProjection.test.ts index 1d1e3f0494..e69f6b8efb 100644 --- a/src/browser/utils/messages/transcriptRenderProjection.test.ts +++ b/src/browser/utils/messages/transcriptRenderProjection.test.ts @@ -75,6 +75,7 @@ function assistant( isLastPartOfMessage: overrides.isLastPartOfMessage, isCompacted: overrides.isCompacted ?? false, isIdleCompacted: overrides.isIdleCompacted ?? false, + isSideAnswer: overrides.isSideAnswer, timestamp: overrides.timestamp, }; } @@ -217,6 +218,122 @@ describe("work bundle coalescing", () => { }); }); + test("spans steering user messages until the turn final assistant row", () => { + const messages = [ + user("u1"), + tool({ id: "read-1", historyId: "history-a1", timestamp: 1_000 }), + user("steer-1"), + tool({ id: "bash-1", historyId: "history-a1", toolName: "bash", timestamp: 31_000 }), + assistant("final-1", { historyId: "history-a1", timestamp: 61_000 }), + ]; + + const infos = computeWorkBundleInfos(messages); + + expect(infos[1]).toMatchObject({ + key: "work:read-1", + position: "head", + durationMs: 60_000, + entries: [ + { message: messages[1], originalIndex: 1 }, + { message: messages[2], originalIndex: 2 }, + { message: messages[3], originalIndex: 3 }, + ], + }); + expect(infos[2]).toMatchObject({ key: "work:read-1", position: "member" }); + expect(infos[3]).toMatchObject({ key: "work:read-1", position: "member" }); + expect(infos[4]).toMatchObject({ key: "work:read-1", position: "final" }); + }); + + test("keeps side-question answers visible while bundling surrounding agent work", () => { + const messages = [ + user("u1"), + tool({ id: "read-1", historyId: "history-a1" }), + user("side-question-1"), + assistant("side-answer-1", { isSideAnswer: true }), + tool({ id: "bash-1", historyId: "history-a1", toolName: "bash" }), + assistant("final-1", { historyId: "history-a1" }), + ]; + + const infos = computeWorkBundleInfos(messages); + + expect(infos[1]).toMatchObject({ + key: "work:read-1", + entries: [ + { message: messages[1], originalIndex: 1 }, + { message: messages[2], originalIndex: 2 }, + { message: messages[3], originalIndex: 3 }, + { message: messages[4], originalIndex: 4 }, + ], + }); + expect(infos[2]).toMatchObject({ key: "work:read-1", position: "member" }); + expect(infos[3]).toMatchObject({ key: "work:read-1", position: "member" }); + expect(infos[4]).toMatchObject({ key: "work:read-1", position: "member" }); + expect(infos[5]).toMatchObject({ key: "work:read-1", position: "final" }); + }); + + test("does not start a work bundle at a side-question answer", () => { + const messages = [ + user("u1"), + assistant("side-answer-1", { isSideAnswer: true }), + tool({ id: "bash-1", historyId: "history-a1", toolName: "bash" }), + assistant("final-1", { historyId: "history-a1" }), + ]; + + const infos = computeWorkBundleInfos(messages); + + expect(infos[1]).toBeUndefined(); + expect(infos[2]).toMatchObject({ + key: "work:bash-1", + entries: [{ message: messages[2], originalIndex: 2 }], + }); + expect(infos[3]).toMatchObject({ key: "work:bash-1", position: "final" }); + }); + + test("does not merge interrupted work across the next user prompt", () => { + const messages = [ + user("u1"), + tool({ id: "interrupted-1", historyId: "history-a1", status: "interrupted" }), + user("u2"), + tool({ id: "bash-1", historyId: "history-a2", toolName: "bash" }), + assistant("final-2", { historyId: "history-a2" }), + ]; + + const infos = computeWorkBundleInfos(messages); + + expect(infos[1]).toBeUndefined(); + expect(infos[2]).toBeUndefined(); + expect(infos[3]).toMatchObject({ + key: "work:bash-1", + entries: [{ message: messages[3], originalIndex: 3 }], + }); + expect(infos[4]).toMatchObject({ key: "work:bash-1", position: "final" }); + }); + + test("does not merge completed turns across the next user message", () => { + const messages = [ + user("u1"), + tool({ id: "read-1", historyId: "history-a1" }), + assistant("final-1", { historyId: "history-a1" }), + user("u2"), + tool({ id: "bash-1", historyId: "history-a2", toolName: "bash" }), + assistant("final-2", { historyId: "history-a2" }), + ]; + + const infos = computeWorkBundleInfos(messages); + + expect(infos[1]).toMatchObject({ + key: "work:read-1", + entries: [{ message: messages[1], originalIndex: 1 }], + }); + expect(infos[2]).toMatchObject({ key: "work:read-1", position: "final" }); + expect(infos[3]).toBeUndefined(); + expect(infos[4]).toMatchObject({ + key: "work:bash-1", + entries: [{ message: messages[4], originalIndex: 4 }], + }); + expect(infos[5]).toMatchObject({ key: "work:bash-1", position: "final" }); + }); + test("leaves active work visible", () => { const messages = [ reasoning({ id: "think-1", historyId: "history-a1" }), @@ -229,7 +346,7 @@ describe("work bundle coalescing", () => { expect(infos.every((info) => info === undefined)).toBe(true); }); - test("keeps non-success tools visible before a final assistant row", () => { + test("collapses non-success tools before a final assistant row", () => { const historyId = "history-a1"; const failedSearch = tool({ id: "search-1", @@ -274,7 +391,22 @@ describe("work bundle coalescing", () => { const infos = computeWorkBundleInfos(messages); - expect(infos.every((info) => info === undefined)).toBe(true); + expect(infos[0]).toMatchObject({ + key: "work:search-1", + position: "head", + entries: [ + { message: failedSearch, originalIndex: 0 }, + { message: failedBash, originalIndex: 1 }, + { message: interruptedRead, originalIndex: 2 }, + { message: redactedRead, originalIndex: 3 }, + { message: partialRead, originalIndex: 4 }, + ], + }); + expect(infos[1]).toMatchObject({ key: "work:search-1", position: "member" }); + expect(infos[2]).toMatchObject({ key: "work:search-1", position: "member" }); + expect(infos[3]).toMatchObject({ key: "work:search-1", position: "member" }); + expect(infos[4]).toMatchObject({ key: "work:search-1", position: "member" }); + expect(infos[5]).toMatchObject({ key: "work:search-1", position: "final" }); }); test("keeps visible artifacts and stream errors out of work bundles", () => { @@ -342,6 +474,28 @@ describe("operational bundle coalescing", () => { expect(infos[4]).toMatchObject({ position: "head" }); }); + test("assistant rows split non-success operational bundles", () => { + const first = tool({ id: "bash-1", toolName: "bash", status: "failed" }); + const middle = assistant("a1"); + const second = tool({ id: "bash-2", toolName: "bash", status: "failed" }); + + const infos = computeOperationalBundleInfos([first, middle, second], { + isTurnActive: false, + }); + + expect(infos[0]).toMatchObject({ + key: "bundle:bash-1", + position: "head", + entries: [{ message: first, originalIndex: 0 }], + }); + expect(infos[1]).toBeUndefined(); + expect(infos[2]).toMatchObject({ + key: "bundle:bash-2", + position: "head", + entries: [{ message: second, originalIndex: 2 }], + }); + }); + test("leaves reasoning-only turns visible", () => { const message = reasoning({ id: "think-only", isOnlyMessageContent: true }); @@ -368,7 +522,7 @@ describe("operational bundle coalescing", () => { expect(reasoningThenTool[0]?.summary.title).toBe("Ran 2 operations"); }); - test("leaves non-success tools visible", () => { + test("groups non-success tools into operational bundles", () => { const failedSearch = tool({ id: "search-1", toolName: "web_search", @@ -402,7 +556,27 @@ describe("operational bundle coalescing", () => { { isTurnActive: false } ); - expect(infos.every((info) => info === undefined)).toBe(true); + expect(infos[0]).toMatchObject({ + key: "bundle:search-1", + position: "head", + state: "settled", + defaultExpanded: false, + entries: [ + { message: failedSearch, originalIndex: 0 }, + { message: failedBash, originalIndex: 1 }, + { message: interruptedRead, originalIndex: 2 }, + { message: redactedRead, originalIndex: 3 }, + { message: partialRead, originalIndex: 4 }, + ], + summary: { + title: "Ran 5 operations", + details: "1 search · 1 shell command · 3 reads", + }, + }); + expect(infos[1]).toMatchObject({ key: "bundle:search-1", position: "member" }); + expect(infos[2]).toMatchObject({ key: "bundle:search-1", position: "member" }); + expect(infos[3]).toMatchObject({ key: "bundle:search-1", position: "member" }); + expect(infos[4]).toMatchObject({ key: "bundle:search-1", position: "member" }); }); test("active and just-settled tail bundles stay expanded until a visible event or turn end", () => { diff --git a/src/browser/utils/messages/transcriptRenderProjection.ts b/src/browser/utils/messages/transcriptRenderProjection.ts index 42c7a5584c..24bd45ddb2 100644 --- a/src/browser/utils/messages/transcriptRenderProjection.ts +++ b/src/browser/utils/messages/transcriptRenderProjection.ts @@ -116,25 +116,15 @@ export function computeWorkBundleInfos( let index = 0; while (index < messages.length) { - const historyId = getWorkBundleHistoryId(messages[index]); - if (historyId === undefined) { + if (!isWorkBundleStart(messages, index)) { index += 1; continue; } const startIndex = index; - while (getWorkBundleHistoryId(messages[index + 1]) === historyId) { + const finalIndex = findWorkBundleFinalIndex(messages, startIndex); + if (finalIndex === undefined) { index += 1; - } - - const finalIndex = index; - index += 1; - - if (finalIndex <= startIndex) { - continue; - } - - if (messages[finalIndex]?.type !== "assistant") { continue; } @@ -143,8 +133,15 @@ export function computeWorkBundleInfos( entries.push({ message: messages[entryIndex], originalIndex: entryIndex }); } - const groupMessages = [...entries.map((entry) => entry.message), messages[finalIndex]]; + const finalMessage = messages[finalIndex]; + if (finalMessage?.type !== "assistant") { + index = finalIndex + 1; + continue; + } + + const groupMessages = [...entries.map((entry) => entry.message), finalMessage]; if (groupMessages.some(isActiveWorkBundleMessage)) { + index = finalIndex + 1; continue; } @@ -155,7 +152,7 @@ export function computeWorkBundleInfos( position: "head", headIndex: startIndex, entries: frozenEntries, - durationMs: computeWorkBundleDurationMs(frozenEntries, messages[finalIndex]), + durationMs: computeWorkBundleDurationMs(frozenEntries, finalMessage), defaultExpanded: false, }; @@ -166,6 +163,7 @@ export function computeWorkBundleInfos( }; } infos[finalIndex] = { ...info, position: "final" }; + index = finalIndex + 1; } return infos; @@ -241,16 +239,156 @@ export function computeOperationalBundleInfos( return infos; } -function getWorkBundleHistoryId(message: DisplayedMessage | undefined): string | undefined { - switch (message?.type) { - case "assistant": - case "reasoning": - return message.historyId; - case "tool": - return isBundleableToolMessage(message) ? message.historyId : undefined; - default: - return undefined; +function isWorkBundleStart(messages: DisplayedMessage[], index: number): boolean { + const message = messages[index]; + const historyId = getWorkBundleAgentHistoryId(message); + if (historyId === undefined) { + return false; + } + if (message.type !== "assistant") { + return true; + } + if (message.isPartial) { + return true; + } + + for (let nextIndex = index + 1; nextIndex < messages.length; nextIndex++) { + const next = messages[nextIndex]; + if (!isWorkBundleTimelineMessage(next)) { + return false; + } + if (isWorkBundleVisibleConversationMessage(next)) { + if (!hasFutureAgentMessageWithHistoryId(messages, nextIndex + 1, historyId)) { + return false; + } + continue; + } + + const nextHistoryId = getWorkBundleAgentHistoryId(next); + if (nextHistoryId !== historyId) { + return false; + } + if (isWorkBundleOperationalMessage(next)) { + return true; + } + if (next.type === "assistant" && !next.isPartial) { + return false; + } } + + return false; +} + +function findWorkBundleFinalIndex( + messages: DisplayedMessage[], + startIndex: number +): number | undefined { + const historyId = getWorkBundleAgentHistoryId(messages[startIndex]); + if (historyId === undefined) { + return undefined; + } + + let sawOperationalMessage = isWorkBundleOperationalMessage(messages[startIndex]); + let finalIndex: number | undefined; + + for (let index = startIndex + 1; index < messages.length; index++) { + const message = messages[index]; + if (!isWorkBundleTimelineMessage(message)) { + break; + } + + if (isWorkBundleVisibleConversationMessage(message)) { + if (!hasFutureAgentMessageWithHistoryId(messages, index + 1, historyId)) { + break; + } + continue; + } + + const messageHistoryId = getWorkBundleAgentHistoryId(message); + if (messageHistoryId !== historyId) { + break; + } + + if (isWorkBundleOperationalMessage(message)) { + sawOperationalMessage = true; + } + + if (message.type !== "assistant" || message.isPartial) { + continue; + } + + finalIndex = index; + const next = messages[index + 1]; + if (next === undefined || !isWorkBundleTimelineMessage(next)) { + break; + } + if (!isWorkBundleVisibleConversationMessage(next)) { + const nextHistoryId = getWorkBundleAgentHistoryId(next); + if (nextHistoryId !== historyId) { + break; + } + } + } + + if (!sawOperationalMessage || finalIndex === undefined || finalIndex <= startIndex) { + return undefined; + } + + return finalIndex; +} + +function hasFutureAgentMessageWithHistoryId( + messages: DisplayedMessage[], + startIndex: number, + historyId: string +): boolean { + for (let index = startIndex; index < messages.length; index++) { + const message = messages[index]; + if (!isWorkBundleTimelineMessage(message)) { + return false; + } + if (isWorkBundleVisibleConversationMessage(message)) { + continue; + } + + return getWorkBundleAgentHistoryId(message) === historyId; + } + + return false; +} + +function getWorkBundleAgentHistoryId(message: DisplayedMessage | undefined): string | undefined { + if (!isWorkBundleAgentMessage(message) || isWorkBundleVisibleConversationMessage(message)) { + return undefined; + } + return message.historyId; +} + +function isWorkBundleTimelineMessage( + message: DisplayedMessage | undefined +): message is DisplayedMessage & { type: "assistant" | "reasoning" | "tool" | "user" } { + return ( + message?.type === "assistant" || + message?.type === "reasoning" || + message?.type === "tool" || + message?.type === "user" + ); +} + +function isWorkBundleAgentMessage( + message: DisplayedMessage | undefined +): message is DisplayedMessage & { type: "assistant" | "reasoning" | "tool" } { + return message?.type === "assistant" || message?.type === "reasoning" || message?.type === "tool"; +} + +function isWorkBundleVisibleConversationMessage(message: DisplayedMessage | undefined): boolean { + return ( + message?.type === "user" || (message?.type === "assistant" && message.isSideAnswer === true) + ); +} + +function isWorkBundleOperationalMessage(message: DisplayedMessage | undefined): boolean { + return message?.type === "reasoning" || message?.type === "tool"; } function isActiveWorkBundleMessage(message: DisplayedMessage): boolean { @@ -323,15 +461,6 @@ export function summarizeOperationalBundle( }; } -function isBundleableToolMessage(message: DisplayedMessage & { type: "tool" }): boolean { - if (message.isPartial) { - return false; - } - return ( - message.status === "completed" || message.status === "pending" || message.status === "executing" - ); -} - function isEmptyCompletedWebSearch(message: OperationalBundleMemberMessage): boolean { return ( message.type === "tool" && @@ -366,7 +495,7 @@ function isOperationalBundleMemberMessage( return message.isOnlyMessageContent !== true; } if (message?.type === "tool") { - return isBundleableToolMessage(message); + return true; } return false; } diff --git a/tests/ui/chat/transcriptDensity.test.tsx b/tests/ui/chat/transcriptDensity.test.tsx index 68e5e39859..bfb6e67968 100644 --- a/tests/ui/chat/transcriptDensity.test.tsx +++ b/tests/ui/chat/transcriptDensity.test.tsx @@ -15,11 +15,27 @@ import { installDom } from "../dom"; import { cleanupView, setupWorkspaceView } from "../helpers"; import { renderApp } from "../renderReviewPanel"; +function queryButtons(container: HTMLElement, testId: string): HTMLButtonElement[] { + const HTMLButton = container.ownerDocument.defaultView?.HTMLButtonElement; + if (!HTMLButton) { + throw new Error("Expected test DOM to provide HTMLButtonElement"); + } + return Array.from(container.querySelectorAll(`[data-testid="${testId}"]`)).flatMap((element) => { + if (element instanceof HTMLButton) { + return [element]; + } + const button = element.querySelector("button"); + return button ? [button] : []; + }); +} + function queryButton(container: HTMLElement, testId: string): HTMLButtonElement | null { const element = container.querySelector(`[data-testid="${testId}"]`); - return element instanceof HTMLButtonElement - ? element - : (element?.querySelector("button") ?? null); + const HTMLButton = container.ownerDocument.defaultView?.HTMLButtonElement; + if (!HTMLButton) { + throw new Error("Expected test DOM to provide HTMLButtonElement"); + } + return element instanceof HTMLButton ? element : (element?.querySelector("button") ?? null); } describe("Hyper transcript density", () => { @@ -42,12 +58,30 @@ describe("Hyper transcript density", () => { createUserMessage("density-user-1", "Audit the auth module", { historySequence: 1 }), createAssistantMessage("density-assistant-1", "I'll gather context first.", { historySequence: 2, + partial: true, reasoning: "Need to inspect auth code before changing it.", toolCalls: [ createFileReadTool("density-read-1", "src/auth.ts", "export function verify() {}"), createWebSearchTool("density-search-1", "JWT validation best practices", 1), createAgentSkillReadTool("density-skill-1", "react-effects", { scope: "global" }), createBashTool("density-rg-1", 'rg "verify" src', "src/auth.ts:1:verify"), + ], + }), + createUserMessage("density-user-2", "Please validate with typecheck too", { + historySequence: 3, + }), + createAssistantMessage("density-assistant-2", "I'll patch and validate now.", { + historySequence: 4, + toolCalls: [ + createBashTool( + "density-fail-1", + "make typecheck", + "Type error in src/auth.ts", + 1, + 30, + 500, + "Failing validation" + ), { type: "text", text: "Implemented the auth audit fix." }, ], }), @@ -66,20 +100,40 @@ describe("Hyper transcript density", () => { return button; }); expect(workButton.getAttribute("aria-expanded")).toBe("false"); + expect(view.container.textContent).toContain("Please validate with typecheck too"); + expect(view.container.textContent).not.toContain("make typecheck"); fireEvent.click(workButton); - const operationalButton = await waitFor(() => { + const firstOperationalButton = await waitFor(() => { const button = queryButton(view.container, "operational-bundle"); if (!button) { throw new Error("Operational bundle button not found"); } return button; }); - fireEvent.click(operationalButton); + expect(firstOperationalButton.textContent).toContain("Ran 5 operations"); + expect(view.container.textContent).toContain("Please validate with typecheck too"); + expect(view.container.textContent).not.toContain("make typecheck"); + fireEvent.click(firstOperationalButton); await waitFor(() => { expect(view.container.textContent).toContain("src/auth.ts"); }); + + const failedOperationalButton = await waitFor(() => { + const button = queryButtons(view.container, "operational-bundle").find((candidate) => + candidate.textContent?.includes("Ran 1 shell command") + ); + if (!button) { + throw new Error("Failed operational bundle button not found"); + } + return button; + }); + fireEvent.click(failedOperationalButton); + + await waitFor(() => { + expect(view.container.textContent).toContain("make typecheck"); + }); } finally { await cleanupView(view, cleanupDom); } From 705695ab9db6ef6f784ff92c9013b3f3e164ebff Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 29 May 2026 07:51:25 +0000 Subject: [PATCH 2/9] =?UTF-8?q?=F0=9F=A4=96=20fix:=20anchor=20hyper=20work?= =?UTF-8?q?=20bundles=20after=20user=20prompts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Place collapsed hyper-density work bundles immediately after the user message that triggered the turn while keeping in-turn steering messages visible below the collapsed bundle and chronological inside expanded bundles. --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$43.21`_ --- src/browser/components/ChatPane/ChatPane.tsx | 7 +- .../transcriptRenderProjection.test.ts | 50 +++-- .../messages/transcriptRenderProjection.ts | 173 ++++++++++-------- ...ity.test.tsx => transcriptDensity.test.ts} | 34 +++- 4 files changed, 174 insertions(+), 90 deletions(-) rename tests/ui/chat/{transcriptDensity.test.tsx => transcriptDensity.test.ts} (84%) diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index 717e267f54..49754dfcef 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -1243,13 +1243,14 @@ const ChatPaneContent: React.FC = (props) => { msg.type === "user" || (msg.type === "assistant" && msg.isSideAnswer === true); if ( - workBundle?.position === "member" && + (workBundle?.position === "member" || workBundle?.position === "final") && (isWorkBundleExpanded || !keepCollapsedWorkBundleMemberVisible) ) { return null; } const renderWorkBundle = workBundle?.position === "head"; + const renderMessageBeforeWorkBundle = renderWorkBundle && msg.type === "user"; const renderMessageAfterWorkBundle = !renderWorkBundle; const operationalBundle = workBundle ? undefined @@ -1275,6 +1276,10 @@ const ChatPaneContent: React.FC = (props) => { return ( + {renderMessageBeforeWorkBundle && + renderMessageAtIndex(msg, index, { + key: `${workspaceId}:${msg.id}:message`, + })} {renderWorkBundle && workBundle && ( { const infos = computeWorkBundleInfos(messages); - expect(infos[0]).toBeUndefined(); - expect(infos[1]).toMatchObject({ + expect(infos[0]).toMatchObject({ key: "work:think-1", position: "head", - headIndex: 1, + headIndex: 0, durationMs: 180_000, defaultExpanded: false, entries: [ { message: messages[1], originalIndex: 1 }, { message: messages[2], originalIndex: 2 }, { message: messages[3], originalIndex: 3 }, + { message: messages[4], originalIndex: 4 }, ], }); + expect(infos[1]).toMatchObject({ key: "work:think-1", position: "member" }); expect(infos[2]).toMatchObject({ key: "work:think-1", position: "member" }); expect(infos[3]).toMatchObject({ key: "work:think-1", position: "member" }); expect(infos[4]).toMatchObject({ key: "work:think-1", position: "final" }); @@ -206,7 +207,7 @@ describe("work bundle coalescing", () => { const workInfos = computeWorkBundleInfos(messages); const operationalInfos = computeOperationalBundleInfos(messages, { isTurnActive: false }); - expect(workInfos[0]?.entries.map((entry) => entry.originalIndex)).toEqual([0, 1, 2, 3]); + expect(workInfos[0]?.entries.map((entry) => entry.originalIndex)).toEqual([0, 1, 2, 3, 4]); expect(operationalInfos[2]).toMatchObject({ position: "head", headIndex: 2, @@ -221,7 +222,7 @@ describe("work bundle coalescing", () => { test("spans steering user messages until the turn final assistant row", () => { const messages = [ user("u1"), - tool({ id: "read-1", historyId: "history-a1", timestamp: 1_000 }), + tool({ id: "read-1", historyId: "history-a1", isPartial: true, timestamp: 1_000 }), user("steer-1"), tool({ id: "bash-1", historyId: "history-a1", toolName: "bash", timestamp: 31_000 }), assistant("final-1", { historyId: "history-a1", timestamp: 61_000 }), @@ -229,16 +230,19 @@ describe("work bundle coalescing", () => { const infos = computeWorkBundleInfos(messages); - expect(infos[1]).toMatchObject({ + expect(infos[0]).toMatchObject({ key: "work:read-1", position: "head", + headIndex: 0, durationMs: 60_000, entries: [ { message: messages[1], originalIndex: 1 }, { message: messages[2], originalIndex: 2 }, { message: messages[3], originalIndex: 3 }, + { message: messages[4], originalIndex: 4 }, ], }); + expect(infos[1]).toMatchObject({ key: "work:read-1", position: "member" }); expect(infos[2]).toMatchObject({ key: "work:read-1", position: "member" }); expect(infos[3]).toMatchObject({ key: "work:read-1", position: "member" }); expect(infos[4]).toMatchObject({ key: "work:read-1", position: "final" }); @@ -256,15 +260,18 @@ describe("work bundle coalescing", () => { const infos = computeWorkBundleInfos(messages); - expect(infos[1]).toMatchObject({ + expect(infos[0]).toMatchObject({ key: "work:read-1", + headIndex: 0, entries: [ { message: messages[1], originalIndex: 1 }, { message: messages[2], originalIndex: 2 }, { message: messages[3], originalIndex: 3 }, { message: messages[4], originalIndex: 4 }, + { message: messages[5], originalIndex: 5 }, ], }); + expect(infos[1]).toMatchObject({ key: "work:read-1", position: "member" }); expect(infos[2]).toMatchObject({ key: "work:read-1", position: "member" }); expect(infos[3]).toMatchObject({ key: "work:read-1", position: "member" }); expect(infos[4]).toMatchObject({ key: "work:read-1", position: "member" }); @@ -281,10 +288,14 @@ describe("work bundle coalescing", () => { const infos = computeWorkBundleInfos(messages); + expect(infos[0]).toBeUndefined(); expect(infos[1]).toBeUndefined(); expect(infos[2]).toMatchObject({ key: "work:bash-1", - entries: [{ message: messages[2], originalIndex: 2 }], + entries: [ + { message: messages[2], originalIndex: 2 }, + { message: messages[3], originalIndex: 3 }, + ], }); expect(infos[3]).toMatchObject({ key: "work:bash-1", position: "final" }); }); @@ -301,10 +312,13 @@ describe("work bundle coalescing", () => { const infos = computeWorkBundleInfos(messages); expect(infos[1]).toBeUndefined(); - expect(infos[2]).toBeUndefined(); + expect(infos[2]).toMatchObject({ key: "work:bash-1", position: "head", headIndex: 2 }); expect(infos[3]).toMatchObject({ key: "work:bash-1", - entries: [{ message: messages[3], originalIndex: 3 }], + entries: [ + { message: messages[3], originalIndex: 3 }, + { message: messages[4], originalIndex: 4 }, + ], }); expect(infos[4]).toMatchObject({ key: "work:bash-1", position: "final" }); }); @@ -321,15 +335,22 @@ describe("work bundle coalescing", () => { const infos = computeWorkBundleInfos(messages); - expect(infos[1]).toMatchObject({ + expect(infos[0]).toMatchObject({ key: "work:read-1", - entries: [{ message: messages[1], originalIndex: 1 }], + position: "head", + entries: [ + { message: messages[1], originalIndex: 1 }, + { message: messages[2], originalIndex: 2 }, + ], }); expect(infos[2]).toMatchObject({ key: "work:read-1", position: "final" }); - expect(infos[3]).toBeUndefined(); + expect(infos[3]).toMatchObject({ key: "work:bash-1", position: "head" }); expect(infos[4]).toMatchObject({ key: "work:bash-1", - entries: [{ message: messages[4], originalIndex: 4 }], + entries: [ + { message: messages[4], originalIndex: 4 }, + { message: messages[5], originalIndex: 5 }, + ], }); expect(infos[5]).toMatchObject({ key: "work:bash-1", position: "final" }); }); @@ -400,6 +421,7 @@ describe("work bundle coalescing", () => { { message: interruptedRead, originalIndex: 2 }, { message: redactedRead, originalIndex: 3 }, { message: partialRead, originalIndex: 4 }, + { message: messages[5], originalIndex: 5 }, ], }); expect(infos[1]).toMatchObject({ key: "work:search-1", position: "member" }); diff --git a/src/browser/utils/messages/transcriptRenderProjection.ts b/src/browser/utils/messages/transcriptRenderProjection.ts index 24bd45ddb2..f1573c9b31 100644 --- a/src/browser/utils/messages/transcriptRenderProjection.ts +++ b/src/browser/utils/messages/transcriptRenderProjection.ts @@ -116,54 +116,59 @@ export function computeWorkBundleInfos( let index = 0; while (index < messages.length) { - if (!isWorkBundleStart(messages, index)) { - index += 1; - continue; - } - - const startIndex = index; - const finalIndex = findWorkBundleFinalIndex(messages, startIndex); - if (finalIndex === undefined) { + const span = findWorkBundleSpan(messages, index); + if (span === undefined) { index += 1; continue; } const entries: WorkBundleEntry[] = []; - for (let entryIndex = startIndex; entryIndex < finalIndex; entryIndex++) { + for (let entryIndex = span.firstEntryIndex; entryIndex <= span.finalIndex; entryIndex++) { entries.push({ message: messages[entryIndex], originalIndex: entryIndex }); } - const finalMessage = messages[finalIndex]; + const finalMessage = messages[span.finalIndex]; if (finalMessage?.type !== "assistant") { - index = finalIndex + 1; + index = span.finalIndex + 1; continue; } - const groupMessages = [...entries.map((entry) => entry.message), finalMessage]; - if (groupMessages.some(isActiveWorkBundleMessage)) { - index = finalIndex + 1; + if (entries.some((entry) => isActiveWorkBundleMessage(entry.message))) { + index = span.finalIndex + 1; continue; } const frozenEntries = Object.freeze(entries); - const first = entries[0].message; + const firstAgentMessage = frozenEntries.find((entry) => + isWorkBundleAgentMessage(entry.message) + ); + if (firstAgentMessage === undefined) { + index = span.finalIndex + 1; + continue; + } + const info: WorkBundleInfo = { - key: `work:${first.id}`, + key: `work:${firstAgentMessage.message.id}`, position: "head", - headIndex: startIndex, + headIndex: span.headIndex, entries: frozenEntries, durationMs: computeWorkBundleDurationMs(frozenEntries, finalMessage), defaultExpanded: false, }; + infos[span.headIndex] = info; for (const entry of entries) { infos[entry.originalIndex] = { ...info, - position: entry.originalIndex === startIndex ? "head" : "member", + position: + entry.originalIndex === span.headIndex + ? "head" + : entry.originalIndex === span.finalIndex + ? "final" + : "member", }; } - infos[finalIndex] = { ...info, position: "final" }; - index = finalIndex + 1; + index = span.finalIndex + 1; } return infos; @@ -239,108 +244,118 @@ export function computeOperationalBundleInfos( return infos; } -function isWorkBundleStart(messages: DisplayedMessage[], index: number): boolean { +interface WorkBundleSpan { + headIndex: number; + firstEntryIndex: number; + finalIndex: number; +} + +function findWorkBundleSpan( + messages: DisplayedMessage[], + index: number +): WorkBundleSpan | undefined { const message = messages[index]; - const historyId = getWorkBundleAgentHistoryId(message); - if (historyId === undefined) { - return false; - } - if (message.type !== "assistant") { - return true; - } - if (message.isPartial) { - return true; + const firstEntryIndex = message?.type === "user" ? index + 1 : index; + const firstEntry = messages[firstEntryIndex]; + if (getWorkBundleAgentHistoryId(firstEntry) === undefined) { + return undefined; } - for (let nextIndex = index + 1; nextIndex < messages.length; nextIndex++) { - const next = messages[nextIndex]; - if (!isWorkBundleTimelineMessage(next)) { - return false; - } - if (isWorkBundleVisibleConversationMessage(next)) { - if (!hasFutureAgentMessageWithHistoryId(messages, nextIndex + 1, historyId)) { - return false; - } - continue; - } - - const nextHistoryId = getWorkBundleAgentHistoryId(next); - if (nextHistoryId !== historyId) { - return false; - } - if (isWorkBundleOperationalMessage(next)) { - return true; - } - if (next.type === "assistant" && !next.isPartial) { - return false; - } + const finalIndex = findWorkBundleFinalIndex(messages, firstEntryIndex); + if (finalIndex === undefined) { + return undefined; } - return false; + return { + headIndex: message?.type === "user" ? index : firstEntryIndex, + firstEntryIndex, + finalIndex, + }; } function findWorkBundleFinalIndex( messages: DisplayedMessage[], startIndex: number ): number | undefined { - const historyId = getWorkBundleAgentHistoryId(messages[startIndex]); - if (historyId === undefined) { + const firstHistoryId = getWorkBundleAgentHistoryId(messages[startIndex]); + if (firstHistoryId === undefined) { return undefined; } - let sawOperationalMessage = isWorkBundleOperationalMessage(messages[startIndex]); + const historyIds = new Set([firstHistoryId]); + let canCrossVisibleConversation = false; + let sawVisibleConversationSinceLastAgent = false; + let sawOperationalMessage = false; let finalIndex: number | undefined; - for (let index = startIndex + 1; index < messages.length; index++) { + for (let index = startIndex; index < messages.length; index++) { const message = messages[index]; if (!isWorkBundleTimelineMessage(message)) { break; } if (isWorkBundleVisibleConversationMessage(message)) { - if (!hasFutureAgentMessageWithHistoryId(messages, index + 1, historyId)) { + if (finalIndex !== undefined) { + break; + } + if (message.type === "assistant" || isSideQuestionStart(messages, index)) { + continue; + } + if (!canCrossVisibleConversation) { break; } + if (!hasFutureWorkBundleAgentMessage(messages, index + 1)) { + break; + } + sawVisibleConversationSinceLastAgent = true; + canCrossVisibleConversation = false; continue; } const messageHistoryId = getWorkBundleAgentHistoryId(message); - if (messageHistoryId !== historyId) { + if (messageHistoryId === undefined) { break; } + if (!historyIds.has(messageHistoryId)) { + if (!sawVisibleConversationSinceLastAgent) { + break; + } + historyIds.add(messageHistoryId); + } + sawVisibleConversationSinceLastAgent = false; if (isWorkBundleOperationalMessage(message)) { sawOperationalMessage = true; } + if (canContinueWorkBundleAcrossConversation(message)) { + canCrossVisibleConversation = true; + } if (message.type !== "assistant" || message.isPartial) { continue; } finalIndex = index; - const next = messages[index + 1]; - if (next === undefined || !isWorkBundleTimelineMessage(next)) { - break; - } - if (!isWorkBundleVisibleConversationMessage(next)) { - const nextHistoryId = getWorkBundleAgentHistoryId(next); - if (nextHistoryId !== historyId) { - break; - } - } + canCrossVisibleConversation = false; } - if (!sawOperationalMessage || finalIndex === undefined || finalIndex <= startIndex) { + if (!sawOperationalMessage || finalIndex === undefined || finalIndex < startIndex) { return undefined; } return finalIndex; } -function hasFutureAgentMessageWithHistoryId( +function isSideQuestionStart(messages: DisplayedMessage[], index: number): boolean { + const next = messages[index + 1]; + return ( + messages[index]?.type === "user" && next?.type === "assistant" && next.isSideAnswer === true + ); +} + +function hasFutureWorkBundleAgentMessage( messages: DisplayedMessage[], - startIndex: number, - historyId: string + startIndex: number ): boolean { for (let index = startIndex; index < messages.length; index++) { const message = messages[index]; @@ -351,12 +366,22 @@ function hasFutureAgentMessageWithHistoryId( continue; } - return getWorkBundleAgentHistoryId(message) === historyId; + return getWorkBundleAgentHistoryId(message) !== undefined; } return false; } +function canContinueWorkBundleAcrossConversation(message: DisplayedMessage): boolean { + if (!isWorkBundleAgentMessage(message)) { + return false; + } + if (message.type === "tool" && message.status === "interrupted") { + return false; + } + return message.isPartial === true; +} + function getWorkBundleAgentHistoryId(message: DisplayedMessage | undefined): string | undefined { if (!isWorkBundleAgentMessage(message) || isWorkBundleVisibleConversationMessage(message)) { return undefined; diff --git a/tests/ui/chat/transcriptDensity.test.tsx b/tests/ui/chat/transcriptDensity.test.ts similarity index 84% rename from tests/ui/chat/transcriptDensity.test.tsx rename to tests/ui/chat/transcriptDensity.test.ts index bfb6e67968..89810b5d80 100644 --- a/tests/ui/chat/transcriptDensity.test.tsx +++ b/tests/ui/chat/transcriptDensity.test.ts @@ -38,6 +38,19 @@ function queryButton(container: HTMLElement, testId: string): HTMLButtonElement return element instanceof HTMLButton ? element : (element?.querySelector("button") ?? null); } +function expectTextOrder(container: HTMLElement, ...orderedText: string[]): void { + const text = container.textContent ?? ""; + let previousIndex = -1; + for (const expected of orderedText) { + const index = text.indexOf(expected); + if (index === -1) { + throw new Error(`Expected transcript text to contain "${expected}"`); + } + expect(index).toBeGreaterThan(previousIndex); + previousIndex = index; + } +} + describe("Hyper transcript density", () => { test("expands work bundles and nested operational bundles through the app render path", async () => { const cleanupDom = installDom(); @@ -55,9 +68,13 @@ describe("Hyper transcript density", () => { projectName: metadata.projectName, projectPath: metadata.projectPath, messages: [ - createUserMessage("density-user-1", "Audit the auth module", { historySequence: 1 }), + createUserMessage("density-user-1", "Audit the auth module", { + historySequence: 1, + timestamp: 0, + }), createAssistantMessage("density-assistant-1", "I'll gather context first.", { historySequence: 2, + timestamp: 1_000, partial: true, reasoning: "Need to inspect auth code before changing it.", toolCalls: [ @@ -69,9 +86,11 @@ describe("Hyper transcript density", () => { }), createUserMessage("density-user-2", "Please validate with typecheck too", { historySequence: 3, + timestamp: 11_000, }), createAssistantMessage("density-assistant-2", "I'll patch and validate now.", { historySequence: 4, + timestamp: 21_000, toolCalls: [ createBashTool( "density-fail-1", @@ -102,6 +121,13 @@ describe("Hyper transcript density", () => { expect(workButton.getAttribute("aria-expanded")).toBe("false"); expect(view.container.textContent).toContain("Please validate with typecheck too"); expect(view.container.textContent).not.toContain("make typecheck"); + expect(view.container.textContent).not.toContain("I'll gather context first."); + expectTextOrder( + view.container, + "Audit the auth module", + "Worked for", + "Please validate with typecheck too" + ); fireEvent.click(workButton); const firstOperationalButton = await waitFor(() => { @@ -114,6 +140,12 @@ describe("Hyper transcript density", () => { expect(firstOperationalButton.textContent).toContain("Ran 5 operations"); expect(view.container.textContent).toContain("Please validate with typecheck too"); expect(view.container.textContent).not.toContain("make typecheck"); + expectTextOrder( + view.container, + "I'll gather context first.", + "Please validate with typecheck too", + "I'll patch and validate now." + ); fireEvent.click(firstOperationalButton); await waitFor(() => { From c1283212b0cdca194e9fdd9224a1297bda175a71 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 29 May 2026 08:11:13 +0000 Subject: [PATCH 3/9] =?UTF-8?q?=F0=9F=A4=96=20fix:=20keep=20collapsed=20wo?= =?UTF-8?q?rk=20bundle=20final=20visible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the final assistant summary visible below collapsed hyper-density work bundles while preserving chronological rendering inside expanded bundles. --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$61.40`_ --- src/browser/components/ChatPane/ChatPane.tsx | 3 ++- tests/ui/chat/transcriptDensity.test.ts | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index 49754dfcef..bf448384e8 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -1241,7 +1241,8 @@ const ChatPaneContent: React.FC = (props) => { const keepCollapsedWorkBundleMemberVisible = msg.type === "user" || - (msg.type === "assistant" && msg.isSideAnswer === true); + (msg.type === "assistant" && + (msg.isSideAnswer === true || workBundle?.position === "final")); if ( (workBundle?.position === "member" || workBundle?.position === "final") && (isWorkBundleExpanded || !keepCollapsedWorkBundleMemberVisible) diff --git a/tests/ui/chat/transcriptDensity.test.ts b/tests/ui/chat/transcriptDensity.test.ts index 89810b5d80..8dfe5a6ef1 100644 --- a/tests/ui/chat/transcriptDensity.test.ts +++ b/tests/ui/chat/transcriptDensity.test.ts @@ -121,12 +121,15 @@ describe("Hyper transcript density", () => { expect(workButton.getAttribute("aria-expanded")).toBe("false"); expect(view.container.textContent).toContain("Please validate with typecheck too"); expect(view.container.textContent).not.toContain("make typecheck"); + expect(view.container.textContent).toContain("Implemented the auth audit fix."); + expect(view.container.textContent).not.toContain("I'll patch and validate now."); expect(view.container.textContent).not.toContain("I'll gather context first."); expectTextOrder( view.container, "Audit the auth module", "Worked for", - "Please validate with typecheck too" + "Please validate with typecheck too", + "Implemented the auth audit fix." ); fireEvent.click(workButton); From 811c8e7964e65263e38353c6eee6076a2103bd2b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 29 May 2026 08:17:19 +0000 Subject: [PATCH 4/9] =?UTF-8?q?=F0=9F=A4=96=20fix:=20avoid=20merging=20tex?= =?UTF-8?q?t-only=20interrupted=20turns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restrict cross-user work-bundle spanning to partial tool rows so interrupted reasoning/text-only turns cannot absorb the next prompt's work. --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$61.40`_ --- .../transcriptRenderProjection.test.ts | 20 +++++++++++++++++++ .../messages/transcriptRenderProjection.ts | 11 ++++------ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/browser/utils/messages/transcriptRenderProjection.test.ts b/src/browser/utils/messages/transcriptRenderProjection.test.ts index 1bacb9fd7d..91d77b2684 100644 --- a/src/browser/utils/messages/transcriptRenderProjection.test.ts +++ b/src/browser/utils/messages/transcriptRenderProjection.test.ts @@ -300,6 +300,26 @@ describe("work bundle coalescing", () => { expect(infos[3]).toMatchObject({ key: "work:bash-1", position: "final" }); }); + test("does not merge partial text-only interruptions across the next user prompt", () => { + const messages = [ + user("u1"), + reasoning({ id: "think-1", historyId: "history-a1", isPartial: true }), + assistant("draft-1", { historyId: "history-a1", isPartial: true }), + user("u2"), + tool({ id: "bash-1", historyId: "history-a2", toolName: "bash" }), + assistant("final-2", { historyId: "history-a2" }), + ]; + + const infos = computeWorkBundleInfos(messages); + + expect(infos[0]).toBeUndefined(); + expect(infos[1]).toBeUndefined(); + expect(infos[2]).toBeUndefined(); + expect(infos[3]).toMatchObject({ key: "work:bash-1", position: "head", headIndex: 3 }); + expect(infos[4]).toMatchObject({ key: "work:bash-1", position: "member" }); + expect(infos[5]).toMatchObject({ key: "work:bash-1", position: "final" }); + }); + test("does not merge interrupted work across the next user prompt", () => { const messages = [ user("u1"), diff --git a/src/browser/utils/messages/transcriptRenderProjection.ts b/src/browser/utils/messages/transcriptRenderProjection.ts index f1573c9b31..cacd0ae59e 100644 --- a/src/browser/utils/messages/transcriptRenderProjection.ts +++ b/src/browser/utils/messages/transcriptRenderProjection.ts @@ -373,13 +373,10 @@ function hasFutureWorkBundleAgentMessage( } function canContinueWorkBundleAcrossConversation(message: DisplayedMessage): boolean { - if (!isWorkBundleAgentMessage(message)) { - return false; - } - if (message.type === "tool" && message.status === "interrupted") { - return false; - } - return message.isPartial === true; + // Only partial tool rows prove the agent was still doing operational work when + // the user spoke. Partial reasoning/text alone can also be an interrupted turn; + // do not anchor the next prompt under that old turn's work bundle. + return message.type === "tool" && message.isPartial === true && message.status !== "interrupted"; } function getWorkBundleAgentHistoryId(message: DisplayedMessage | undefined): string | undefined { From 00de0081648d3038d4135c853ca5de89745a154b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 29 May 2026 08:23:52 +0000 Subject: [PATCH 5/9] =?UTF-8?q?=F0=9F=A4=96=20fix:=20reset=20work-bundle?= =?UTF-8?q?=20steering=20gate=20on=20text=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reset cross-user work-bundle eligibility on every agent row so a partial tool cannot carry the steering gate through later partial assistant text into the next prompt. --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$61.40`_ --- .../transcriptRenderProjection.test.ts | 20 +++++++++++++++++++ .../messages/transcriptRenderProjection.ts | 4 +--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/browser/utils/messages/transcriptRenderProjection.test.ts b/src/browser/utils/messages/transcriptRenderProjection.test.ts index 91d77b2684..4f26a6a0dd 100644 --- a/src/browser/utils/messages/transcriptRenderProjection.test.ts +++ b/src/browser/utils/messages/transcriptRenderProjection.test.ts @@ -300,6 +300,26 @@ describe("work bundle coalescing", () => { expect(infos[3]).toMatchObject({ key: "work:bash-1", position: "final" }); }); + test("does not merge mixed partial tool and text interruptions across the next user prompt", () => { + const messages = [ + user("u1"), + tool({ id: "read-1", historyId: "history-a1", isPartial: true }), + assistant("draft-1", { historyId: "history-a1", isPartial: true }), + user("u2"), + tool({ id: "bash-1", historyId: "history-a2", toolName: "bash" }), + assistant("final-2", { historyId: "history-a2" }), + ]; + + const infos = computeWorkBundleInfos(messages); + + expect(infos[0]).toBeUndefined(); + expect(infos[1]).toBeUndefined(); + expect(infos[2]).toBeUndefined(); + expect(infos[3]).toMatchObject({ key: "work:bash-1", position: "head", headIndex: 3 }); + expect(infos[4]).toMatchObject({ key: "work:bash-1", position: "member" }); + expect(infos[5]).toMatchObject({ key: "work:bash-1", position: "final" }); + }); + test("does not merge partial text-only interruptions across the next user prompt", () => { const messages = [ user("u1"), diff --git a/src/browser/utils/messages/transcriptRenderProjection.ts b/src/browser/utils/messages/transcriptRenderProjection.ts index cacd0ae59e..d78cc65178 100644 --- a/src/browser/utils/messages/transcriptRenderProjection.ts +++ b/src/browser/utils/messages/transcriptRenderProjection.ts @@ -327,9 +327,7 @@ function findWorkBundleFinalIndex( if (isWorkBundleOperationalMessage(message)) { sawOperationalMessage = true; } - if (canContinueWorkBundleAcrossConversation(message)) { - canCrossVisibleConversation = true; - } + canCrossVisibleConversation = canContinueWorkBundleAcrossConversation(message); if (message.type !== "assistant" || message.isPartial) { continue; From f57b8a72e5707b9665aeb531aea1eb54596e5929 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 29 May 2026 08:30:19 +0000 Subject: [PATCH 6/9] =?UTF-8?q?=F0=9F=A4=96=20fix:=20require=20work=20befo?= =?UTF-8?q?re=20finalizing=20bundles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only record a final assistant row after an operational row has been seen so pre-tool assistant text cannot become an empty work bundle. --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$61.40`_ --- .../messages/transcriptRenderProjection.test.ts | 12 ++++++++++++ .../utils/messages/transcriptRenderProjection.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/browser/utils/messages/transcriptRenderProjection.test.ts b/src/browser/utils/messages/transcriptRenderProjection.test.ts index 4f26a6a0dd..0eb424c90a 100644 --- a/src/browser/utils/messages/transcriptRenderProjection.test.ts +++ b/src/browser/utils/messages/transcriptRenderProjection.test.ts @@ -300,6 +300,18 @@ describe("work bundle coalescing", () => { expect(infos[3]).toMatchObject({ key: "work:bash-1", position: "final" }); }); + test("does not finalize work bundles before the first operation", () => { + const messages = [ + user("u1"), + assistant("draft-1", { historyId: "history-a1", content: "I'll inspect first." }), + tool({ id: "read-1", historyId: "history-a1" }), + ]; + + const infos = computeWorkBundleInfos(messages); + + expect(infos.every((info) => info === undefined)).toBe(true); + }); + test("does not merge mixed partial tool and text interruptions across the next user prompt", () => { const messages = [ user("u1"), diff --git a/src/browser/utils/messages/transcriptRenderProjection.ts b/src/browser/utils/messages/transcriptRenderProjection.ts index d78cc65178..313b81d13c 100644 --- a/src/browser/utils/messages/transcriptRenderProjection.ts +++ b/src/browser/utils/messages/transcriptRenderProjection.ts @@ -329,7 +329,7 @@ function findWorkBundleFinalIndex( } canCrossVisibleConversation = canContinueWorkBundleAcrossConversation(message); - if (message.type !== "assistant" || message.isPartial) { + if (message.type !== "assistant" || message.isPartial || !sawOperationalMessage) { continue; } From 5d2867906b253234357da5eda5150c93a7f3a8c3 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 29 May 2026 09:07:53 +0000 Subject: [PATCH 7/9] =?UTF-8?q?=F0=9F=A4=96=20fix:=20flatten=20hyper=20wor?= =?UTF-8?q?k=20bundle=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Always render the hyper-density work header for active and settled work, use a Working label for active work, and remove inner work-bundle indentation so nested content aligns with the header divider. --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$74.28`_ --- src/browser/components/ChatPane/ChatPane.tsx | 23 ++++++------- .../Messages/WorkBundleMessage.test.tsx | 13 +++++++ .../features/Messages/WorkBundleMessage.tsx | 6 +++- .../transcriptRenderProjection.test.ts | 19 ++++++++--- .../messages/transcriptRenderProjection.ts | 34 ++++++++++++++----- 5 files changed, 69 insertions(+), 26 deletions(-) diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index bf448384e8..107bfde516 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -1321,23 +1321,20 @@ const ChatPaneContent: React.FC = (props) => { key={`${workspaceId}:${workBundle.key}:${entry.message.id}`} > {renderNestedBundle && nestedOperationalBundle && ( -
- - setOperationalBundleExpanded( - nestedOperationalBundle.key, - !isNestedExpanded - ) - } - /> -
+ + setOperationalBundleExpanded( + nestedOperationalBundle.key, + !isNestedExpanded + ) + } + /> )} {renderNestedMessage && renderMessageAtIndex(entry.message, entry.originalIndex, { key: `${workspaceId}:${workBundle.key}:${entry.message.id}:message`, - className: nestedOperationalBundle ? "ml-8" : "ml-4", })}
); diff --git a/src/browser/features/Messages/WorkBundleMessage.test.tsx b/src/browser/features/Messages/WorkBundleMessage.test.tsx index 0ebcb84917..2fb6dca39a 100644 --- a/src/browser/features/Messages/WorkBundleMessage.test.tsx +++ b/src/browser/features/Messages/WorkBundleMessage.test.tsx @@ -15,6 +15,7 @@ const item: WorkBundleInfo = { headIndex: 1, entries: [], durationMs: 180_000, + state: "settled", defaultExpanded: false, }; @@ -46,6 +47,18 @@ describe("WorkBundleMessage", () => { expect(view.getByRole("button", { expanded: true })).toBeDefined(); }); + test("renders active working label", () => { + const view = render( + undefined} + /> + ); + + expect(view.getByText("Working...")).toBeDefined(); + }); + test("renders fallback label without duration", () => { const view = render( { expect(infos[5]).toMatchObject({ key: "work:bash-1", position: "final" }); }); - test("leaves active work visible", () => { + test("wraps active work in an expanded working bundle", () => { const messages = [ - reasoning({ id: "think-1", historyId: "history-a1" }), + user("u1"), + assistant("draft-1", { historyId: "history-a1" }), tool({ id: "read-1", historyId: "history-a1", status: "executing" }), - assistant("final-1", { historyId: "history-a1" }), ]; const infos = computeWorkBundleInfos(messages); - expect(infos.every((info) => info === undefined)).toBe(true); + expect(infos[0]).toMatchObject({ + key: "work:draft-1", + position: "head", + state: "active", + defaultExpanded: true, + entries: [ + { message: messages[1], originalIndex: 1 }, + { message: messages[2], originalIndex: 2 }, + ], + }); + expect(infos[1]).toMatchObject({ key: "work:draft-1", position: "member" }); + expect(infos[2]).toMatchObject({ key: "work:draft-1", position: "member" }); }); test("collapses non-success tools before a final assistant row", () => { diff --git a/src/browser/utils/messages/transcriptRenderProjection.ts b/src/browser/utils/messages/transcriptRenderProjection.ts index 313b81d13c..9d59fe7fe1 100644 --- a/src/browser/utils/messages/transcriptRenderProjection.ts +++ b/src/browser/utils/messages/transcriptRenderProjection.ts @@ -36,6 +36,7 @@ export interface WorkBundleInfo { headIndex: number; entries: readonly WorkBundleEntry[]; durationMs?: number; + state: "active" | "settled"; defaultExpanded: boolean; } @@ -128,12 +129,15 @@ export function computeWorkBundleInfos( } const finalMessage = messages[span.finalIndex]; - if (finalMessage?.type !== "assistant") { + if (span.state === "settled" && finalMessage?.type !== "assistant") { index = span.finalIndex + 1; continue; } - if (entries.some((entry) => isActiveWorkBundleMessage(entry.message))) { + if ( + span.state === "settled" && + entries.some((entry) => isActiveWorkBundleMessage(entry.message)) + ) { index = span.finalIndex + 1; continue; } @@ -153,7 +157,8 @@ export function computeWorkBundleInfos( headIndex: span.headIndex, entries: frozenEntries, durationMs: computeWorkBundleDurationMs(frozenEntries, finalMessage), - defaultExpanded: false, + state: span.state, + defaultExpanded: span.state === "active", }; infos[span.headIndex] = info; @@ -163,7 +168,7 @@ export function computeWorkBundleInfos( position: entry.originalIndex === span.headIndex ? "head" - : entry.originalIndex === span.finalIndex + : span.state === "settled" && entry.originalIndex === span.finalIndex ? "final" : "member", }; @@ -248,6 +253,7 @@ interface WorkBundleSpan { headIndex: number; firstEntryIndex: number; finalIndex: number; + state: "active" | "settled"; } function findWorkBundleSpan( @@ -269,14 +275,14 @@ function findWorkBundleSpan( return { headIndex: message?.type === "user" ? index : firstEntryIndex, firstEntryIndex, - finalIndex, + ...finalIndex, }; } function findWorkBundleFinalIndex( messages: DisplayedMessage[], startIndex: number -): number | undefined { +): Pick | undefined { const firstHistoryId = getWorkBundleAgentHistoryId(messages[startIndex]); if (firstHistoryId === undefined) { return undefined; @@ -286,6 +292,8 @@ function findWorkBundleFinalIndex( let canCrossVisibleConversation = false; let sawVisibleConversationSinceLastAgent = false; let sawOperationalMessage = false; + let sawActiveMessage = false; + let lastAgentIndex = startIndex; let finalIndex: number | undefined; for (let index = startIndex; index < messages.length; index++) { @@ -323,6 +331,10 @@ function findWorkBundleFinalIndex( historyIds.add(messageHistoryId); } sawVisibleConversationSinceLastAgent = false; + lastAgentIndex = index; + if (isActiveWorkBundleMessage(message)) { + sawActiveMessage = true; + } if (isWorkBundleOperationalMessage(message)) { sawOperationalMessage = true; @@ -337,11 +349,17 @@ function findWorkBundleFinalIndex( canCrossVisibleConversation = false; } - if (!sawOperationalMessage || finalIndex === undefined || finalIndex < startIndex) { + if (!sawOperationalMessage) { return undefined; } + if (finalIndex !== undefined && finalIndex >= startIndex) { + return { finalIndex, state: "settled" }; + } + if (sawActiveMessage && lastAgentIndex >= startIndex) { + return { finalIndex: lastAgentIndex, state: "active" }; + } - return finalIndex; + return undefined; } function isSideQuestionStart(messages: DisplayedMessage[], index: number): boolean { From f12fc1af0a1c686d1b100429563917b3593ed70d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 29 May 2026 09:22:06 +0000 Subject: [PATCH 8/9] =?UTF-8?q?=F0=9F=A4=96=20fix:=20show=20active=20work?= =?UTF-8?q?=20elapsed=20duration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show elapsed duration in active hyper-density work headers and keep post-prompt work boundaries from using pre-prompt partial operations. --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$81.55`_ --- .../Messages/TranscriptDensity.stories.tsx | 5 ++- .../Messages/WorkBundleMessage.test.tsx | 13 +++++-- .../features/Messages/WorkBundleMessage.tsx | 37 +++++++++++++++---- .../transcriptRenderProjection.test.ts | 13 +++++++ .../messages/transcriptRenderProjection.ts | 13 ++++++- 5 files changed, 67 insertions(+), 14 deletions(-) diff --git a/src/browser/features/Messages/TranscriptDensity.stories.tsx b/src/browser/features/Messages/TranscriptDensity.stories.tsx index 8f374dde0e..103624b2c9 100644 --- a/src/browser/features/Messages/TranscriptDensity.stories.tsx +++ b/src/browser/features/Messages/TranscriptDensity.stories.tsx @@ -148,15 +148,16 @@ export const HyperActiveExpandedBundle: AppStory = { setup={() => { collapseLeftSidebar(); setDensity("hyper"); + const activeStartedAt = Date.now() - 40_000; return setupSimpleChatStory({ messages: [ createUserMessage("active-user-1", "Inspect the repository", { historySequence: 1, - timestamp: STABLE_TIMESTAMP - 40_000, + timestamp: activeStartedAt - 5_000, }), createAssistantMessage("active-assistant-1", "I'll read the key files now.", { historySequence: 2, - timestamp: STABLE_TIMESTAMP - 35_000, + timestamp: activeStartedAt, toolCalls: [ createPendingTool("active-read-1", "file_read", { path: "src/App.tsx" }), createPendingTool("active-search-1", "web_search", { diff --git a/src/browser/features/Messages/WorkBundleMessage.test.tsx b/src/browser/features/Messages/WorkBundleMessage.test.tsx index 2fb6dca39a..17a3d11512 100644 --- a/src/browser/features/Messages/WorkBundleMessage.test.tsx +++ b/src/browser/features/Messages/WorkBundleMessage.test.tsx @@ -14,6 +14,7 @@ const item: WorkBundleInfo = { position: "head", headIndex: 1, entries: [], + startedAtMs: 0, durationMs: 180_000, state: "settled", defaultExpanded: false, @@ -47,16 +48,22 @@ describe("WorkBundleMessage", () => { expect(view.getByRole("button", { expanded: true })).toBeDefined(); }); - test("renders active working label", () => { + test("renders active working label with elapsed duration", () => { const view = render( undefined} /> ); - expect(view.getByText("Working...")).toBeDefined(); + expect(view.getByText(/Working for \d+s\.\.\./)).toBeDefined(); }); test("renders fallback label without duration", () => { diff --git a/src/browser/features/Messages/WorkBundleMessage.tsx b/src/browser/features/Messages/WorkBundleMessage.tsx index 9ea184417e..1e33492ed9 100644 --- a/src/browser/features/Messages/WorkBundleMessage.tsx +++ b/src/browser/features/Messages/WorkBundleMessage.tsx @@ -4,6 +4,22 @@ import { cn } from "@/common/lib/utils"; import { ExpandIcon } from "@/browser/features/Tools/Shared/ToolPrimitives"; import type { WorkBundleInfo } from "@/browser/utils/messages/transcriptRenderProjection"; +function useActiveNowMs(isActive: boolean): number { + const [nowMs, setNowMs] = React.useState(() => Date.now()); + + React.useEffect(() => { + if (!isActive) { + return; + } + + setNowMs(Date.now()); + const intervalId = window.setInterval(() => setNowMs(Date.now()), 1_000); + return () => window.clearInterval(intervalId); + }, [isActive]); + + return nowMs; +} + interface WorkBundleMessageProps { item: WorkBundleInfo; expanded: boolean; @@ -11,13 +27,20 @@ interface WorkBundleMessageProps { } export function WorkBundleMessage(props: WorkBundleMessageProps): React.ReactElement { - const duration = props.item.durationMs; - const label = - props.item.state === "active" + const isActive = props.item.state === "active"; + const nowMs = useActiveNowMs(isActive && props.item.startedAtMs !== undefined); + const duration = isActive + ? props.item.startedAtMs === undefined + ? undefined + : Math.max(0, nowMs - props.item.startedAtMs) + : props.item.durationMs; + const label = isActive + ? duration === undefined ? "Working..." - : duration === undefined - ? "Worked" - : `Worked for ${formatDuration(duration, "precise")}`; + : `Working for ${formatDuration(duration, "precise")}...` + : duration === undefined + ? "Worked" + : `Worked for ${formatDuration(duration, "precise")}`; return (