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 <steipete@gmail.com>
This commit is contained in:
ClawSweeper
2026-07-26 01:09:08 -07:00
committed by GitHub
parent 0c8f070c92
commit af26c7ed72
12 changed files with 537 additions and 67 deletions
+39
View File
@@ -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();
+18 -6
View File
@@ -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
+24
View File
@@ -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<string, unknown>): 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(
+5 -1
View File
@@ -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;
+213 -2
View File
@@ -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<typeof chatThread.buildCachedChatItems>);
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<typeof chatThread.buildCachedChatItems>);
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<typeof chatThread.buildCachedChatItems>);
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,
@@ -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<typeof renderGroupedMessage>[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}
</div>
<div
class="chat-group-footer ${persistUserIdentity
@@ -17,12 +17,12 @@ import { shouldToggleSelectableDisclosure } from "./chat-tool-cards.ts";
import { renderChatWorkingIndicator } from "./chat-working-indicator.ts";
/** A contiguous run of in-flight streaming items rendered under one assistant group. */
type StreamGroupPart = Extract<
export type StreamGroupPart = Extract<
ChatItem,
{ kind: "stream" } | { kind: "reading-indicator" } | { kind: "question" } | { kind: "plan" }
>;
type StreamGroupOptions = {
export type StreamGroupOptions = {
onOpenSidebar?: (content: SidebarContent) => void;
assistant?: AssistantIdentity;
basePath?: string;
@@ -43,11 +43,44 @@ function renderQuestionStreamPart(
return prompt ? renderChatQuestionSummary(prompt) : nothing;
}
export function renderStreamGroupParts(
parts: StreamGroupPart[],
opts: StreamGroupOptions,
presentation: "standalone" | "continuation",
) {
return parts.map((part) =>
part.kind === "reading-indicator"
? renderChatWorkingIndicator(part, {
waitingApproval: opts.waitingApproval === true,
startupPhase: opts.startupPhase,
outputTokens: opts.runOutputTokens,
presentation,
})
: part.kind === "question"
? renderQuestionStreamPart(part, opts)
: part.kind === "plan"
? renderChatPlanChecklist(opts.planStatus, {
active: opts.planActive === true,
variant: "card",
})
: renderGroupedMessage(
{
role: "assistant",
content: [{ type: "text", text: part.text }],
timestamp: part.startedAt,
},
part.key,
{ isStreaming: part.isStreaming, showReasoning: false },
opts.onOpenSidebar,
),
);
}
// One assistant group per contiguous run of streaming items: a reply that
// arrives as several stream segments renders under a single avatar/footer
// instead of flashing a separate avatar+bubble per segment (#63956).
export function renderStreamGroup(parts: StreamGroupPart[], opts: StreamGroupOptions = {}) {
const { onOpenSidebar, assistant, basePath, authToken } = opts;
const { assistant, basePath, authToken } = opts;
const name = assistant?.name ?? "Assistant";
// Footer (sender + time) anchors to the earliest streamed segment; a run that
// is only the reading indicator has no timestamp and therefore no footer.
@@ -65,34 +98,7 @@ export function renderStreamGroup(parts: StreamGroupPart[], opts: StreamGroupOpt
return html`
<div class=${groupClass} data-chat-row-key=${parts[0]?.key ?? nothing}>
${avatar}
<div class="chat-group-messages">
${parts.map((part) =>
part.kind === "reading-indicator"
? renderChatWorkingIndicator(
part,
opts.waitingApproval === true,
opts.startupPhase,
opts.runOutputTokens,
)
: part.kind === "question"
? renderQuestionStreamPart(part, opts)
: part.kind === "plan"
? renderChatPlanChecklist(opts.planStatus, {
active: opts.planActive === true,
variant: "card",
})
: renderGroupedMessage(
{
role: "assistant",
content: [{ type: "text", text: part.text }],
timestamp: part.startedAt,
},
part.key,
{ isStreaming: part.isStreaming, showReasoning: false },
onOpenSidebar,
),
)}
</div>
<div class="chat-group-messages">${renderStreamGroupParts(parts, opts, "standalone")}</div>
${footerStartedAt !== null
? html`
<div class="chat-group-footer">
@@ -1592,6 +1592,40 @@ describe("grouped chat rendering", () => {
expect(container.querySelector(".chat-group-footer")).toBeNull();
});
it("morphs one assistant turn from working status to its terminal recap", () => {
const container = document.createElement("div");
const message = {
role: "assistant",
content: "First result is ready.",
timestamp: 1_000,
};
renderAssistantMessage(container, message, {
activeContinuation: {
parts: [{ kind: "reading-indicator", key: "reading", startedAt: 1_000 }],
options: {},
},
});
expect(container.querySelectorAll(".chat-group.assistant")).toHaveLength(1);
expect(container.querySelector(".chat-reading-indicator")).toBeNull();
expect(container.querySelector(".chat-working-indicator--continuation")).not.toBeNull();
expect(container.querySelector(".chat-working-indicator__status")?.textContent).toContain(
"Working…",
);
renderAssistantMessage(container, message, {
turnRecap: { runtimeMs: 5_000, outputTokens: 42 },
});
expect(container.querySelectorAll(".chat-group.assistant")).toHaveLength(1);
expect(container.querySelector(".chat-working-indicator")).toBeNull();
expect(container.querySelector(".chat-turn-recap--continuation")?.textContent).toContain(
"Done in 5s",
);
expect(container.querySelector(".chat-tasks-status__claw")).toBeNull();
});
it("renders the active startup phase with elapsed time", () => {
const container = document.createElement("div");
@@ -10,3 +10,4 @@ export {
export { renderMessageGroup } from "./chat-message-group.ts";
export type { MessageReplyTarget } from "./chat-message-markdown.ts";
export { renderStreamGroup, renderWorkGroupSummary } from "./chat-message-stream.ts";
export type { StreamGroupOptions, StreamGroupPart } from "./chat-message-stream.ts";
+88 -12
View File
@@ -46,9 +46,10 @@ import {
resolveUiGlobalAliasAgentId,
type UiSessionDefaultsHost,
} from "../../../lib/sessions/session-key.ts";
import { resolveTurnRecap } from "../chat-progress.ts";
import { resolveTurnRecap, type TurnRecap } from "../chat-progress.ts";
import type { ChatRunStartupStatus } from "../chat-run-startup.ts";
import {
assistantGroupCanOwnActiveRunStatus,
buildCachedChatItems,
coalesceStreamRuns,
collapseCompletedTurnWork,
@@ -78,6 +79,8 @@ import {
renderStreamGroup,
renderWorkGroupSummary,
type MessageReplyTarget,
type StreamGroupOptions,
type StreamGroupPart,
} from "./chat-message.ts";
import { renderRealtimeTalkConversation } from "./chat-realtime-controls.ts";
import { handleChatSelectionPointerUp, removeChatSelectionPopup } from "./chat-selection-popup.ts";
@@ -1125,10 +1128,22 @@ function trackTranscriptRenderDependencies(
return dependencies;
}
function guardChatRenderItems(state: ChatThreadState, render: (item: ChatRenderItem) => unknown) {
function guardChatRenderItems(
state: ChatThreadState,
// Turn status ownership is decided by sibling rows, not by the owning row's
// own content: an unchanged reply that gains or loses the embedded working
// row / recap must re-render, or the stale copy stacks with the new owner.
statusOwnership: (item: ChatRenderItem) => string,
render: (item: ChatRenderItem) => unknown,
) {
return (item: ChatRenderItem) =>
guard([...chatRenderItemGuardDependencies(item), state.transcriptRenderContext], () =>
render(item),
guard(
[
...chatRenderItemGuardDependencies(item),
state.transcriptRenderContext,
statusOwnership(item),
],
() => render(item),
);
}
@@ -1229,6 +1244,11 @@ function renderChatThreadContents(
const showLoadingSkeleton = props.loading && chatItems.length === 0;
const threadContextWindow =
activeSession?.contextTokens ?? props.sessions?.defaults?.contextTokens ?? null;
const activeContinuationByGroupKey = new Map<
string,
{ parts: StreamGroupPart[]; options: StreamGroupOptions }
>();
const turnRecapByGroupKey = new Map<string, TurnRecap>();
const renderGroupItem = (item: MessageGroup) => {
if (deleted.has(item.key)) {
return nothing;
@@ -1293,9 +1313,23 @@ function renderChatThreadContents(
}
: undefined,
rewindDisabled: Boolean(props.runActive || props.runWorking),
activeContinuation: activeContinuationByGroupKey.get(item.key),
turnRecap: turnRecapByGroupKey.get(item.key),
});
};
const renderItem = guardChatRenderItems(state, (item) => {
const statusOwnershipSignature = (item: ChatRenderItem): string => {
if (item.kind !== "group") {
return "";
}
const continuation = activeContinuationByGroupKey.get(item.key);
const recap = turnRecapByGroupKey.get(item.key);
// Part keys stand in for the continuation: its options mirror props that
// already invalidate every row through the shared render context.
return `${continuation?.parts.map((part) => part.key).join("") ?? ""}|${
recap ? `${recap.runtimeMs}:${recap.outputTokens ?? ""}` : ""
}`;
};
const renderItem = guardChatRenderItems(state, statusOwnershipSignature, (item) => {
if (item.kind === "divider") {
return renderChatDivider(item, props.onOpenSessionCheckpoints);
}
@@ -1344,7 +1378,54 @@ function renderChatThreadContents(
runWorking: Boolean(props.runWorking),
searchActive: state.searchOpen && Boolean(state.searchQuery.trim()),
});
const transcriptRows: ChatTranscriptRow[] = collapsedItems.map((item) => ({
// Watch/settle on actual indicator visibility (not runWorking): queued
// sends show the claw before the run starts, and the recap must never
// stack under a visible working row.
const workingIndicatorVisible = chatItems.some((item) => item.kind === "reading-indicator");
const turnRecap = resolveTurnRecap(props.sessionKey, workingIndicatorVisible, activeSession);
const transcriptItems = collapsedItems.filter((item, index) => {
if (item.kind !== "stream-run") {
return true;
}
const previous = collapsedItems[index - 1];
const isActiveStatusRun =
item.parts.some((part) => part.kind === "reading-indicator") &&
item.parts.every((part) => part.kind === "reading-indicator" || part.kind === "plan");
if (
previous?.kind !== "group" ||
!isActiveStatusRun ||
deleted.has(previous.key) ||
!assistantGroupCanOwnActiveRunStatus(previous)
) {
return true;
}
// A reply and its still-running state are one turn-level presentation.
// Keeping the status in the reply avoids a second claw/assistant row.
activeContinuationByGroupKey.set(previous.key, {
parts: item.parts,
options: {
planStatus: props.planStatus,
planActive: Boolean(props.runActive),
startupPhase: props.startupStatus?.phase,
waitingApproval: props.waitingApproval,
runOutputTokens: props.runOutputTokens,
},
});
return false;
});
let turnRecapOwnerKey: string | null = null;
if (turnRecap !== null) {
const lastItem = transcriptItems.at(-1);
if (
lastItem?.kind === "group" &&
!deleted.has(lastItem.key) &&
assistantGroupCanOwnActiveRunStatus(lastItem)
) {
turnRecapByGroupKey.set(lastItem.key, turnRecap);
turnRecapOwnerKey = lastItem.key;
}
}
const transcriptRows: ChatTranscriptRow[] = transcriptItems.map((item) => ({
kind: "item",
key: item.key,
item,
@@ -1357,12 +1438,7 @@ function renderChatThreadContents(
content: realtimeConversation,
});
}
// Watch/settle on actual indicator visibility (not runWorking): queued
// sends show the claw before the run starts, and the recap must never
// stack under a visible working row.
const workingIndicatorVisible = chatItems.some((item) => item.kind === "reading-indicator");
const turnRecap = resolveTurnRecap(props.sessionKey, workingIndicatorVisible, activeSession);
if (turnRecap !== null && !isEmpty && !showLoadingSkeleton) {
if (turnRecap !== null && turnRecapOwnerKey === null && !isEmpty && !showLoadingSkeleton) {
transcriptRows.push({
kind: "content",
key: "turn-recap",
@@ -60,31 +60,49 @@ function stanceClass(key: string): string {
export function renderChatWorkingIndicator(
part: Extract<ChatItem, { kind: "reading-indicator" }>,
waitingApproval = false,
startupPhase?: ChatRunStartupPhase,
outputTokens?: number | null,
options: {
waitingApproval?: boolean;
startupPhase?: ChatRunStartupPhase;
outputTokens?: number | null;
presentation?: "standalone" | "continuation";
} = {},
) {
const waitingApproval = options.waitingApproval === true;
const continuation = options.presentation === "continuation";
// The animated claw stays decorative; the text status exposes progress without
// announcing every elapsed-time tick to screen readers.
return html`
<div class="chat-working-indicator" role="status" aria-live="off">
<div class="chat-bubble chat-reading-indicator ${stanceClass(part.key)}" aria-hidden="true">
${icons.claw}
</div>
<div
class="chat-working-indicator ${continuation ? "chat-working-indicator--continuation" : ""}"
role="status"
aria-live="off"
>
${continuation
? nothing
: html`
<div
class="chat-bubble chat-reading-indicator ${stanceClass(part.key)}"
aria-hidden="true"
>
${icons.claw}
</div>
`}
<span class="chat-working-indicator__status">
${waitingApproval
? html`<span>${t("chat.waitingForApproval")}</span>`
: startupPhase
: options.startupPhase
? html`
<span>${startupStatusLabel(startupPhase)}</span>
<span>${startupStatusLabel(options.startupPhase)}</span>
<openclaw-elapsed-time
class="chat-working-indicator__elapsed"
.startMs=${part.startedAt}
></openclaw-elapsed-time>
${renderLiveOutputTokens(outputTokens)}
${renderLiveOutputTokens(options.outputTokens)}
`
: html`
<span class="agent-chat__sr-only">${t("common.working")}</span>
<span class=${continuation ? "" : "agent-chat__sr-only"}
>${t("common.working")}</span
>
<openclaw-elapsed-time
class="chat-working-indicator__elapsed"
.startMs=${part.startedAt}
@@ -94,7 +112,7 @@ export function renderChatWorkingIndicator(
.startMs=${part.startedAt}
.seed=${part.key}
></openclaw-working-phrase>
${renderLiveOutputTokens(outputTokens)}
${renderLiveOutputTokens(options.outputTokens)}
`}
</span>
</div>
@@ -104,7 +122,11 @@ export function renderChatWorkingIndicator(
/** Post-turn recap row: once the run settles, the parked claw reports how
* long the turn took (and its output tokens when the terminal patch carried
* them). Sticky until the next run replaces it. */
export function renderTurnRecapRow(recap: TurnRecap) {
export function renderTurnRecapRow(
recap: TurnRecap,
options: { presentation?: "standalone" | "continuation" } = {},
) {
const continuation = options.presentation === "continuation";
// Sub-second turns still read "1s"; the clamp also keeps the type a string.
const clampedMs = Math.max(1_000, recap.runtimeMs);
const duration = formatDurationCompact(clampedMs, { spaced: true }) ?? "1s";
@@ -116,8 +138,15 @@ export function renderTurnRecapRow(recap: TurnRecap) {
: t("chat.turnRecap.tokens", { count: formatCompactTokenCount(recap.outputTokens) })
: null;
return html`
<div class="chat-tasks-status chat-turn-recap" role="status">
<span class="chat-tasks-status__claw" aria-hidden="true">${icons.claw}</span>
<div
class="chat-tasks-status chat-turn-recap ${continuation
? "chat-turn-recap--continuation"
: ""}"
role="status"
>
${continuation
? nothing
: html`<span class="chat-tasks-status__claw" aria-hidden="true">${icons.claw}</span>`}
<span>${t("chat.turnRecap.doneIn", { duration })}</span>
${tokens === null
? nothing
+11
View File
@@ -1170,6 +1170,17 @@
color: var(--muted);
}
.chat-working-indicator--continuation {
gap: 6px;
min-height: 0;
margin-top: 4px;
}
.chat-turn-recap--continuation {
margin-bottom: 0;
font-size: var(--control-ui-text-sm);
}
.chat-reading-indicator svg {
width: 18px;
height: 18px;