From eaea34aba504d57c56513c5f4ca1d456387f39e3 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 28 May 2026 10:56:10 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20hyper=20transcript?= =?UTF-8?q?=20density?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebase the hyper transcript density work onto latest origin/main and preserve the render-time work/operational bundle coalescing path without reintroducing the reverted file-tool coalescing implementation. --- Generated with mux • Model: openai:gpt-5.5 • Thinking: xhigh • Cost: $251.34 --- scripts/wait_pr_coder_agents_review.sh | 2 +- src/browser/components/ChatPane/ChatPane.tsx | 279 ++++++++-- .../OperationalBundleMessage.test.tsx | 80 +++ .../Messages/OperationalBundleMessage.tsx | 42 ++ .../Messages/TranscriptDensity.stories.tsx | 230 ++++++++ .../Messages/WorkBundleMessage.test.tsx | 60 +++ .../features/Messages/WorkBundleMessage.tsx | 35 ++ .../Settings/Sections/GeneralSection.tsx | 38 ++ .../Tools/Shared/HookOutputDisplay.tsx | 34 +- src/browser/hooks/useTranscriptDensity.ts | 17 + .../utils/messages/displayedMessageBuilder.ts | 7 +- .../transcriptRenderProjection.test.ts | 505 ++++++++++++++++++ .../messages/transcriptRenderProjection.ts | 434 +++++++++++++++ src/common/constants/storage.test.ts | 7 + src/common/constants/storage.ts | 20 + src/common/utils/tools/hookOutput.ts | 24 + tests/ui/chat/transcriptDensity.test.tsx | 87 +++ 17 files changed, 1806 insertions(+), 95 deletions(-) create mode 100644 src/browser/features/Messages/OperationalBundleMessage.test.tsx create mode 100644 src/browser/features/Messages/OperationalBundleMessage.tsx create mode 100644 src/browser/features/Messages/TranscriptDensity.stories.tsx create mode 100644 src/browser/features/Messages/WorkBundleMessage.test.tsx create mode 100644 src/browser/features/Messages/WorkBundleMessage.tsx create mode 100644 src/browser/hooks/useTranscriptDensity.ts create mode 100644 src/browser/utils/messages/transcriptRenderProjection.test.ts create mode 100644 src/browser/utils/messages/transcriptRenderProjection.ts create mode 100644 src/common/utils/tools/hookOutput.ts create mode 100644 tests/ui/chat/transcriptDensity.test.tsx diff --git a/scripts/wait_pr_coder_agents_review.sh b/scripts/wait_pr_coder_agents_review.sh index 3abf0c7887..5dc7ec3f77 100755 --- a/scripts/wait_pr_coder_agents_review.sh +++ b/scripts/wait_pr_coder_agents_review.sh @@ -36,7 +36,7 @@ fi REQUEST_COMMAND="/coder-agents-review" # Match both the app slug and GitHub's bot-login form. BOT_LOGIN_REGEX="${CODER_AGENTS_REVIEW_BOT_LOGIN_REGEX:-^coder-agents-review(\[bot\])?$}" -CODER_AGENTS_BOT_APPROVAL_REGEX="^(no (issues|problems)( found)?[.]?|no major issues( found)?[.]?|didn.t find (any )?(major )?(issues|problems)[.]?|review complete(d)?[.]?|zero open findings[.]?|zero open findings across .* (coder-agents-review |review )?complete[.]?|Round [0-9]+[.] zero open findings[.] (coder-agents-review |review )?complete[.]?|Round [0-9]+[.] zero open findings across .* (coder-agents-review |review )?complete[.]?)$" +CODER_AGENTS_BOT_APPROVAL_REGEX="^(no (issues|problems)( found)?[.]?|no major issues( found)?[.]?|didn.t find (any )?(major )?(issues|problems)[.]?|review complete(d)?[.]?|zero open findings[.]?|zero open findings across .* (coder-agents-review |review )?complete[.]?|Round [0-9]+[.] zero open findings[.] (coder-agents-review |review )?complete[.]?|Round [0-9]+[.] zero open findings across .* (coder-agents-review |review )?complete[.]?|All [0-9]+ findings.*No open findings[.]?|No new code since .*All [0-9]+ findings remain resolved[.].*Nothing to review[.].*)$" CODER_AGENTS_BOT_NEGATIVE_BEFORE_APPROVAL_REGEX="^(Round [0-9]+ is blocked|Review failed|Failed to review|Unable to review|Cannot review|Could not review|Review timed out|Request timed out|Review cancelled|Request cancelled)" CODER_AGENTS_BOT_PROGRESS_REGEX="^(queued|started|running|in progress|reviewing|will review)[[:space:][:punct:]]*$" POLL_INTERVAL_SECS=30 diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index df834a58de..1714cbb19c 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -12,6 +12,8 @@ import { MessageListProvider } from "@/browser/features/Messages/MessageListCont import { cn } from "@/common/lib/utils"; import { ChatInstructionsChatDecoration } from "@/browser/components/InstructionsTab/AdditionalSystemContextScratchpad"; import { MessageRenderer } from "@/browser/features/Messages/MessageRenderer"; +import { WorkBundleMessage } from "@/browser/features/Messages/WorkBundleMessage"; +import { OperationalBundleMessage } from "@/browser/features/Messages/OperationalBundleMessage"; import { MarkdownRenderer } from "@/browser/features/Messages/MarkdownRenderer"; import { useTranscriptContextMenu } from "@/browser/features/Messages/useTranscriptContextMenu"; import type { UserMessageNavigation } from "@/browser/features/Messages/UserMessage"; @@ -79,6 +81,7 @@ import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; import type { TerminalSessionCreateOptions } from "@/browser/utils/terminal"; import { useAPI } from "@/browser/contexts/API"; import { useChatTranscriptFullWidth } from "@/browser/hooks/useChatTranscriptFullWidth"; +import { useTranscriptDensity } from "@/browser/hooks/useTranscriptDensity"; import { useReviews } from "@/browser/hooks/useReviews"; import { ReviewsBanner } from "../ReviewsBanner/ReviewsBanner"; import type { ReviewNoteData } from "@/common/types/review"; @@ -100,6 +103,10 @@ import { isSideQuestionScrollHoldBottomClamped, type SideQuestionScrollHoldState, } from "./sideQuestionScrollHold"; +import { + computeOperationalBundleInfos, + computeWorkBundleInfos, +} from "@/browser/utils/messages/transcriptRenderProjection"; import { recordSyntheticReactRenderSample } from "@/browser/utils/perf/reactProfileCollector"; // Perf e2e runs load the production bundle where React's onRender profiler callbacks may not @@ -264,6 +271,7 @@ const ChatPaneContent: React.FC = (props) => { } = props; const workspaceState = useWorkspaceState(workspaceId); const chatTranscriptFullWidth = useChatTranscriptFullWidth(); + const [transcriptDensity] = useTranscriptDensity(); const { api } = useAPI(); const { workspaceMetadata } = useWorkspaceContext(); const storeRaw = useWorkspaceStoreRaw(); @@ -343,6 +351,14 @@ const ChatPaneContent: React.FC = (props) => { // Track which bash_output groups are expanded (keyed by first message ID) const [expandedBashGroups, setExpandedBashGroups] = useState>(new Set()); + const [workBundleExpansionOverrides, setWorkBundleExpansionOverrides] = useState< + Map + >(new Map()); + + const [operationalBundleExpansionOverrides, setOperationalBundleExpansionOverrides] = useState< + Map + >(new Map()); + // Extract state from workspace state // Keep a ref to the latest workspace state so event handlers (passed to memoized children) @@ -434,6 +450,21 @@ const ChatPaneContent: React.FC = (props) => { [deferredMessages] ); + const workBundleInfos = useMemo( + () => (transcriptDensity === "hyper" ? computeWorkBundleInfos(deferredMessages) : undefined), + [deferredMessages, transcriptDensity] + ); + + const operationalBundleInfos = useMemo( + () => + transcriptDensity === "hyper" + ? computeOperationalBundleInfos(deferredMessages, { + isTurnActive: isStreamStarting || canInterrupt, + }) + : undefined, + [canInterrupt, deferredMessages, isStreamStarting, transcriptDensity] + ); + const autoCompactionResult = useMemo( () => checkAutoCompaction( @@ -686,6 +717,8 @@ const ChatPaneContent: React.FC = (props) => { useEffect(() => { setEditingState({ workspaceId, message: undefined }); setExpandedBashGroups(new Set()); + setWorkBundleExpansionOverrides(new Map()); + setOperationalBundleExpansionOverrides(new Map()); }, [workspaceId]); const handleChatInputReady = useCallback((api: ChatInputAPI) => { @@ -1039,6 +1072,90 @@ const ChatPaneContent: React.FC = (props) => { } } + const setWorkBundleExpanded = (key: string, expanded: boolean) => { + setWorkBundleExpansionOverrides((prev) => new Map(prev).set(key, expanded)); + }; + + const setOperationalBundleExpanded = (key: string, expanded: boolean) => { + setOperationalBundleExpansionOverrides((prev) => new Map(prev).set(key, expanded)); + }; + + const toggleBashOutputGroup = (groupKey: string) => { + setExpandedBashGroups((prev) => { + const next = new Set(prev); + if (next.has(groupKey)) { + next.delete(groupKey); + } else { + next.add(groupKey); + } + return next; + }); + }; + + const renderMessageAtIndex = ( + message: DisplayedMessage, + index: number, + options: { key: string; className?: string } + ): React.ReactNode => { + const bashOutputGroup = bashOutputGroupInfos[index]; + const groupKey = bashOutputGroup ? deferredMessages[bashOutputGroup.firstIndex]?.id : undefined; + const isGroupExpanded = groupKey ? expandedBashGroups.has(groupKey) : false; + + if (bashOutputGroup?.position === "middle" && !isGroupExpanded) { + return null; + } + + const isAtCutoff = + editCutoffHistoryId !== undefined && + message.type !== "history-hidden" && + message.type !== "workspace-init" && + message.type !== "compaction-boundary" && + message.historyId === editCutoffHistoryId; + + const taskReportLinkingForMessage = + message.type === "tool" && (message.toolName === "task" || message.toolName === "task_await") + ? taskReportLinking + : undefined; + + const messageNode = ( + + ); + + return ( + + {options.className ?
{messageNode}
: messageNode} + {bashOutputGroup?.position === "first" && groupKey && ( + toggleBashOutputGroup(groupKey)} + /> + )} + {isAtCutoff && } + {interruptedBarrierMessageIds.has(message.id) && } +
+ ); + }; + return ( <> @@ -1127,74 +1244,122 @@ const ChatPaneContent: React.FC = (props) => { )} {deferredMessages.map((msg, index) => { - const bashOutputGroup = bashOutputGroupInfos[index]; - - // For bash_output groups, use first message ID as expansion key - const groupKey = bashOutputGroup - ? deferredMessages[bashOutputGroup.firstIndex]?.id + const workBundle = workBundleInfos?.[index]; + const workBundleOverride = workBundle + ? workBundleExpansionOverrides.get(workBundle.key) : undefined; - const isGroupExpanded = groupKey ? expandedBashGroups.has(groupKey) : false; + const isWorkBundleExpanded = workBundle + ? (workBundleOverride ?? workBundle.defaultExpanded) + : false; - // Skip rendering middle items in a bash_output group (unless expanded) - if (bashOutputGroup?.position === "middle" && !isGroupExpanded) { + if (workBundle?.position === "member") { return null; } - const isAtCutoff = - editCutoffHistoryId !== undefined && - msg.type !== "history-hidden" && - msg.type !== "workspace-init" && - msg.type !== "compaction-boundary" && - msg.historyId === editCutoffHistoryId; + const renderWorkBundle = workBundle?.position === "head"; + const renderMessageAfterWorkBundle = !renderWorkBundle; + const operationalBundle = workBundle + ? undefined + : operationalBundleInfos?.[index]; + const operationalBundleOverride = operationalBundle + ? operationalBundleExpansionOverrides.get(operationalBundle.key) + : undefined; + const isOperationalBundleExpanded = operationalBundle + ? (operationalBundleOverride ?? operationalBundle.defaultExpanded) + : false; + + if ( + operationalBundle?.position === "member" && + !isOperationalBundleExpanded + ) { + return null; + } - const taskReportLinkingForMessage = - msg.type === "tool" && - (msg.toolName === "task" || msg.toolName === "task_await") - ? taskReportLinking - : undefined; + const renderOperationalBundle = operationalBundle?.position === "head"; + const renderMessageAfterOperationalBundle = + renderMessageAfterWorkBundle && + (!renderOperationalBundle || isOperationalBundleExpanded); return ( - - {/* Show collapsed indicator after the first item in a bash_output group */} - {bashOutputGroup?.position === "first" && groupKey && ( - { - setExpandedBashGroups((prev) => { - const next = new Set(prev); - if (next.has(groupKey)) { - next.delete(groupKey); - } else { - next.add(groupKey); - } - return next; - }); - }} + {renderWorkBundle && workBundle && ( + + setWorkBundleExpanded(workBundle.key, !isWorkBundleExpanded) + } + /> + )} + {renderWorkBundle && + workBundle && + isWorkBundleExpanded && + workBundle.entries.map((entry) => { + const nestedOperationalBundle = + operationalBundleInfos?.[entry.originalIndex]; + const nestedOverride = nestedOperationalBundle + ? operationalBundleExpansionOverrides.get( + nestedOperationalBundle.key + ) + : undefined; + const isNestedExpanded = nestedOperationalBundle + ? (nestedOverride ?? nestedOperationalBundle.defaultExpanded) + : false; + + if ( + nestedOperationalBundle?.position === "member" && + !isNestedExpanded + ) { + return null; + } + + const renderNestedBundle = + nestedOperationalBundle?.position === "head"; + const renderNestedMessage = !renderNestedBundle || isNestedExpanded; + + return ( + + {renderNestedBundle && nestedOperationalBundle && ( +
+ + setOperationalBundleExpanded( + nestedOperationalBundle.key, + !isNestedExpanded + ) + } + /> +
+ )} + {renderNestedMessage && + renderMessageAtIndex(entry.message, entry.originalIndex, { + key: `${workspaceId}:${workBundle.key}:${entry.message.id}:message`, + className: nestedOperationalBundle ? "ml-8" : "ml-4", + })} +
+ ); + })} + {renderOperationalBundle && operationalBundle && ( + + setOperationalBundleExpanded( + operationalBundle.key, + !isOperationalBundleExpanded + ) + } /> )} - {isAtCutoff && } - {interruptedBarrierMessageIds.has(msg.id) && } + {renderMessageAfterOperationalBundle && + renderMessageAtIndex(msg, index, { + key: `${workspaceId}:${msg.id}:message`, + className: operationalBundle ? "ml-4" : undefined, + })}
); })} diff --git a/src/browser/features/Messages/OperationalBundleMessage.test.tsx b/src/browser/features/Messages/OperationalBundleMessage.test.tsx new file mode 100644 index 0000000000..8ebbbe90fe --- /dev/null +++ b/src/browser/features/Messages/OperationalBundleMessage.test.tsx @@ -0,0 +1,80 @@ +import type * as React from "react"; +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { GlobalWindow } from "happy-dom"; +import type { DisplayedMessage } from "@/common/types/message"; +import { computeOperationalBundleInfos } from "@/browser/utils/messages/transcriptRenderProjection"; +import { OperationalBundleMessage } from "./OperationalBundleMessage"; + +void mock.module("lucide-react", () => ({ + ChevronRight: (props: React.SVGProps) => , +})); + +function tool(id: string): DisplayedMessage & { type: "tool" } { + return { + type: "tool", + id, + historyId: `history-${id}`, + toolCallId: `call-${id}`, + toolName: "file_read", + args: {}, + status: "completed", + isPartial: false, + historySequence: 1, + }; +} + +const noop = () => undefined; + +describe("OperationalBundleMessage", () => { + beforeEach(() => { + globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis; + globalThis.document = globalThis.window.document; + }); + + afterEach(() => { + cleanup(); + globalThis.window = undefined as unknown as Window & typeof globalThis; + globalThis.document = undefined as unknown as Document; + }); + + test("renders summary and toggles expansion", () => { + const item = computeOperationalBundleInfos([tool("read-1"), tool("read-2")], { + isTurnActive: false, + })[0]!; + + let expanded = false; + const onToggle = () => { + expanded = !expanded; + }; + const view = render( + + ); + + expect(view.getByRole("button", { expanded: false })).toBeDefined(); + expect(view.getByText("Ran 2 operations")).toBeDefined(); + + fireEvent.click(view.getByRole("button")); + view.rerender(); + + expect(view.getByRole("button", { expanded: true })).toBeDefined(); + }); + + test("renders active bundle state", () => { + const item = computeOperationalBundleInfos([{ ...tool("read-1"), status: "executing" }], { + isTurnActive: true, + })[0]!; + + const view = render(); + + expect(view.getByText("Running 1 operation")).toBeDefined(); + }); + + test("suppresses redundant singleton details", () => { + const item = computeOperationalBundleInfos([tool("read-1")], { isTurnActive: false })[0]!; + + const view = render(); + + expect(view.getByRole("button").textContent).toBe("▶Read 1 file"); + }); +}); diff --git a/src/browser/features/Messages/OperationalBundleMessage.tsx b/src/browser/features/Messages/OperationalBundleMessage.tsx new file mode 100644 index 0000000000..105010b0dd --- /dev/null +++ b/src/browser/features/Messages/OperationalBundleMessage.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { cn } from "@/common/lib/utils"; +import { ExpandIcon, ToolContainer } from "@/browser/features/Tools/Shared/ToolPrimitives"; +import type { OperationalBundleInfo } from "@/browser/utils/messages/transcriptRenderProjection"; + +interface OperationalBundleMessageProps { + item: OperationalBundleInfo; + expanded: boolean; + onToggle: () => void; +} + +export function OperationalBundleMessage(props: OperationalBundleMessageProps): React.ReactElement { + const title = + props.item.state === "active" + ? `Running ${props.item.entries.length.toLocaleString()} ${ + props.item.entries.length === 1 ? "operation" : "operations" + }` + : props.item.summary.title; + const details = props.item.entries.length === 1 ? "" : props.item.summary.details; + + return ( + + + + ); +} diff --git a/src/browser/features/Messages/TranscriptDensity.stories.tsx b/src/browser/features/Messages/TranscriptDensity.stories.tsx new file mode 100644 index 0000000000..a5cb139106 --- /dev/null +++ b/src/browser/features/Messages/TranscriptDensity.stories.tsx @@ -0,0 +1,230 @@ +import { userEvent, waitFor } from "@storybook/test"; +import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import type { AppStory } from "@/browser/stories/meta.js"; +import { appMeta, AppWithMocks, CHROMATIC_SMOKE_MODES } from "@/browser/stories/meta.js"; +import { setupSimpleChatStory } from "@/browser/stories/helpers/chatSetup"; +import { collapseLeftSidebar } from "@/browser/stories/helpers/uiState"; +import { createAssistantMessage, createUserMessage } from "@/browser/stories/mocks/messages"; +import { + createAgentSkillReadTool, + createBashTool, + createFileEditTool, + createFileReadTool, + createGenericTool, + createPendingTool, + createWebSearchTool, +} from "@/browser/stories/mocks/tools"; +import { STABLE_TIMESTAMP } from "@/browser/stories/mocks/workspaces"; +import { TRANSCRIPT_DENSITY_KEY, type TranscriptDensity } from "@/common/constants/storage"; + +const meta = { ...appMeta, title: "App/Chat/Transcript Density" }; +export default meta; + +function setDensity(density: TranscriptDensity): void { + updatePersistedState(TRANSCRIPT_DENSITY_KEY, density); +} + +function setupTranscriptDensityStory(density: TranscriptDensity) { + collapseLeftSidebar(); + setDensity(density); + return setupSimpleChatStory({ + messages: [ + createUserMessage("density-user-1", "Audit the auth module and make the smallest safe fix", { + historySequence: 1, + timestamp: STABLE_TIMESTAMP - 60_000, + }), + createAssistantMessage("density-assistant-1", "I'll gather context first.", { + historySequence: 2, + timestamp: STABLE_TIMESTAMP - 55_000, + reasoning: + "Need to inspect auth code, search related validation guidance, and then make a minimal patch.", + toolCalls: [ + createFileReadTool("density-read-1", "src/auth.ts", "export function verify() {}"), + 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, + }, + ], + }), + ], + }); +} + +function getRequiredStoryElement(canvasElement: HTMLElement, testId: string): HTMLElement { + const element = canvasElement.querySelector(`[data-testid="${testId}"]`); + if (!(element instanceof HTMLElement)) { + throw new Error(`Expected ${testId} to be rendered`); + } + return element; +} + +export const NormalNoisyTranscript: AppStory = { + parameters: { chromatic: { modes: CHROMATIC_SMOKE_MODES } }, + render: () => setupTranscriptDensityStory("normal")} />, +}; + +export const HyperCollapsedBundles: AppStory = { + parameters: { chromatic: { modes: CHROMATIC_SMOKE_MODES } }, + render: () => setupTranscriptDensityStory("hyper")} />, +}; + +export const HyperExpandedBundle: AppStory = { + // Chromatic executes this play function for the visual snapshot, while the + // app-level UI test covers expansion behavior without Storybook's manager-page timing. + tags: ["!test"], + render: () => setupTranscriptDensityStory("hyper")} />, + play: async ({ canvasElement }) => { + const workBundle = await waitFor(() => getRequiredStoryElement(canvasElement, "work-bundle"), { + timeout: 15_000, + }); + + await userEvent.click(workBundle); + + await waitFor( + () => { + if (workBundle.getAttribute("aria-expanded") !== "true") { + throw new Error("Expected HyperExpandedBundle work bundle to be expanded"); + } + }, + { timeout: 15_000 } + ); + await waitFor(() => getRequiredStoryElement(canvasElement, "operational-bundle"), { + timeout: 15_000, + }); + }, +}; + +export const HyperActiveExpandedBundle: AppStory = { + render: () => ( + { + collapseLeftSidebar(); + setDensity("hyper"); + return setupSimpleChatStory({ + messages: [ + createUserMessage("active-user-1", "Inspect the repository", { + historySequence: 1, + timestamp: STABLE_TIMESTAMP - 40_000, + }), + createAssistantMessage("active-assistant-1", "I'll read the key files now.", { + historySequence: 2, + timestamp: STABLE_TIMESTAMP - 35_000, + toolCalls: [ + createPendingTool("active-read-1", "file_read", { path: "src/App.tsx" }), + createPendingTool("active-search-1", "web_search", { + query: "Mux transcript density", + }), + ], + }), + ], + }); + }} + /> + ), +}; + +export const HyperVisibleCriticalEvents: AppStory = { + render: () => ( + { + collapseLeftSidebar(); + setDensity("hyper"); + return setupSimpleChatStory({ + messages: [ + createUserMessage("critical-user-1", "Make the change and validate it", { + historySequence: 1, + timestamp: STABLE_TIMESTAMP - 40_000, + }), + createAssistantMessage("critical-assistant-1", "I'll inspect, edit, and validate.", { + historySequence: 2, + timestamp: STABLE_TIMESTAMP - 35_000, + toolCalls: [ + createFileReadTool( + "critical-read-1", + "src/config.ts", + "export const enabled = false;" + ), + createFileEditTool( + "critical-edit-1", + "src/config.ts", + "--- src/config.ts\n+++ src/config.ts\n@@ -1 +1 @@\n-export const enabled = false;\n+export const enabled = true;" + ), + createBashTool( + "critical-validation-1", + "make typecheck", + "Type error in src/config.ts", + 1 + ), + createGenericTool( + "critical-question-1", + "ask_user_question", + { question: "Proceed?" }, + { answer: "Yes" } + ), + createGenericTool( + "critical-notify-1", + "notify", + { title: "Validation failed" }, + { success: true } + ), + ], + }), + ], + }); + }} + /> + ), +}; + +export const HyperAllMissSearchBundle: AppStory = { + render: () => ( + { + collapseLeftSidebar(); + setDensity("hyper"); + return setupSimpleChatStory({ + messages: [ + createUserMessage("miss-user-1", "Look for a deprecated helper", { + historySequence: 1, + timestamp: STABLE_TIMESTAMP - 40_000, + }), + createAssistantMessage("miss-assistant-1", "I'll search for that helper.", { + historySequence: 2, + timestamp: STABLE_TIMESTAMP - 35_000, + toolCalls: [createWebSearchTool("miss-search-1", "deprecatedMuxHelper", 0)], + }), + ], + }); + }} + /> + ), +}; diff --git a/src/browser/features/Messages/WorkBundleMessage.test.tsx b/src/browser/features/Messages/WorkBundleMessage.test.tsx new file mode 100644 index 0000000000..0ebcb84917 --- /dev/null +++ b/src/browser/features/Messages/WorkBundleMessage.test.tsx @@ -0,0 +1,60 @@ +import type * as React from "react"; +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { GlobalWindow } from "happy-dom"; +import type { WorkBundleInfo } from "@/browser/utils/messages/transcriptRenderProjection"; +import { WorkBundleMessage } from "./WorkBundleMessage"; + +void mock.module("lucide-react", () => ({ + ChevronRight: (props: React.SVGProps) => , +})); + +const item: WorkBundleInfo = { + key: "work:one", + position: "head", + headIndex: 1, + entries: [], + durationMs: 180_000, + defaultExpanded: false, +}; + +describe("WorkBundleMessage", () => { + beforeEach(() => { + globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis; + globalThis.document = globalThis.window.document; + }); + + afterEach(() => { + cleanup(); + globalThis.window = undefined as unknown as Window & typeof globalThis; + globalThis.document = undefined as unknown as Document; + }); + + test("renders duration and toggles expansion", () => { + let expanded = false; + const onToggle = () => { + expanded = !expanded; + }; + const view = render(); + + expect(view.getByRole("button", { expanded: false })).toBeDefined(); + expect(view.getByText("Worked for 3m 0s")).toBeDefined(); + + fireEvent.click(view.getByRole("button")); + view.rerender(); + + expect(view.getByRole("button", { expanded: true })).toBeDefined(); + }); + + test("renders fallback label without duration", () => { + const view = render( + undefined} + /> + ); + + expect(view.getByText("Worked")).toBeDefined(); + }); +}); diff --git a/src/browser/features/Messages/WorkBundleMessage.tsx b/src/browser/features/Messages/WorkBundleMessage.tsx new file mode 100644 index 0000000000..5cafe22098 --- /dev/null +++ b/src/browser/features/Messages/WorkBundleMessage.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { formatDuration } from "@/common/utils/formatDuration"; +import { cn } from "@/common/lib/utils"; +import { ExpandIcon } from "@/browser/features/Tools/Shared/ToolPrimitives"; +import type { WorkBundleInfo } from "@/browser/utils/messages/transcriptRenderProjection"; + +interface WorkBundleMessageProps { + item: WorkBundleInfo; + expanded: boolean; + onToggle: () => void; +} + +export function WorkBundleMessage(props: WorkBundleMessageProps): React.ReactElement { + const duration = props.item.durationMs; + const label = + duration === undefined ? "Worked" : `Worked for ${formatDuration(duration, "precise")}`; + + return ( + + ); +} diff --git a/src/browser/features/Settings/Sections/GeneralSection.tsx b/src/browser/features/Settings/Sections/GeneralSection.tsx index b5a9d59feb..3a23d62a10 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -10,6 +10,7 @@ import { import { Input } from "@/browser/components/Input/Input"; import { Switch } from "@/browser/components/Switch/Switch"; import { updatePersistedState, usePersistedState } from "@/browser/hooks/usePersistedState"; +import { useTranscriptDensity } from "@/browser/hooks/useTranscriptDensity"; import { useAPI } from "@/browser/contexts/API"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { @@ -22,8 +23,11 @@ import { BASH_COLLAPSED_SUMMARY_MODES, CHAT_TRANSCRIPT_FULL_WIDTH_KEY, DEFAULT_BASH_COLLAPSED_SUMMARY_MODE, + TRANSCRIPT_DENSITIES, normalizeBashCollapsedSummaryMode, + normalizeTranscriptDensity, type BashCollapsedSummaryMode, + type TranscriptDensity, type EditorConfig, type EditorType, type LaunchBehavior, @@ -158,6 +162,14 @@ const BASH_COLLAPSED_SUMMARY_MODE_OPTIONS = BASH_COLLAPSED_SUMMARY_MODES.map((va value, label: BASH_COLLAPSED_SUMMARY_MODE_LABELS[value], })); +const TRANSCRIPT_DENSITY_LABELS: Record = { + normal: "Normal", + hyper: "Hyper", +}; +const TRANSCRIPT_DENSITY_OPTIONS = TRANSCRIPT_DENSITIES.map((value) => ({ + value, + label: TRANSCRIPT_DENSITY_LABELS[value], +})); const ARCHIVE_BEHAVIOR_OPTIONS = [ { value: "keep", label: "Keep running" }, { value: "stop", label: "Stop workspace" }, @@ -187,6 +199,7 @@ export function GeneralSection() { DEFAULT_BASH_COLLAPSED_SUMMARY_MODE ); const bashCollapsedSummaryMode = normalizeBashCollapsedSummaryMode(rawBashCollapsedSummaryMode); + const [transcriptDensity, setTranscriptDensity] = useTranscriptDensity(); const [rawTerminalFontConfig, setTerminalFontConfig] = usePersistedState( TERMINAL_FONT_CONFIG_KEY, DEFAULT_TERMINAL_FONT_CONFIG @@ -572,6 +585,31 @@ export function GeneralSection() { /> +
+
+
Transcript density
+
+ Control how much detail the transcript shows. Hyper collapses completed work into + expandable summaries. +
+
+ +
+
Collapsed bash summaries
diff --git a/src/browser/features/Tools/Shared/HookOutputDisplay.tsx b/src/browser/features/Tools/Shared/HookOutputDisplay.tsx index 436caf7ccd..d21c6b2f04 100644 --- a/src/browser/features/Tools/Shared/HookOutputDisplay.tsx +++ b/src/browser/features/Tools/Shared/HookOutputDisplay.tsx @@ -1,46 +1,16 @@ import React, { useState } from "react"; import { ChevronRight } from "lucide-react"; import { cn } from "@/common/lib/utils"; -import type { WithHookOutput } from "@/common/types/tools"; import { formatDuration } from "@/common/utils/formatDuration"; +export { extractHookDuration, extractHookOutput } from "@/common/utils/tools/hookOutput"; + interface HookOutputDisplayProps { output: string; durationMs?: number; className?: string; } -/** - * Type guard to check if an object has hook_output. - */ -function hasHookOutput(result: unknown): result is WithHookOutput & { hook_output: string } { - return ( - typeof result === "object" && - result !== null && - "hook_output" in result && - typeof (result as WithHookOutput).hook_output === "string" - ); -} - -/** - * Extract hook_output from a tool result object. - * Returns null if no hook output or if the result is not an object with hook_output. - */ -export function extractHookOutput(result: unknown): string | null { - if (!hasHookOutput(result)) return null; - return result.hook_output.length > 0 ? result.hook_output : null; -} - -/** - * Extract hook_duration_ms from a tool result object. - * Returns undefined if no duration or if the result is not an object with hook_duration_ms. - */ -export function extractHookDuration(result: unknown): number | undefined { - if (typeof result !== "object" || result === null) return undefined; - const duration = (result as WithHookOutput).hook_duration_ms; - return typeof duration === "number" && Number.isFinite(duration) ? duration : undefined; -} - /** * Subtle, expandable display for tool hook output. * Only shown when a hook produced output (non-empty). diff --git a/src/browser/hooks/useTranscriptDensity.ts b/src/browser/hooks/useTranscriptDensity.ts new file mode 100644 index 0000000000..0600d4bc7f --- /dev/null +++ b/src/browser/hooks/useTranscriptDensity.ts @@ -0,0 +1,17 @@ +import { usePersistedState } from "@/browser/hooks/usePersistedState"; +import { + DEFAULT_TRANSCRIPT_DENSITY, + normalizeTranscriptDensity, + TRANSCRIPT_DENSITY_KEY, + type TranscriptDensity, +} from "@/common/constants/storage"; + +export function useTranscriptDensity(): [TranscriptDensity, (density: TranscriptDensity) => void] { + const [rawDensity, setRawDensity] = usePersistedState( + TRANSCRIPT_DENSITY_KEY, + DEFAULT_TRANSCRIPT_DENSITY, + { listener: true } + ); + + return [normalizeTranscriptDensity(rawDensity), setRawDensity]; +} diff --git a/src/browser/utils/messages/displayedMessageBuilder.ts b/src/browser/utils/messages/displayedMessageBuilder.ts index 474b72bd15..ba2e4faf85 100644 --- a/src/browser/utils/messages/displayedMessageBuilder.ts +++ b/src/browser/utils/messages/displayedMessageBuilder.ts @@ -7,6 +7,7 @@ import type { } from "@/common/types/message"; import { getCompactionFollowUpContent } from "@/common/types/message"; import type { StreamErrorType } from "@/common/types/errors"; +import { extractHookOutput } from "@/common/utils/tools/hookOutput"; import type { ImageEditToolResult, ImageGenerateToolResult } from "@/common/types/tools"; import { ImageEditToolResultSchema, @@ -41,11 +42,7 @@ function isSuccessfulImageEditResult( } function hasVisibleHookOutput(result: unknown): boolean { - if (typeof result !== "object" || result === null || Array.isArray(result)) { - return false; - } - const hookOutput = (result as Record).hook_output; - return typeof hookOutput === "string" && hookOutput.length > 0; + return extractHookOutput(result) !== null; } function appendGeneratedImageMessage( diff --git a/src/browser/utils/messages/transcriptRenderProjection.test.ts b/src/browser/utils/messages/transcriptRenderProjection.test.ts new file mode 100644 index 0000000000..1d1e3f0494 --- /dev/null +++ b/src/browser/utils/messages/transcriptRenderProjection.test.ts @@ -0,0 +1,505 @@ +import { describe, expect, test } from "bun:test"; +import type { DisplayedMessage } from "@/common/types/message"; +import { + computeOperationalBundleInfos, + computeWorkBundleInfos, + summarizeOperationalBundle, +} from "./transcriptRenderProjection"; + +let nextToolId = 0; + +function tool( + overrides: Partial +): DisplayedMessage & { type: "tool" } { + const id = overrides.id ?? `tool-${++nextToolId}`; + return { + type: "tool", + id, + historyId: overrides.historyId ?? `history-${id}`, + toolCallId: overrides.toolCallId ?? `call-${id}`, + toolName: overrides.toolName ?? "file_read", + args: overrides.args ?? {}, + result: overrides.result, + status: overrides.status ?? "completed", + isPartial: overrides.isPartial ?? false, + historySequence: overrides.historySequence ?? 1, + streamSequence: overrides.streamSequence, + isLastPartOfMessage: overrides.isLastPartOfMessage, + timestamp: overrides.timestamp, + nestedCalls: overrides.nestedCalls, + }; +} + +function reasoning( + overrides: Partial = {} +): DisplayedMessage & { type: "reasoning" } { + const id = overrides.id ?? "reasoning-1"; + return { + type: "reasoning", + id, + historyId: overrides.historyId ?? `history-${id}`, + content: overrides.content ?? "Thinking through the plan", + historySequence: overrides.historySequence ?? 1, + isStreaming: overrides.isStreaming ?? false, + isPartial: overrides.isPartial ?? false, + streamSequence: overrides.streamSequence, + isLastPartOfMessage: overrides.isLastPartOfMessage, + isOnlyMessageContent: overrides.isOnlyMessageContent, + timestamp: overrides.timestamp, + }; +} + +function user(id: string): DisplayedMessage { + return { + type: "user", + id, + historyId: `history-${id}`, + content: "hello", + historySequence: 1, + }; +} + +function assistant( + id: string, + overrides: Partial = {} +): DisplayedMessage & { type: "assistant" } { + return { + type: "assistant", + id, + historyId: overrides.historyId ?? `history-${id}`, + content: overrides.content ?? "done", + historySequence: overrides.historySequence ?? 1, + streamSequence: overrides.streamSequence, + isStreaming: overrides.isStreaming ?? false, + isPartial: overrides.isPartial ?? false, + isLastPartOfMessage: overrides.isLastPartOfMessage, + isCompacted: overrides.isCompacted ?? false, + isIdleCompacted: overrides.isIdleCompacted ?? false, + timestamp: overrides.timestamp, + }; +} + +function streamError(id: string, historyId: string): DisplayedMessage & { type: "stream-error" } { + return { + type: "stream-error", + id, + historyId, + error: "Provider error", + errorType: "api", + historySequence: 1, + }; +} + +function generatedImage( + id: string, + historyId: string +): DisplayedMessage & { type: "generated-image" } { + return { + type: "generated-image", + id, + historyId, + toolCallId: `call-${id}`, + prompt: "Draw a chart", + model: "image-model", + images: [{ path: "/tmp/chart.png", filename: "chart.png", mediaType: "image/png" }], + historySequence: 1, + isPartial: false, + }; +} + +function editedImage(id: string, historyId: string): DisplayedMessage & { type: "edited-image" } { + return { + type: "edited-image", + id, + historyId, + toolCallId: `call-${id}`, + prompt: "Adjust the chart", + model: "image-model", + source: { + path: "/tmp/chart.png", + resolvedPath: "/tmp/chart.png", + sizeBytes: 100, + dimensions: { width: 10, height: 10 }, + }, + images: [ + { + path: "/tmp/chart-edited.png", + filename: "chart-edited.png", + mediaType: "image/png", + outputDimensions: { width: 10, height: 10 }, + }, + ], + historySequence: 1, + isPartial: false, + }; +} + +function planDisplay(id: string, historyId: string): DisplayedMessage & { type: "plan-display" } { + return { + type: "plan-display", + id, + historyId, + content: "# Plan", + path: ".mux/plan.md", + historySequence: 1, + }; +} + +describe("work bundle coalescing", () => { + test("collapses completed assistant work before the final row", () => { + const messages = [ + user("u1"), + reasoning({ id: "think-1", historyId: "history-a1", timestamp: 1_000 }), + assistant("draft-1", { + historyId: "history-a1", + content: "I'll inspect first.", + timestamp: 61_000, + }), + tool({ id: "read-1", historyId: "history-a1", timestamp: 121_000 }), + assistant("final-1", { + historyId: "history-a1", + content: "Implemented the fix.", + timestamp: 181_000, + }), + ]; + + const infos = computeWorkBundleInfos(messages); + + expect(infos[0]).toBeUndefined(); + expect(infos[1]).toMatchObject({ + key: "work:think-1", + position: "head", + headIndex: 1, + durationMs: 180_000, + defaultExpanded: false, + entries: [ + { message: messages[1], originalIndex: 1 }, + { message: messages[2], originalIndex: 2 }, + { message: messages[3], originalIndex: 3 }, + ], + }); + 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" }); + }); + + test("omits work duration when timestamps are missing", () => { + const historyId = "history-a1"; + const messages = [tool({ id: "read-1", historyId }), assistant("final-1", { historyId })]; + + const infos = computeWorkBundleInfos(messages); + + expect(infos[0]).toMatchObject({ durationMs: undefined }); + }); + + test("keeps operational bundle metadata aligned inside work bundles", () => { + const historyId = "history-a1"; + const messages = [ + reasoning({ id: "think-1", historyId }), + assistant("draft-1", { historyId }), + tool({ id: "read-1", historyId, toolName: "file_read" }), + tool({ id: "skill-1", historyId, toolName: "agent_skill_read" }), + assistant("final-1", { historyId }), + ]; + + const workInfos = computeWorkBundleInfos(messages); + const operationalInfos = computeOperationalBundleInfos(messages, { isTurnActive: false }); + + expect(workInfos[0]?.entries.map((entry) => entry.originalIndex)).toEqual([0, 1, 2, 3]); + expect(operationalInfos[2]).toMatchObject({ + position: "head", + headIndex: 2, + entries: [ + { message: messages[0], originalIndex: 0 }, + { message: messages[2], originalIndex: 2 }, + { message: messages[3], originalIndex: 3 }, + ], + }); + }); + + test("leaves active work visible", () => { + const messages = [ + reasoning({ id: "think-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); + }); + + test("keeps non-success tools visible before a final assistant row", () => { + const historyId = "history-a1"; + const failedSearch = tool({ + id: "search-1", + historyId, + toolName: "web_search", + status: "failed", + result: { error: "provider unavailable" }, + }); + const failedBash = tool({ + id: "bash-1", + historyId, + toolName: "bash", + status: "failed", + result: { exitCode: 1, output: "type error" }, + }); + const interruptedRead = tool({ + id: "read-interrupted-1", + historyId, + toolName: "file_read", + status: "interrupted", + }); + const redactedRead = tool({ + id: "read-redacted-1", + historyId, + toolName: "file_read", + status: "redacted", + }); + const partialRead = tool({ + id: "read-partial-1", + historyId, + toolName: "file_read", + isPartial: true, + }); + const messages = [ + failedSearch, + failedBash, + interruptedRead, + redactedRead, + partialRead, + assistant("final-1", { historyId }), + ]; + + const infos = computeWorkBundleInfos(messages); + + expect(infos.every((info) => info === undefined)).toBe(true); + }); + + test("keeps visible artifacts and stream errors out of work bundles", () => { + const historyId = "history-a1"; + const messages = [ + reasoning({ id: "think-1", historyId }), + tool({ id: "read-1", historyId }), + streamError("error-1", historyId), + generatedImage("generated-1", historyId), + editedImage("edited-1", historyId), + planDisplay("plan-1", historyId), + ]; + + const infos = computeWorkBundleInfos(messages); + + expect(infos.every((info) => info === undefined)).toBe(true); + }); +}); + +describe("operational bundle coalescing", () => { + test("groups consecutive reasoning and tool calls without mutating messages", () => { + const first = reasoning({ id: "think-1" }); + const second = tool({ id: "read-1", toolName: "file_read" }); + const third = tool({ id: "edit-1", toolName: "file_edit_replace_string" }); + const messages = [user("u1"), first, assistant("a1"), second, third, assistant("a2")]; + const before = JSON.stringify(messages); + + const infos = computeOperationalBundleInfos(messages, { isTurnActive: false }); + + expect(JSON.stringify(messages)).toBe(before); + expect(infos[0]).toBeUndefined(); + expect(infos[1]).toMatchObject({ key: "bundle:think-1", position: "member", headIndex: 3 }); + expect(infos[2]).toBeUndefined(); + expect(infos[3]).toMatchObject({ + key: "bundle:think-1", + position: "head", + headIndex: 3, + state: "settled", + defaultExpanded: false, + entries: [ + { message: first, originalIndex: 1 }, + { message: second, originalIndex: 3 }, + { message: third, originalIndex: 4 }, + ], + }); + expect(infos[4]).toMatchObject({ key: "bundle:think-1", position: "member" }); + expect(infos[5]).toBeUndefined(); + }); + + test("conversation rows break bundles", () => { + const messages = [ + tool({ id: "read-1", toolName: "file_read" }), + assistant("a1"), + tool({ id: "edit", toolName: "file_edit_replace_string" }), + user("u1"), + tool({ id: "read-2", toolName: "agent_skill_read" }), + ]; + + const infos = computeOperationalBundleInfos(messages, { isTurnActive: false }); + + expect(infos[0]).toMatchObject({ position: "head" }); + expect(infos[1]).toBeUndefined(); + expect(infos[2]).toMatchObject({ position: "head" }); + expect(infos[3]).toBeUndefined(); + expect(infos[4]).toMatchObject({ position: "head" }); + }); + + test("leaves reasoning-only turns visible", () => { + const message = reasoning({ id: "think-only", isOnlyMessageContent: true }); + + const infos = computeOperationalBundleInfos([message], { isTurnActive: false }); + + expect(infos[0]).toBeUndefined(); + }); + + test("does not duplicate leading reasoning when it is the bundle head", () => { + const first = reasoning({ id: "think-1" }); + const second = tool({ id: "read-1", toolName: "file_read" }); + + const reasoningOnly = computeOperationalBundleInfos([first], { isTurnActive: false }); + expect(reasoningOnly[0]?.entries).toEqual([{ message: first, originalIndex: 0 }]); + expect(reasoningOnly[0]?.summary.title).toBe("Reasoned"); + + const reasoningThenTool = computeOperationalBundleInfos([first, second], { + isTurnActive: false, + }); + expect(reasoningThenTool[0]?.entries).toEqual([ + { message: first, originalIndex: 0 }, + { message: second, originalIndex: 1 }, + ]); + expect(reasoningThenTool[0]?.summary.title).toBe("Ran 2 operations"); + }); + + test("leaves non-success tools visible", () => { + const failedSearch = tool({ + id: "search-1", + toolName: "web_search", + status: "failed", + result: { error: "provider unavailable" }, + }); + const failedBash = tool({ + id: "bash-1", + toolName: "bash", + status: "failed", + result: { exitCode: 1, output: "type error" }, + }); + const interruptedRead = tool({ + id: "read-interrupted-1", + toolName: "file_read", + status: "interrupted", + }); + const redactedRead = tool({ + id: "read-redacted-1", + toolName: "file_read", + status: "redacted", + }); + const partialRead = tool({ + id: "read-partial-1", + toolName: "file_read", + isPartial: true, + }); + + const infos = computeOperationalBundleInfos( + [failedSearch, failedBash, interruptedRead, redactedRead, partialRead], + { isTurnActive: false } + ); + + expect(infos.every((info) => info === undefined)).toBe(true); + }); + + test("active and just-settled tail bundles stay expanded until a visible event or turn end", () => { + const active = computeOperationalBundleInfos( + [reasoning({ id: "think-1", isStreaming: true })], + { + isTurnActive: true, + } + ); + expect(active[0]).toMatchObject({ + position: "head", + state: "active", + defaultExpanded: true, + }); + + const justSettledTail = computeOperationalBundleInfos( + [tool({ id: "read-1", status: "completed" })], + { isTurnActive: true } + ); + expect(justSettledTail[0]).toMatchObject({ + position: "head", + state: "settled", + defaultExpanded: true, + }); + + const afterVisibleEvent = computeOperationalBundleInfos( + [tool({ id: "read-1", status: "completed" }), assistant("a1")], + { isTurnActive: true } + ); + expect(afterVisibleEvent[0]).toMatchObject({ + position: "head", + state: "settled", + defaultExpanded: false, + }); + }); + + test("bundle key stays stable while an active bundle grows", () => { + const one = computeOperationalBundleInfos([tool({ id: "read-1", status: "executing" })], { + isTurnActive: true, + }); + const two = computeOperationalBundleInfos( + [tool({ id: "read-1", status: "executing" }), tool({ id: "search-1" })], + { isTurnActive: true } + ); + + expect(one[0]?.key).toBe("bundle:read-1"); + expect(two[0]?.key).toBe("bundle:read-1"); + expect(two[1]).toMatchObject({ key: "bundle:read-1", position: "member" }); + }); +}); + +describe("operational bundle summary", () => { + test("summarizes mixed tools and reasoning", () => { + const summary = summarizeOperationalBundle([ + reasoning({ id: "think-1" }), + tool({ id: "edit-1", toolName: "file_edit_replace_string" }), + tool({ id: "test-1", toolName: "bash", args: { script: "make test" } }), + tool({ id: "question-1", toolName: "ask_user_question" }), + ]); + + expect(summary.title).toBe("Ran 4 operations"); + expect(summary.details).toBe("1 reasoning step · 1 edit · 1 shell command · 1 question"); + }); + + test("pluralizes irregular detail labels", () => { + const summary = summarizeOperationalBundle([ + tool({ id: "search-1", toolName: "web_search", result: [{ title: "one" }] }), + tool({ id: "search-2", toolName: "web_search", result: [{ title: "two" }] }), + tool({ id: "fetch-1", toolName: "web_fetch" }), + tool({ id: "fetch-2", toolName: "web_fetch" }), + ]); + + expect(summary.details).toBe("2 searches · 2 fetches"); + }); + + test("all-miss completed search bundle gets neutral copy", () => { + const allMiss = summarizeOperationalBundle([ + tool({ id: "search-1", toolName: "web_search", status: "completed", result: [] }), + tool({ + id: "search-2", + toolName: "web_search", + status: "completed", + result: { type: "json", value: { sources: [] } }, + }), + ]); + expect(allMiss.title).toBe("No results"); + }); + + test("failed search summaries do not use no-results copy", () => { + const failedSearch = summarizeOperationalBundle([ + tool({ + id: "search-1", + toolName: "web_search", + status: "failed", + result: { error: "provider unavailable" }, + }), + ]); + expect(failedSearch.title).toBe("Searched 1 query"); + }); +}); diff --git a/src/browser/utils/messages/transcriptRenderProjection.ts b/src/browser/utils/messages/transcriptRenderProjection.ts new file mode 100644 index 0000000000..42c7a5584c --- /dev/null +++ b/src/browser/utils/messages/transcriptRenderProjection.ts @@ -0,0 +1,434 @@ +import type { DisplayedMessage } from "@/common/types/message"; +import { isPlainObject } from "@/common/utils/isPlainObject"; + +export type OperationalBundleMemberMessage = DisplayedMessage & { type: "reasoning" | "tool" }; + +export interface OperationalBundleSummary { + title: string; + details: string; +} + +export interface OperationalBundleEntry { + message: OperationalBundleMemberMessage; + originalIndex: number; +} + +export interface OperationalBundleInfo { + key: string; + position: "head" | "member"; + /** Render slot where the bundle header is placed; leading entries can appear earlier. */ + headIndex: number; + entries: readonly OperationalBundleEntry[]; + summary: OperationalBundleSummary; + state: "active" | "settled"; + defaultExpanded: boolean; +} + +export interface WorkBundleEntry { + message: DisplayedMessage; + originalIndex: number; +} + +export interface WorkBundleInfo { + key: string; + position: "head" | "member" | "final"; + /** Render slot where the work-bundle header is placed. */ + headIndex: number; + entries: readonly WorkBundleEntry[]; + durationMs?: number; + defaultExpanded: boolean; +} + +interface ComputeBundleInfosOptions { + isTurnActive: boolean; +} + +type OperationalBundleCategory = + | "edit" + | "fetch" + | "question" + | "read" + | "reasoning" + | "search" + | "shell" + | "skill" + | "task" + | "tool"; + +const FILE_READ_TOOL_NAMES = new Set(["file_read"]); + +// Keep legacy edit tool names categorized as edits for old transcripts, without +// reintroducing the removed file-tool coalescing render path. +const FILE_EDIT_TOOL_NAMES = new Set([ + "file_edit_replace_string", + "file_edit_replace_lines", + "file_edit_insert", +]); + +const OPERATIONAL_BUNDLE_CATEGORY_COPY: Record< + OperationalBundleCategory, + { singletonTitle: string; detailLabel: string; detailLabelPlural: string } +> = { + reasoning: { + singletonTitle: "Reasoned", + detailLabel: "reasoning step", + detailLabelPlural: "reasoning steps", + }, + read: { singletonTitle: "Read 1 file", detailLabel: "read", detailLabelPlural: "reads" }, + search: { + singletonTitle: "Searched 1 query", + detailLabel: "search", + detailLabelPlural: "searches", + }, + fetch: { singletonTitle: "Fetched 1 page", detailLabel: "fetch", detailLabelPlural: "fetches" }, + skill: { + singletonTitle: "Read 1 skill", + detailLabel: "skill read", + detailLabelPlural: "skill reads", + }, + edit: { singletonTitle: "Edited 1 file", detailLabel: "edit", detailLabelPlural: "edits" }, + shell: { + singletonTitle: "Ran 1 shell command", + detailLabel: "shell command", + detailLabelPlural: "shell commands", + }, + question: { + singletonTitle: "Asked 1 question", + detailLabel: "question", + detailLabelPlural: "questions", + }, + task: { + singletonTitle: "Ran 1 agent task", + detailLabel: "agent task", + detailLabelPlural: "agent tasks", + }, + tool: { + singletonTitle: "Ran 1 operation", + detailLabel: "operation", + detailLabelPlural: "operations", + }, +}; + +export function computeWorkBundleInfos( + messages: DisplayedMessage[] +): Array { + const infos = new Array(messages.length); + let index = 0; + + while (index < messages.length) { + const historyId = getWorkBundleHistoryId(messages[index]); + if (historyId === undefined) { + index += 1; + continue; + } + + const startIndex = index; + while (getWorkBundleHistoryId(messages[index + 1]) === historyId) { + index += 1; + } + + const finalIndex = index; + index += 1; + + if (finalIndex <= startIndex) { + continue; + } + + if (messages[finalIndex]?.type !== "assistant") { + continue; + } + + const entries: WorkBundleEntry[] = []; + for (let entryIndex = startIndex; entryIndex < finalIndex; entryIndex++) { + entries.push({ message: messages[entryIndex], originalIndex: entryIndex }); + } + + const groupMessages = [...entries.map((entry) => entry.message), messages[finalIndex]]; + if (groupMessages.some(isActiveWorkBundleMessage)) { + continue; + } + + const frozenEntries = Object.freeze(entries); + const first = entries[0].message; + const info: WorkBundleInfo = { + key: `work:${first.id}`, + position: "head", + headIndex: startIndex, + entries: frozenEntries, + durationMs: computeWorkBundleDurationMs(frozenEntries, messages[finalIndex]), + defaultExpanded: false, + }; + + for (const entry of entries) { + infos[entry.originalIndex] = { + ...info, + position: entry.originalIndex === startIndex ? "head" : "member", + }; + } + infos[finalIndex] = { ...info, position: "final" }; + } + + return infos; +} + +export function computeOperationalBundleInfos( + messages: DisplayedMessage[], + options: ComputeBundleInfosOptions +): Array { + const infos = new Array(messages.length); + let index = 0; + + while (index < messages.length) { + const leadingReasoningStart = index; + const leadingReasoningEntries: OperationalBundleEntry[] = []; + while (true) { + const message = messages[index]; + if (message?.type !== "reasoning") { + break; + } + leadingReasoningEntries.push({ message, originalIndex: index }); + index += 1; + } + + if (leadingReasoningEntries.length > 0 && messages[index]?.type === "assistant") { + index += 1; + } else if (leadingReasoningEntries.length > 0) { + leadingReasoningEntries.length = 0; + index = leadingReasoningStart; + } + + if (!isOperationalBundleMemberMessage(messages[index])) { + index += 1; + continue; + } + + const headIndex = index; + const entries: OperationalBundleEntry[] = [...leadingReasoningEntries]; + while (index < messages.length) { + const candidate = messages[index]; + if (!isOperationalBundleMemberMessage(candidate)) { + break; + } + entries.push({ message: candidate, originalIndex: index }); + index += 1; + } + + const frozenEntries = Object.freeze(entries); + const first = entries[0].message; + + const state = frozenEntries.some((entry) => isActiveOperationalMessage(entry.message)) + ? "active" + : "settled"; + const hasSubsequentVisibleEvent = hasVisibleEventAfter(messages, index); + const defaultExpanded = + state === "active" || (options.isTurnActive && !hasSubsequentVisibleEvent); + const key = `bundle:${first.id}`; + const summary = summarizeOperationalBundle(frozenEntries.map((entry) => entry.message)); + + for (const entry of frozenEntries) { + infos[entry.originalIndex] = { + key, + position: entry.originalIndex === headIndex ? "head" : "member", + headIndex, + entries: frozenEntries, + summary, + state, + defaultExpanded, + }; + } + } + + 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 isActiveWorkBundleMessage(message: DisplayedMessage): boolean { + if (message.type === "assistant" || message.type === "reasoning") { + return message.isStreaming; + } + return ( + message.type === "tool" && (message.status === "pending" || message.status === "executing") + ); +} + +function getMessageTimestamp(message: DisplayedMessage): number | undefined { + return "timestamp" in message && typeof message.timestamp === "number" + ? message.timestamp + : undefined; +} + +function computeWorkBundleDurationMs( + entries: readonly WorkBundleEntry[], + finalMessage: DisplayedMessage +): number | undefined { + const startTimestamp = getMessageTimestamp(entries[0].message); + const endTimestamp = + getMessageTimestamp(finalMessage) ?? getMessageTimestamp(entries.at(-1)!.message); + if ( + startTimestamp === undefined || + endTimestamp === undefined || + endTimestamp <= startTimestamp + ) { + return undefined; + } + return endTimestamp - startTimestamp; +} + +function hasVisibleEventAfter(messages: DisplayedMessage[], startIndex: number): boolean { + for (let index = startIndex; index < messages.length; index++) { + if (!isOperationalBundleMemberMessage(messages[index])) { + return true; + } + } + + return false; +} + +export function summarizeOperationalBundle( + messages: OperationalBundleMemberMessage[] +): OperationalBundleSummary { + if (messages.length === 0) { + throw new Error("Cannot summarize an empty operational bundle"); + } + + const allSearchMisses = messages.every(isEmptyCompletedWebSearch); + if (allSearchMisses) { + return { + title: "No results", + details: formatDetails(messages), + }; + } + + if (messages.length === 1) { + return { + title: singletonTitle(messages[0]), + details: formatDetails(messages), + }; + } + + return { + title: `Ran ${messages.length.toLocaleString()} operations`, + details: formatDetails(messages), + }; +} + +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" && + message.toolName === "web_search" && + message.status === "completed" && + getWebSearchResultCount(message.result) === 0 + ); +} + +function getWebSearchResultCount(result: unknown): number | undefined { + const unwrapped = unwrapJsonResult(result); + if (Array.isArray(unwrapped)) { + return unwrapped.length; + } + if (isPlainObject(unwrapped) && Array.isArray(unwrapped.sources)) { + return unwrapped.sources.length; + } + return undefined; +} + +function unwrapJsonResult(result: unknown): unknown { + if (isPlainObject(result) && result.type === "json" && "value" in result) { + return result.value; + } + return result; +} + +function isOperationalBundleMemberMessage( + message: DisplayedMessage | undefined +): message is OperationalBundleMemberMessage { + if (message?.type === "reasoning") { + return message.isOnlyMessageContent !== true; + } + if (message?.type === "tool") { + return isBundleableToolMessage(message); + } + return false; +} + +function isActiveOperationalMessage(message: OperationalBundleMemberMessage): boolean { + if (message.type === "reasoning") { + return message.isStreaming; + } + return message.status === "pending" || message.status === "executing"; +} + +function singletonTitle(message: OperationalBundleMemberMessage): string { + return OPERATIONAL_BUNDLE_CATEGORY_COPY[getOperationalBundleCategory(message)].singletonTitle; +} + +function formatDetails(messages: OperationalBundleMemberMessage[]): string { + const counts = new Map(); + for (const message of messages) { + const category = getOperationalBundleCategory(message); + counts.set(category, (counts.get(category) ?? 0) + 1); + } + + return Array.from(counts.entries()) + .map(([category, count]) => { + const copy = OPERATIONAL_BUNDLE_CATEGORY_COPY[category]; + const label = count === 1 ? copy.detailLabel : copy.detailLabelPlural; + return `${count.toLocaleString()} ${label}`; + }) + .join(" · "); +} + +function getOperationalBundleCategory( + message: OperationalBundleMemberMessage +): OperationalBundleCategory { + if (message.type === "reasoning") { + return "reasoning"; + } + + if (FILE_READ_TOOL_NAMES.has(message.toolName)) { + return "read"; + } + if (FILE_EDIT_TOOL_NAMES.has(message.toolName)) { + return "edit"; + } + if (message.toolName === "bash") { + return "shell"; + } + if (message.toolName === "web_search") { + return "search"; + } + if (message.toolName === "web_fetch") { + return "fetch"; + } + if (message.toolName === "agent_skill_read" || message.toolName === "agent_skill_read_file") { + return "skill"; + } + if (message.toolName === "ask_user_question") { + return "question"; + } + if (message.toolName === "task" || message.toolName === "task_await") { + return "task"; + } + + return "tool"; +} diff --git a/src/common/constants/storage.test.ts b/src/common/constants/storage.test.ts index 558649391a..482057fb2f 100644 --- a/src/common/constants/storage.test.ts +++ b/src/common/constants/storage.test.ts @@ -4,6 +4,7 @@ import { deleteWorkspaceStorage, getDraftScopeId, getInputAttachmentsKey, + normalizeTranscriptDensity, } from "@/common/constants/storage"; class MemoryStorage implements Storage { @@ -63,6 +64,12 @@ describe("storage workspace-scoped keys", () => { expect(getInputAttachmentsKey("ws-123")).toBe("inputAttachments:ws-123"); }); + test("normalizeTranscriptDensity falls back for corrupt values", () => { + expect(normalizeTranscriptDensity("hyper")).toBe("hyper"); + expect(normalizeTranscriptDensity("compact")).toBe("normal"); + expect(normalizeTranscriptDensity(null)).toBe("normal"); + }); + test("copyWorkspaceStorage copies inputAttachments key", () => { const source = "ws-source"; const dest = "ws-dest"; diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index ead2d92cb8..c6440a2aaf 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -380,6 +380,26 @@ export const DEFAULT_EDITOR_CONFIG: EditorConfig = { editor: "vscode", }; +/** + * Transcript density display preference (global) + * Stores: "normal" | "hyper" + */ +export const TRANSCRIPT_DENSITY_KEY = "transcriptDensity"; + +export const TRANSCRIPT_DENSITIES = ["normal", "hyper"] as const; + +export type TranscriptDensity = (typeof TRANSCRIPT_DENSITIES)[number]; + +export const DEFAULT_TRANSCRIPT_DENSITY: TranscriptDensity = "normal"; + +function isTranscriptDensity(value: unknown): value is TranscriptDensity { + return typeof value === "string" && TRANSCRIPT_DENSITIES.includes(value as TranscriptDensity); +} + +export function normalizeTranscriptDensity(value: unknown): TranscriptDensity { + return isTranscriptDensity(value) ? value : DEFAULT_TRANSCRIPT_DENSITY; +} + /** * Collapsed bash tool summary display mode (global) * Stores: "command" | "intent-command" | "intent" diff --git a/src/common/utils/tools/hookOutput.ts b/src/common/utils/tools/hookOutput.ts new file mode 100644 index 0000000000..bd93ffbdc5 --- /dev/null +++ b/src/common/utils/tools/hookOutput.ts @@ -0,0 +1,24 @@ +import { isPlainObject } from "@/common/utils/isPlainObject"; + +function hasHookOutput( + result: unknown +): result is Record & { hook_output: string } { + return isPlainObject(result) && typeof result.hook_output === "string"; +} + +/** + * Extracts stdout/stderr captured from hook execution results. + */ +export function extractHookOutput(result: unknown): string | null { + if (!hasHookOutput(result)) return null; + return result.hook_output.length > 0 ? result.hook_output : null; +} + +/** + * Extracts hook execution duration in milliseconds when available. + */ +export function extractHookDuration(result: unknown): number | undefined { + if (!isPlainObject(result)) return undefined; + const duration = result.hook_duration_ms; + return typeof duration === "number" && Number.isFinite(duration) ? duration : undefined; +} diff --git a/tests/ui/chat/transcriptDensity.test.tsx b/tests/ui/chat/transcriptDensity.test.tsx new file mode 100644 index 0000000000..68e5e39859 --- /dev/null +++ b/tests/ui/chat/transcriptDensity.test.tsx @@ -0,0 +1,87 @@ +import "../dom"; +import { fireEvent, waitFor } from "@testing-library/react"; +import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { TRANSCRIPT_DENSITY_KEY, type TranscriptDensity } from "@/common/constants/storage"; +import { setupSimpleChatStory } from "@/browser/stories/helpers/chatSetup"; +import { createWorkspace } from "@/browser/stories/mocks/workspaces"; +import { createAssistantMessage, createUserMessage } from "@/browser/stories/mocks/messages"; +import { + createAgentSkillReadTool, + createBashTool, + createFileReadTool, + createWebSearchTool, +} from "@/browser/stories/mocks/tools"; +import { installDom } from "../dom"; +import { cleanupView, setupWorkspaceView } from "../helpers"; +import { renderApp } from "../renderReviewPanel"; + +function queryButton(container: HTMLElement, testId: string): HTMLButtonElement | null { + const element = container.querySelector(`[data-testid="${testId}"]`); + return element instanceof HTMLButtonElement + ? element + : (element?.querySelector("button") ?? null); +} + +describe("Hyper transcript density", () => { + test("expands work bundles and nested operational bundles through the app render path", async () => { + const cleanupDom = installDom(); + updatePersistedState(TRANSCRIPT_DENSITY_KEY, "hyper"); + + const metadata = createWorkspace({ + id: "ws-density", + name: "feature", + projectName: "my-app", + projectPath: "/home/user/projects/my-app", + }); + const client = setupSimpleChatStory({ + workspaceId: metadata.id, + workspaceName: metadata.name, + projectName: metadata.projectName, + projectPath: metadata.projectPath, + messages: [ + createUserMessage("density-user-1", "Audit the auth module", { historySequence: 1 }), + createAssistantMessage("density-assistant-1", "I'll gather context first.", { + historySequence: 2, + 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"), + { type: "text", text: "Implemented the auth audit fix." }, + ], + }), + ], + }); + const view = renderApp({ apiClient: client, metadata }); + + try { + await setupWorkspaceView(view, metadata, metadata.id); + + const workButton = await waitFor(() => { + const button = queryButton(view.container, "work-bundle"); + if (!button) { + throw new Error("Work bundle button not found"); + } + return button; + }); + expect(workButton.getAttribute("aria-expanded")).toBe("false"); + fireEvent.click(workButton); + + const operationalButton = await waitFor(() => { + const button = queryButton(view.container, "operational-bundle"); + if (!button) { + throw new Error("Operational bundle button not found"); + } + return button; + }); + fireEvent.click(operationalButton); + + await waitFor(() => { + expect(view.container.textContent).toContain("src/auth.ts"); + }); + } finally { + await cleanupView(view, cleanupDom); + } + }, 30_000); +});