From af26c7ed72e7318a477a26eb35e16886affc61d1 Mon Sep 17 00:00:00 2001 From: ClawSweeper Date: Sun, 26 Jul 2026 01:09:08 -0700 Subject: [PATCH] fix(ui): keep chat run status in one assistant turn (#114039) * fix(ui): unify active chat status * fix(ui): preserve chat recap ordering * fix(ui): re-render turn rows when embedded run status changes owner --------- Co-authored-by: Peter Steinberger --- ui/src/e2e/chat-run-lifecycle.e2e.test.ts | 39 ++++ ui/src/pages/chat/chat-thread-grouping.ts | 24 +- ui/src/pages/chat/chat-thread.test.ts | 24 ++ ui/src/pages/chat/chat-thread.ts | 6 +- ui/src/pages/chat/chat-view.test.ts | 215 +++++++++++++++++- .../chat/components/chat-message-group.ts | 23 ++ .../chat/components/chat-message-stream.ts | 68 +++--- .../chat/components/chat-message.test.ts | 34 +++ ui/src/pages/chat/components/chat-message.ts | 1 + ui/src/pages/chat/components/chat-thread.ts | 100 +++++++- .../chat/components/chat-working-indicator.ts | 59 +++-- ui/src/styles/chat/tool-cards.css | 11 + 12 files changed, 537 insertions(+), 67 deletions(-) diff --git a/ui/src/e2e/chat-run-lifecycle.e2e.test.ts b/ui/src/e2e/chat-run-lifecycle.e2e.test.ts index af502fe80c4f..2ce93522075d 100644 --- a/ui/src/e2e/chat-run-lifecycle.e2e.test.ts +++ b/ui/src/e2e/chat-run-lifecycle.e2e.test.ts @@ -1,4 +1,6 @@ // Control UI E2E tests cover chat run lifecycle behavior through the Gateway WebSocket. +import { mkdir } from "node:fs/promises"; +import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { CHAT_RUN_STATUS_TOAST_DURATION_MS } from "../pages/chat/run-lifecycle.ts"; @@ -44,6 +46,43 @@ describeControlUiE2e("Control UI chat run lifecycle", () => { await server?.close(); }); + it("keeps a continuing run inside its latest assistant reply", async () => { + const context = await browser.newContext({ viewport: { height: 800, width: 1200 } }); + const currentPage = await context.newPage(); + page = currentPage; + await installMockGateway(currentPage, { + historyMessages: [ + { + role: "assistant", + content: "First result is ready.", + timestamp: Date.now() - 1_000, + }, + ], + inFlightRun: { runId: "run-continuing", text: "" }, + sessionInfo: { + activeRunIds: ["run-continuing"], + hasActiveRun: true, + key: "main", + }, + }); + + await currentPage.goto(`${server?.baseUrl ?? ""}chat`); + const assistantGroup = currentPage.locator(".chat-group.assistant"); + await assistantGroup.getByText("First result is ready.", { exact: true }).waitFor(); + await assistantGroup.locator(".chat-working-indicator--continuation").waitFor(); + + expect(await assistantGroup.count()).toBe(1); + expect(await currentPage.locator(".chat-reading-indicator").count()).toBe(0); + expect(await assistantGroup.getByText("Working…", { exact: true }).count()).toBe(1); + + const artifactDir = path.resolve(".artifacts/control-ui-e2e/chat-single-turn-status"); + await mkdir(artifactDir, { recursive: true }); + await currentPage.screenshot({ + path: path.join(artifactDir, "continuing-reply.png"), + fullPage: true, + }); + }); + it("shows compaction savings and live working time", async () => { const context = await browser.newContext({ viewport: { height: 800, width: 1200 } }); const currentPage = await context.newPage(); diff --git a/ui/src/pages/chat/chat-thread-grouping.ts b/ui/src/pages/chat/chat-thread-grouping.ts index 110f3602fa8d..fbe20f3e31b8 100644 --- a/ui/src/pages/chat/chat-thread-grouping.ts +++ b/ui/src/pages/chat/chat-thread-grouping.ts @@ -538,14 +538,9 @@ function isTurnBoundaryGroup(item: TurnRenderItem): boolean { if (item.kind !== "group") { return false; } - const role = item.role.toLowerCase(); // sessions_send projections start a new autonomous turn, same contract as // annotateToolTurnOutcome; they are inputs, not work produced by this turn. - return ( - role === "user" || - groupStartsProjectedTurnBoundary(item) || - (role === "assistant" && assistantGroupIsForwardedBoundary(item)) - ); + return messageGroupStartsTurnBoundary(item); } function isCollapsibleWorkGroup(item: TurnRenderItem): item is MessageGroup { @@ -575,6 +570,23 @@ function assistantGroupHasVisibleReplyContent(group: MessageGroup): boolean { }); } +export function assistantGroupCanOwnActiveRunStatus(group: MessageGroup): boolean { + return ( + group.role.toLowerCase() === "assistant" && + !assistantGroupIsForwardedBoundary(group) && + assistantGroupHasVisibleReplyContent(group) + ); +} + +function messageGroupStartsTurnBoundary(group: MessageGroup): boolean { + const role = group.role.toLowerCase(); + return ( + role === "user" || + groupStartsProjectedTurnBoundary(group) || + (role === "assistant" && assistantGroupIsForwardedBoundary(group)) + ); +} + // History carries no final-vs-commentary marker (commentary exists only as // live stream segments), so the last assistant group with visible content // stands in for the final reply. Turns whose last content is commentary diff --git a/ui/src/pages/chat/chat-thread.test.ts b/ui/src/pages/chat/chat-thread.test.ts index 89ed0c4ee5d1..d6ba19c4528d 100644 --- a/ui/src/pages/chat/chat-thread.test.ts +++ b/ui/src/pages/chat/chat-thread.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import type { MessageGroup } from "../../lib/chat/chat-types.ts"; import { extractToolCardsCached as extractToolCards } from "../../lib/chat/tool-cards.ts"; import { + assistantGroupCanOwnActiveRunStatus, buildCachedChatItems, coalesceStreamRuns, collapseCompletedTurnWork, @@ -15,6 +16,29 @@ import { syncToolCardExpansionState, } from "./chat-thread.ts"; +describe("assistantGroupCanOwnActiveRunStatus", () => { + const group = (message: Record): MessageGroup => ({ + kind: "group", + key: "assistant:1", + role: "assistant", + timestamp: 1, + isStreaming: false, + messages: [{ key: "message:1", message }], + }); + + it("accepts visible replies and rejects forwarded assistant input", () => { + expect(assistantGroupCanOwnActiveRunStatus(group({ content: "Reply" }))).toBe(true); + expect( + assistantGroupCanOwnActiveRunStatus( + group({ + content: "Forwarded input", + provenance: { kind: "inter_session", sourceTool: "sessions_send" }, + }), + ), + ).toBe(false); + }); +}); + describe("persistedMessageEntryId", () => { it("rejects optimistic pending bubbles and accepts transcript identities", () => { expect( diff --git a/ui/src/pages/chat/chat-thread.ts b/ui/src/pages/chat/chat-thread.ts index b531be9f2161..d56c87f84473 100644 --- a/ui/src/pages/chat/chat-thread.ts +++ b/ui/src/pages/chat/chat-thread.ts @@ -17,7 +17,11 @@ import { sanitizeStreamText } from "./chat-thread-items.ts"; import { getOrCreateSessionCacheValue, setSessionCacheValue } from "./session-cache.ts"; export { isPendingSendMessage, persistedMessageEntryId } from "./chat-thread-items.ts"; -export { coalesceStreamRuns, collapseCompletedTurnWork } from "./chat-thread-grouping.ts"; +export { + assistantGroupCanOwnActiveRunStatus, + coalesceStreamRuns, + collapseCompletedTurnWork, +} from "./chat-thread-grouping.ts"; type CachedChatItems = { input: BuildChatItemsProps | null; diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 6cdbc8470e03..7643a914ea59 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -28,6 +28,7 @@ import { registerChatAttachmentPayload as registerStoredChatAttachmentPayload, releaseChatAttachmentPayloads, } from "./attachment-payload-store.ts"; +import * as chatProgress from "./chat-progress.ts"; import { switchChatFastMode, switchChatModel, switchChatThinkingLevel } from "./chat-session.ts"; import * as chatThread from "./chat-thread.ts"; import { resetChatViewState } from "./chat-view-state.ts"; @@ -2022,13 +2023,14 @@ describe("chat loading skeleton", () => { present: { ".chat-reading-indicator": null }, }, { - name: "keeps the working spark below a rendered response while the run continues", + name: "keeps continuing-run status inside the rendered response", props: { canAbort: true, messages: [{ role: "assistant", content: "Finished answer", timestamp: 1 }], stream: null, }, - present: { ".chat-reading-indicator": null, ".chat-group": "Finished answer" }, + present: { ".chat-group": "Finished answer" }, + counts: { ".chat-group": 1, ".chat-stream-run": 0 }, }, { name: "drops the working spark once the run reaches a terminal status", @@ -2089,6 +2091,215 @@ describe("chat loading skeleton", () => { } }); + it("routes live and completed status into the existing assistant turn", () => { + renderChatView({ + canAbort: true, + messages: [{ role: "assistant", content: "Finished answer", timestamp: 1 }], + stream: null, + }); + + expect(renderMessageGroupMock).toHaveBeenCalledTimes(1); + expect(renderMessageGroupMock.mock.calls[0]?.[1]).toMatchObject({ + activeContinuation: { + parts: [{ kind: "reading-indicator", key: "reading:test", startedAt: 1 }], + }, + }); + + renderMessageGroupMock.mockClear(); + vi.spyOn(chatProgress, "resolveTurnRecap").mockReturnValue({ + runtimeMs: 5_000, + outputTokens: 42, + }); + const container = renderChatView({ + messages: [{ role: "assistant", content: "Finished answer", timestamp: 1 }], + }); + + expect(renderMessageGroupMock).toHaveBeenCalledTimes(1); + expect(renderMessageGroupMock.mock.calls[0]?.[1]).toMatchObject({ + turnRecap: { runtimeMs: 5_000, outputTokens: 42 }, + }); + expect(container.querySelector(".chat-turn-recap")).toBeNull(); + }); + + it("keeps a completed recap after later tool content", () => { + vi.mocked(chatThread.buildCachedChatItems).mockReturnValueOnce([ + { + kind: "group", + key: "group:assistant:test", + role: "assistant", + messages: [ + { + key: "message:assistant:test", + message: { role: "assistant", content: "Interim answer", timestamp: 1 }, + }, + ], + timestamp: 1, + isStreaming: false, + }, + { + kind: "group", + key: "group:tool:test", + role: "tool", + messages: [ + { + key: "message:tool:test", + message: { role: "tool", content: "Later tool result", timestamp: 2 }, + }, + ], + timestamp: 2, + isStreaming: false, + }, + ]); + vi.spyOn(chatProgress, "resolveTurnRecap").mockReturnValue({ + runtimeMs: 5_000, + outputTokens: 42, + }); + + const container = renderChatView({ + messages: [{ role: "assistant", content: "Interim answer", timestamp: 1 }], + }); + + expect(renderMessageGroupMock.mock.calls[0]?.[1].turnRecap).toBeUndefined(); + expect(container.querySelector(".chat-turn-recap")?.textContent).toContain("Done in"); + }); + + it("releases the embedded status when later work steals ownership from an unchanged reply", () => { + // Rows memoize on their own item identity, so an unchanged reply that + // stops owning the status must still re-render without it. + const replyGroup = { + kind: "group", + key: "group:assistant:reply", + role: "assistant", + messages: [ + { + key: "message:assistant:reply", + message: { role: "assistant", content: "Interim answer", timestamp: 1 }, + }, + ], + timestamp: 1, + isStreaming: false, + }; + const readingIndicator = { kind: "reading-indicator", key: "reading:test", startedAt: 1 }; + const toolGroup = { + kind: "group", + key: "group:tool:later", + role: "tool", + messages: [ + { + key: "message:tool:later", + message: { role: "tool", content: "Later tool result", timestamp: 2 }, + }, + ], + timestamp: 2, + isStreaming: false, + }; + const props = { + canAbort: true, + messages: [{ role: "assistant", content: "Interim answer", timestamp: 1 }], + stream: null, + }; + const container = document.createElement("div"); + + vi.mocked(chatThread.buildCachedChatItems).mockReturnValue([ + replyGroup, + readingIndicator, + ] as ReturnType); + render(renderChat(createChatProps(props)), container); + expect(renderMessageGroupMock.mock.calls.at(-1)?.[1].activeContinuation).toBeDefined(); + + renderMessageGroupMock.mockClear(); + vi.mocked(chatThread.buildCachedChatItems).mockReturnValue([ + replyGroup, + toolGroup, + readingIndicator, + ] as ReturnType); + render(renderChat(createChatProps(props)), container); + + const replyCall = renderMessageGroupMock.mock.calls.find( + ([group]) => group.key === replyGroup.key, + ); + expect(replyCall).toBeDefined(); + expect(replyCall?.[1].activeContinuation).toBeUndefined(); + }); + + it("releases the embedded recap when a later reply becomes the settled turn", () => { + const firstReply = { + kind: "group", + key: "group:assistant:first", + role: "assistant", + messages: [ + { + key: "message:assistant:first", + message: { role: "assistant", content: "First answer", timestamp: 1 }, + }, + ], + timestamp: 1, + isStreaming: false, + }; + const secondReply = { + ...firstReply, + key: "group:assistant:second", + messages: [ + { + key: "message:assistant:second", + message: { role: "assistant", content: "Second answer", timestamp: 2 }, + }, + ], + timestamp: 2, + }; + vi.spyOn(chatProgress, "resolveTurnRecap").mockReturnValue({ + runtimeMs: 5_000, + outputTokens: 42, + }); + const props = { messages: [{ role: "assistant", content: "First answer", timestamp: 1 }] }; + const container = document.createElement("div"); + + vi.mocked(chatThread.buildCachedChatItems).mockReturnValue([firstReply] as ReturnType< + typeof chatThread.buildCachedChatItems + >); + render(renderChat(createChatProps(props)), container); + expect(renderMessageGroupMock.mock.calls.at(-1)?.[1].turnRecap).toBeDefined(); + + renderMessageGroupMock.mockClear(); + vi.mocked(chatThread.buildCachedChatItems).mockReturnValue([ + firstReply, + secondReply, + ] as ReturnType); + render(renderChat(createChatProps(props)), container); + + const firstCall = renderMessageGroupMock.mock.calls.find( + ([group]) => group.key === firstReply.key, + ); + expect(firstCall).toBeDefined(); + expect(firstCall?.[1].turnRecap).toBeUndefined(); + expect( + renderMessageGroupMock.mock.calls.find(([group]) => group.key === secondReply.key)?.[1] + .turnRecap, + ).toBeDefined(); + }); + + it("keeps live status standalone when the preceding response is hidden", () => { + const sessionKey = "deleted-active-status"; + renderChatView({ + sessionKey, + messages: [{ role: "assistant", content: "Hidden answer", timestamp: 1 }], + }); + const onDelete = renderMessageGroupMock.mock.calls[0]?.[1].onDelete; + expect(onDelete).toBeTypeOf("function"); + onDelete?.(); + renderMessageGroupMock.mockClear(); + + const container = renderChatView({ + canAbort: true, + sessionKey, + messages: [{ role: "assistant", content: "Hidden answer", timestamp: 1 }], + stream: null, + }); + + expect(renderMessageGroupMock).not.toHaveBeenCalled(); + expect(container.querySelector(".chat-reading-indicator")).not.toBeNull(); + }); + it("shows prompt-bar progress beside context usage while the current session send is awaiting acknowledgement", () => { const container = renderChatView({ sending: true, diff --git a/ui/src/pages/chat/components/chat-message-group.ts b/ui/src/pages/chat/components/chat-message-group.ts index 8012acc6c5ef..2516c0e40db6 100644 --- a/ui/src/pages/chat/components/chat-message-group.ts +++ b/ui/src/pages/chat/components/chat-message-group.ts @@ -12,6 +12,7 @@ import { extractToolCardsCached, isToolCardError } from "../../../lib/chat/tool- import type { EmbedSandboxMode } from "../../../lib/chat/tool-display.ts"; import { resolveIdentityHue } from "../../../lib/identity-avatar.ts"; import { renderChatAvatar } from "../chat-avatar.ts"; +import type { TurnRecap } from "../chat-progress.ts"; import { isPendingSendMessage, persistedMessageEntryId } from "../chat-thread.ts"; import { workspaceResultConflictFromTranscript } from "../workspace-conflict.ts"; import { renderChatAuthorAvatar } from "./chat-author-avatar.ts"; @@ -23,6 +24,11 @@ import { resolveMessageActionDetails, type MessageReplyTarget, } from "./chat-message-markdown.ts"; +import { + renderStreamGroupParts, + type StreamGroupOptions, + type StreamGroupPart, +} from "./chat-message-stream.ts"; import { extractGroupMeta, renderChatTimestamp, @@ -34,6 +40,12 @@ import { resolveToolRowText, shouldToggleSelectableDisclosure, } from "./chat-tool-cards.ts"; +import { renderTurnRecapRow } from "./chat-working-indicator.ts"; + +type ActiveContinuation = { + parts: StreamGroupPart[]; + options: StreamGroupOptions; +}; type RenderMessageGroupOptions = { onOpenSidebar?: (content: SidebarContent) => void; @@ -72,6 +84,8 @@ type RenderMessageGroupOptions = { onReply?: (target: MessageReplyTarget) => void; onRewind?: () => void; rewindDisabled?: boolean; + activeContinuation?: ActiveContinuation; + turnRecap?: TurnRecap; }; type GroupedMessageRenderOptions = Parameters[2]; @@ -389,6 +403,15 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup : nothing} `; })} + ${opts.activeContinuation + ? renderStreamGroupParts( + opts.activeContinuation.parts, + opts.activeContinuation.options, + "continuation", + ) + : opts.turnRecap + ? renderTurnRecapRow(opts.turnRecap, { presentation: "continuation" }) + : nothing}