mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(" | ||||