diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index 15d19a2aa7..107bfde516 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -1239,11 +1239,19 @@ const ChatPaneContent: React.FC = (props) => { ? (workBundleOverride ?? workBundle.defaultExpanded) : false; - if (workBundle?.position === "member") { + const keepCollapsedWorkBundleMemberVisible = + msg.type === "user" || + (msg.type === "assistant" && + (msg.isSideAnswer === true || workBundle?.position === "final")); + if ( + (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 @@ -1269,6 +1277,10 @@ const ChatPaneContent: React.FC = (props) => { return ( + {renderMessageBeforeWorkBundle && + renderMessageAtIndex(msg, index, { + key: `${workspaceId}:${msg.id}:message`, + })} {renderWorkBundle && workBundle && ( = (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/TranscriptDensity.stories.tsx b/src/browser/features/Messages/TranscriptDensity.stories.tsx index a5cb139106..6eda817ae9 100644 --- a/src/browser/features/Messages/TranscriptDensity.stories.tsx +++ b/src/browser/features/Messages/TranscriptDensity.stories.tsx @@ -36,45 +36,69 @@ 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: [ 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") + createGenericTool( + "density-question-1", + "ask_user_question", + { question: "Any additional validation needed?" }, + { answer: "Please validate with typecheck too" } ), - 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, + }, + ], + } + ), ], }); } @@ -129,15 +153,16 @@ export const HyperActiveExpandedBundle: AppStory = { setup={() => { collapseLeftSidebar(); setDensity("hyper"); + const activeStartedAt = Date.now() - 39_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 0ebcb84917..17a3d11512 100644 --- a/src/browser/features/Messages/WorkBundleMessage.test.tsx +++ b/src/browser/features/Messages/WorkBundleMessage.test.tsx @@ -14,7 +14,9 @@ const item: WorkBundleInfo = { position: "head", headIndex: 1, entries: [], + startedAtMs: 0, durationMs: 180_000, + state: "settled", defaultExpanded: false, }; @@ -46,6 +48,24 @@ describe("WorkBundleMessage", () => { expect(view.getByRole("button", { expanded: true })).toBeDefined(); }); + test("renders active working label with elapsed duration", () => { + const view = render( + undefined} + /> + ); + + expect(view.getByText(/Working for \d+s\.\.\./)).toBeDefined(); + }); + test("renders fallback label without duration", () => { const view = render( 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,9 +27,20 @@ interface WorkBundleMessageProps { } export function WorkBundleMessage(props: WorkBundleMessageProps): React.ReactElement { - const duration = props.item.durationMs; - const label = - duration === undefined ? "Worked" : `Worked for ${formatDuration(duration, "precise")}`; + 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..." + : `Working for ${formatDuration(duration, "precise")}...` + : duration === undefined + ? "Worked" + : `Worked for ${formatDuration(duration, "precise")}`; return (