fix(ui): preserve completed chat visual order (#161473)

Keep browser previews beside their original preceding visible output when
completed work collapses. Record anchors in the existing work-group partition,
then apply them after whole-group tab/page deduplication in both transcript
and run-frame rendering. Later previews use separate measured virtual rows;
streaming and expanded work keep their existing paths.

Remove the extra preview wrapper and skip preview preparation for groups
without browser tabs, while reusing cached tool-card extraction. Simplify
row insertion, share collapsed-tool summary setup and review-chip rendering,
remove repeated translation-key prefixes and a single-use review-label
wrapper, and reuse the already-selected footer action owners. Preserve all
existing labels, action ownership, deduplication, and regression assertions.
Production delta: +130/-132, net -2 lines (tests and docs excluded).

The nested exec/show_widget/embed/browser open-close regression reproduced
browser-before-widget on the base and passes after cleanup for widget-first,
browser-first, and run-frame histories, without duplicate cards or row
overlaps. All four E2E cases pass (9.59s suite execution; 134.92s including
harness startup). Focused validation passes 731 unit tests and 2 browser
component tests across 24 files in 98.24s, plus UI typecheck, formatting,
lint, and diff checks. Synthetic before/after captures remain local.
This commit is contained in:
Peter Steinberger 2026-09-29 19:19:08 -07:00 • committed by GitHub
parent a0a93bdde7
commit 3e4daf8497
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 312 additions and 133 deletions

View file

@ -655,7 +655,9 @@ transcript search is filtering messages.
In completed dashboard turns, commentary, reasoning-only messages, and tool activity
share one **Worked for…** disclosure above the answers. Expanding it shows the
activity in its original order; explicit answer segments and visual results stay
visible below it. Failed tool results after the last answer stay visible outside
visible below it. Browser tab previews keep their execution order relative to those
visible results, so a page opened after an inline widget appears after that widget.
Failed tool results after the last answer stay visible outside
the disclosure until a later answer follows them. This is display grouping, not a
change to stored history. Live turns, search results, and turns without an answer
stay expanded. User messages,

View file

@ -1,6 +1,8 @@
// Control UI E2E covers completed-work expansion and persistent visual outcomes.
import path from "node:path";
import { beforeEach, expect, it } from "vitest";
import { projectChatDisplayMessages } from "../../../src/gateway/chat-display-projection.ts";
import { createNestedToolActivity } from "../../../src/sessions/nested-tool-activity.ts";
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
let artifactDir: string | undefined;
@ -36,6 +38,183 @@ async function captureProof(page: import("playwright").Page, name: string) {
suite.define(() => {
const canvasView = useCanvasSandboxFixture();
it.each([
{ scenario: "widget first", browserFirst: false, framed: false },
{ scenario: "browser first", browserFirst: true, framed: false },
{ scenario: "widget first in a run frame", browserFirst: false, framed: true },
])(
"keeps widgets and browser previews in execution order ($scenario)",
async ({ browserFirst, framed }) => {
await suite.withPage(
{ viewport: { width: 1200, height: 900 }, deviceScaleFactor: 2, colorScheme: "dark" },
async ({ page }) => {
const sessionKey = "agent:main:dashboard:visual-order";
const viewId = "cv_visual_order";
const canvasResult = {
kind: "canvas",
presentation: { target: "assistant_message", title: "Release confidence" },
view: { id: viewId, url: `/__openclaw__/canvas/documents/${viewId}/index.html` },
};
const browserTab = {
target: "host",
profile: "managed",
targetId: "visual-order-tab",
title: "Release reference",
url: "https://example.com/release",
};
const nestedExec = (
id: string,
toolName: string,
input: Record<string, unknown>,
value: unknown,
timestamp: number,
details?: Record<string, unknown>,
) => {
const json = JSON.stringify(value);
const text =
toolName === "browser"
? `<<<EXTERNAL_UNTRUSTED_CONTENT id="1234567890abcdef">>>\nSource: Browser\n---\n${json}\n<<<END_EXTERNAL_UNTRUSTED_CONTENT id="1234567890abcdef">>>`
: json;
const result = { content: [{ type: "text", text }], details };
return [
{
role: "assistant",
content: [{ type: "toolCall", id, name: "exec", arguments: {} }],
timestamp,
},
createNestedToolActivity({
runId: "visual-order-run",
scopeId: id,
afterEntryId: id,
startOrder: 0,
parentToolCallId: id,
toolCallId: `${id}-child`,
toolName,
input,
result,
isError: false,
startedAt: timestamp + 100,
timestamp: timestamp + 200,
}),
{
role: "toolResult",
toolCallId: id,
toolName: "exec",
content: result.content,
timestamp: timestamp + 300,
},
];
};
const widgetMessages = [
...nestedExec(
"widget",
"show_widget",
{ title: "Release confidence" },
canvasResult,
browserFirst ? 3_000 : 2_000,
),
{
role: "assistant",
content: `[embed ref="${viewId}" title="Release confidence" height="1000" /]`,
timestamp: browserFirst ? 4_000 : 3_000,
},
];
const browserMessages = nestedExec(
"open",
"browser",
{ action: "open", targetUrl: browserTab.url },
browserTab,
browserFirst ? 2_000 : 4_000,
{ browserTab },
);
const history = [
{
role: "user",
content: "Show a widget and check the release page.",
timestamp: 1_000,
},
...(browserFirst
? [...browserMessages, ...widgetMessages]
: [...widgetMessages, ...browserMessages]),
...nestedExec(
"close",
"browser",
{ action: "close", targetId: browserTab.targetId },
{ ok: true },
5_000,
),
{
role: "assistant",
content: "The widget and release reference are ready.",
timestamp: 6_000,
},
];
await installMockGateway(page, {
sessionKey,
methodResponses: {
"canvas.document.view": canvasView(`<!doctype html>
<style>body { margin: 0; padding: 24px; background: #132332; color: #eaf5ff; font: 16px system-ui; }
div { margin-top: 18px; padding: 18px; background: #254c65; border-radius: 8px; }</style>
<h2>Release confidence</h2><p>First output · inline widget</p>
<div>Gateway 92% &nbsp; Control UI 84% &nbsp; Mobile 68%</div>`),
},
historyMessages: projectChatDisplayMessages(
history.map((message, index) =>
framed
? Object.assign(message, {
__openclaw: {
id: `visual-${index}`,
seq: index + 1,
runId: "visual-order-run",
},
})
: message,
),
),
});
await page.goto(controlUiSessionUrl(suite.server.baseUrl, sessionKey));
await page
.getByText("The widget and release reference are ready.", { exact: true })
.waitFor();
const widget = page.locator('.chat-tool-card__preview[data-kind="canvas"]');
const browser = page.locator("openclaw-browser-tab-card");
await widget.waitFor();
await browser.waitFor();
await waitForChatScrollIdle(page);
expect(await widget.count()).toBe(1);
expect(await browser.count()).toBe(1);
expect(
await page
.locator('.chat-tool-card__preview[data-kind="canvas"], openclaw-browser-tab-card')
.evaluateAll((elements) =>
elements.map((element) =>
element.tagName === "OPENCLAW-BROWSER-TAB-CARD" ? "browser" : "widget",
),
),
).toEqual(browserFirst ? ["browser", "widget"] : ["widget", "browser"]);
const workedFor = page.locator(".chat-work-group > .chat-activity-group__summary");
expect(await workedFor.getAttribute("aria-expanded")).toBe("false");
for (const expanded of [true, false]) {
await workedFor.click();
await waitForChatScrollIdle(page);
expect(await workedFor.getAttribute("aria-expanded")).toBe(String(expanded));
expect(await widget.count()).toBe(1);
expect(await browser.count()).toBe(1);
const overlaps = await page.locator(".chat-virtual-row").evaluateAll((elements) => {
const rects = elements.map((element) => element.getBoundingClientRect());
return rects.flatMap((rect, index) => {
const next = rects[index + 1];
return next && rect.bottom > next.top
? [{ bottom: rect.bottom, nextTop: next.top }]
: [];
});
});
expect(overlaps).toEqual([]);
}
},
);
},
);
it("keeps visual output visible and virtual rows apart when completed work expands", async () => {
await suite.withPage({ viewport: { height: 900, width: 1200 } }, async ({ page }) => {
const sessionKey = "agent:main:dashboard:worked-for-geometry";

View file

@ -211,6 +211,8 @@ export type WorkGroupRenderItem = {
kind: "work-group";
key: string;
groups: MessageGroup[];
/** Hidden group -> preceding preserved output; absent entries stay under the summary. */
previewAfterGroup?: ReadonlyMap<string, string>;
durationMs: number | null;
};
@ -393,6 +395,8 @@ export function collapseCompletedTurnWork(
}
const groups: MessageGroup[] = [];
const answers: TurnRenderItem[] = [];
const previewAfterGroup = new Map<string, string>();
let precedingAnswerKey: string | undefined;
for (let index = segmentStart; index <= segmentEnd; index += 1) {
const item = turn[index]!;
// Only a later answer can put a failed result inside completed work.
@ -407,12 +411,15 @@ export function collapseCompletedTurnWork(
))
) {
groups.push(item);
if (precedingAnswerKey) {
previewAfterGroup.set(item.key, precedingAnswerKey);
}
} else {
answers.push(item);
precedingAnswerKey = item.key;
}
}
const firstGroup = groups[0];
if (!firstGroup) {
if (groups.length === 0) {
result.push(...turn);
continue;
}
@ -443,6 +450,7 @@ export function collapseCompletedTurnWork(
finalReplyIndex >= 0 || !continuationBoundary ? terminalReply.key : continuationBoundary.key
}`,
groups,
...(previewAfterGroup.size > 0 ? { previewAfterGroup } : {}),
durationMs,
});
result.push(...answers, ...turn.slice(segmentEnd + 1));

View file

@ -20,7 +20,7 @@ import {
type StreamGroupPart,
} from "./chat-message.ts";
import { renderChatSourcePreviews } from "./chat-source-previews.ts";
import { renderBrowserTabPreviews } from "./chat-tool-cards.ts";
import { renderWorkGroupBrowserTabPreviews } from "./chat-tool-cards.ts";
type MessageGroupRenderOptions = Parameters<typeof renderMessageGroup>[1];
@ -72,6 +72,12 @@ export function renderAgentRunFrame(frame: AgentRunFrameRenderItem, opts: AgentR
const bodyParts = frame.parts.flatMap<BodyPart>((part) =>
part.kind === "stream-run" ? part.parts : [part],
);
const workPreviews = renderWorkGroupBrowserTabPreviews(
frame.parts.flatMap((part) =>
part.kind === "work-group" && !opts.isWorkExpanded(part.key) ? [part] : [],
),
opts.renderGroupOptions(shell),
);
const frameContent = [
repeat(
bodyParts,
@ -91,10 +97,7 @@ export function renderAgentRunFrame(frame: AgentRunFrameRenderItem, opts: AgentR
expanded,
onToggle: () => opts.onToggleWork(part.key, expanded),
presentation: "continuation",
browserTabPreviews: renderBrowserTabPreviews(
part.groups,
opts.renderGroupOptions(shell),
),
browserTabPreviews: workPreviews.get(part.key),
})}
${expanded ? part.groups.map(renderFrameGroup) : nothing}
`;
@ -105,7 +108,7 @@ export function renderAgentRunFrame(frame: AgentRunFrameRenderItem, opts: AgentR
? renderActivityGroup(part.groups, opts.renderGroupOptions(firstGroup), "continuation")
: nothing;
}
return renderFrameGroup(part);
return html`${renderFrameGroup(part)}${workPreviews.get(part.key) ?? nothing}`;
},
),
actionOwner

View file

@ -296,9 +296,7 @@ export function renderActivityGroup(
const reviewOutcome = resolveToolApprovalReviewOutcome(approvalReviews, recordedReviewOutcomes);
const reviewer = approvalReviews[0]?.label ?? "Review";
const reviewAriaLabel = reviewOutcome
? t(`chat.toolCards.review.${reviewOutcome === "reviewing" ? "reviewing" : reviewOutcome}`, {
reviewer,
})
? t(`chat.toolCards.review.${reviewOutcome}`, { reviewer })
: "";
const content = html`
<div class="chat-activity-group ${activityExpanded ? "is-open" : ""}">
@ -529,12 +527,8 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
: group.messages;
const preparedMessages = actionOwners.map((item) => prepareGroupMessage(group, item, opts));
const lastMessageIndex = group.messages.length - 1;
const footerActionDetails = ownsRunFrame
? (preparedMessages[0]?.actions ?? null)
: (preparedMessages[lastMessageIndex]?.actions ?? null);
const footerActionMessageKey = ownsRunFrame
? opts.frameActionOwner?.key
: group.messages[lastMessageIndex]?.key;
const footerActionDetails = preparedMessages.at(-1)?.actions ?? null;
const footerActionMessageKey = actionOwners.at(-1)?.key;
const hasUserFooterActions =
normalizedRole === "user" &&
Boolean(

View file

@ -12,12 +12,7 @@ import {
import { icons, type IconName } from "../../../components/icons.ts";
import { t } from "../../../i18n/index.ts";
import { browserTabCardRevision } from "../../../lib/chat/browser-tab-preview.ts";
import type {
MessageGroup,
ToolApprovalReview,
ToolCard,
ToolCardOutcome,
} from "../../../lib/chat/chat-types.ts";
import type { MessageGroup, ToolCard, ToolCardOutcome } from "../../../lib/chat/chat-types.ts";
import { readToolApprovalReviews } from "../../../lib/chat/tool-approval-reviews.ts";
import { resolveToolCallView, type ToolCallView } from "../../../lib/chat/tool-call-view.ts";
import {
@ -30,6 +25,7 @@ import {
} from "../../../lib/chat/tool-cards.ts";
import { resolveToolDisplay } from "../../../lib/chat/tool-display.ts";
import { renderPluginSurface } from "../../../plugins/control-ui-view.ts";
import type { WorkGroupRenderItem } from "../chat-thread-grouping.ts";
import type { PluginToolIcons } from "../chat-tool-icon-controller.ts";
import { renderHighlightedCommand } from "./chat-command-highlight.ts";
import { renderDiffStatChips } from "./chat-diff-render.ts";
@ -44,17 +40,25 @@ import { renderToolPreview } from "./widget-card.ts";
export function renderBrowserTabPreviews(
groups: readonly MessageGroup[],
options: { sessionKey?: string; latestBrowserTabs?: ReadonlyMap<string, BrowserTabSelection> },
placePreview?: (groupKey: string, content: unknown) => void,
) {
const cards = groups.flatMap((group) =>
group.messages.flatMap((item) => extractToolCardsCached(item.message)),
group.messages.flatMap((item) =>
extractToolCardsCached(item.message)
.filter((card) => card.browserTab)
.map((card) => ({ card, groupKey: group.key })),
),
);
if (cards.length === 0) {
return [];
}
// Select each tab's final state before collapsing reopened pages. A newer
// blank/non-web result must still retire that tab's older web preview.
const seenTabs = new Set<string>();
const seenPages = new Set<string>();
return cards
.toReversed()
.flatMap((card) => {
.flatMap(({ card, groupKey }) => {
if (!card.browserTab || resolveToolCardOutcome(card, false) !== "succeeded") {
return [];
}
@ -77,18 +81,38 @@ export function renderBrowserTabPreviews(
return [];
}
seenPages.add(pageKey);
const revision = browserTabCardRevision(card);
return [
renderToolPreview(preview, "chat_tool", {
browserTabRevision: revision ? JSON.stringify([options.sessionKey, revision]) : undefined,
browserTabLatest: Boolean(
revision &&
options.latestBrowserTabs?.get(browserTabKey(preview))?.revision === revision,
),
}),
];
return [{ card, groupKey, preview }];
})
.toReversed();
.toReversed()
.map(({ card, groupKey, preview }) => {
const revision = browserTabCardRevision(card);
const content = renderToolPreview(preview, "chat_tool", {
browserTabRevision: revision ? JSON.stringify([options.sessionKey, revision]) : undefined,
browserTabLatest: Boolean(
revision && options.latestBrowserTabs?.get(browserTabKey(preview))?.revision === revision,
),
});
placePreview?.(groupKey, content);
return content;
});
}
export function renderWorkGroupBrowserTabPreviews(
items: readonly WorkGroupRenderItem[],
options: Parameters<typeof renderBrowserTabPreviews>[1],
) {
const byAnchor = new Map<string, unknown[]>();
// Deduplicate the whole turn before placing previews, so later blank tabs or
// repeated page opens cannot resurrect an earlier preview across an answer.
for (const item of items) {
renderBrowserTabPreviews(item.groups, options, (groupKey, content) => {
const anchor = item.previewAfterGroup?.get(groupKey) ?? item.key;
const previews = byAnchor.get(anchor) ?? [];
previews.push(content);
byAnchor.set(anchor, previews);
});
}
return byAnchor;
}
export function renderToolIcon(
@ -118,31 +142,11 @@ const TOOL_ROW_VERB_KEYS: Partial<Record<ToolCallView["kind"], string>> = {
};
const MUTATION_VERB_KEYS = {
update: [
"chat.toolCards.verbs.editing",
"chat.toolCards.verbs.edited",
"chat.toolCards.verbs.edit",
],
add: [
"chat.toolCards.verbs.creating",
"chat.toolCards.verbs.created",
"chat.toolCards.verbs.create",
],
delete: [
"chat.toolCards.verbs.deleting",
"chat.toolCards.verbs.deleted",
"chat.toolCards.verbs.delete",
],
mixed: [
"chat.toolCards.verbs.changing",
"chat.toolCards.verbs.changed",
"chat.toolCards.verbs.change",
],
write: [
"chat.toolCards.verbs.writing",
"chat.toolCards.verbs.wrote",
"chat.toolCards.verbs.write",
],
update: ["editing", "edited", "edit"],
add: ["creating", "created", "create"],
delete: ["deleting", "deleted", "delete"],
mixed: ["changing", "changed", "change"],
write: ["writing", "wrote", "write"],
} as const;
function resolveMutationVerbKind(view: ToolCallView): keyof typeof MUTATION_VERB_KEYS | undefined {
@ -160,7 +164,9 @@ function resolveToolRowVerb(view: ToolCallView, outcome: ToolCardOutcome): strin
const mutation = resolveMutationVerbKind(view);
if (mutation) {
const [running, succeeded, fallback] = MUTATION_VERB_KEYS[mutation];
return t(outcome === "running" ? running : outcome === "succeeded" ? succeeded : fallback);
return t(
`chat.toolCards.verbs.${outcome === "running" ? running : outcome === "succeeded" ? succeeded : fallback}`,
);
}
const key = TOOL_ROW_VERB_KEYS[view.kind];
return key ? t(key) : undefined;
@ -257,12 +263,7 @@ function renderToolRowContent(
`;
}
const display = resolveToolDisplay({ name: card.name, args: card.args, detailMode: "explain" });
const summary = resolveCollapsedToolSummaryParts({
card,
displayLabel: display.label,
displayDetail: display.detail,
});
const summary = resolveCollapsedToolSummaryParts(card);
const displayLabel = formatCollapsedToolSummaryText(summary.label) ?? summary.label;
const displayName = distinctSummaryText(summary.name, displayLabel);
return html`
@ -328,40 +329,28 @@ function renderProgressCardReceipt(card: ToolCard, outcome: ToolCardOutcome) {
}
export function resolveCollapsedToolDetail(card: ToolCard, displayDetail: string | undefined) {
const directDetail = displayDetail?.trim();
if (directDetail) {
if (displayDetail?.trim()) {
return displayDetail;
}
if (typeof card.args !== "string") {
return undefined;
}
const inputText = card.inputText?.trim() ? card.inputText : card.args;
return formatCollapsedToolPreviewText(inputText);
return typeof card.args === "string"
? formatCollapsedToolPreviewText(card.inputText?.trim() ? card.inputText : card.args)
: undefined;
}
function resolveCollapsedToolSummaryParts(params: {
card: ToolCard;
displayLabel: string;
displayDetail: string | undefined;
}): { label: string; name?: string } {
const displayDetail = params.displayDetail?.trim();
function resolveCollapsedToolSummaryParts(card: ToolCard): { label: string; name?: string } {
const display = resolveToolDisplay({ name: card.name, args: card.args, detailMode: "explain" });
const displayDetail = display.detail?.trim();
// Message captions belong to the canonical publication, not the original tool input.
if (params.card.name.trim().toLowerCase() === "message") {
return { label: params.displayLabel, name: displayDetail || undefined };
if (card.name.trim().toLowerCase() === "message") {
return { label: display.label, name: displayDetail || undefined };
}
const argumentPreview = toolArgumentPreview(params.card.args);
if (argumentPreview) {
return { label: params.displayLabel, name: argumentPreview };
}
if (displayDetail) {
return { label: params.displayLabel, name: displayDetail };
const name = toolArgumentPreview(card.args) || displayDetail;
if (name) {
return { label: display.label, name };
}
return {
label:
typeof params.card.args === "string"
? (resolveCollapsedToolDetail(params.card, undefined) ?? params.displayLabel)
: params.displayLabel,
label: resolveCollapsedToolDetail(card, undefined) ?? display.label,
};
}
@ -376,25 +365,10 @@ function resolveToolRowText(card: ToolCard, view: ToolCallView, outcome: ToolCar
if (verb && view.target) {
return `${verb} ${view.target}`;
}
const display = resolveToolDisplay({ name: card.name, args: card.args, detailMode: "explain" });
const summary = resolveCollapsedToolSummaryParts({
card,
displayLabel: display.label,
displayDetail: display.detail,
});
const summary = resolveCollapsedToolSummaryParts(card);
return [summary.label, summary.name].filter(Boolean).join(" ");
}
function toolReviewLabel(review: ToolApprovalReview): string {
const key =
review.status === "in_progress"
? "reviewing"
: review.status === "timed_out"
? "timedOut"
: review.status;
return t(`chat.toolCards.review.${key}`, { reviewer: review.label });
}
export function renderToolApprovalReviews(card: ToolCard) {
const reviews = readToolApprovalReviews(card.details);
if (reviews.length === 0) {
@ -404,29 +378,31 @@ export function renderToolApprovalReviews(card: ToolCard) {
<div class="chat-tool-reviews">
${reviews.map((review) => {
const adverse = ["denied", "timed_out", "aborted"].includes(review.status);
const key =
review.status === "in_progress"
? "reviewing"
: review.status === "timed_out"
? "timedOut"
: review.status;
return html`
<div class="chat-tool-review" data-review-status=${review.status}>
<div class="chat-tool-review__header">
<span class="chat-tool-review__icon"
>${adverse ? icons.shieldX : icons.shieldCheck}</span
>
<span class="chat-tool-review__label">${toolReviewLabel(review)}</span>
${
review.riskLevel
<span class="chat-tool-review__label"
>${t(`chat.toolCards.review.${key}`, { reviewer: review.label })}</span
>
${[
{ kind: "risk", level: review.riskLevel },
{ kind: "authorization", level: review.userAuthorization },
].map(({ kind, level }) =>
level
? html`<span class="chat-tool-review__chip"
>${t("chat.toolCards.review.risk", { level: review.riskLevel })}</span
>${t(`chat.toolCards.review.${kind}`, { level })}</span
>`
: nothing
}
${
review.userAuthorization
? html`<span class="chat-tool-review__chip"
>${t("chat.toolCards.review.authorization", {
level: review.userAuthorization,
})}</span
>`
: nothing
}
: nothing,
)}
</div>
${
review.status === "in_progress"

View file

@ -1,6 +1,6 @@
// Chat-item projection, expansion, reply hydration, and guarded row rendering.
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import { nothing } from "lit";
import { html, nothing } from "lit";
import { classifySessionKind } from "../../../../../src/sessions/classify-session-kind.js";
import { markdownGitHubAliasSignature } from "../../../components/markdown-github-repositories.ts";
import { currentThemeBranding } from "../../../components/neutral-mark.ts";
@ -56,7 +56,7 @@ import {
getTranscriptState,
type ChatThreadProps,
} from "./chat-thread-interactions.ts";
import { renderBrowserTabPreviews } from "./chat-tool-cards.ts";
import { renderWorkGroupBrowserTabPreviews } from "./chat-tool-cards.ts";
import { latestTranscriptAnnouncement } from "./chat-transcript-announcement.ts";
import type { TranscriptRow } from "./chat-transcript-layout.ts";
import {
@ -228,6 +228,12 @@ export function projectChatTranscript(
}
const { messageRowKeysById, transcriptMessageKeys, loadedReplySources, positionIndex, rows } =
projectTranscriptIndex(transcriptChain, expandedToolCards, props);
const workPreviews = renderWorkGroupBrowserTabPreviews(
transcriptItems.flatMap((item) =>
item.kind === "work-group" && !expandedToolCards.get(item.key) ? [item] : [],
),
{ sessionKey: props.sessionKey, latestBrowserTabs },
);
const questionPrompts = new Map(
(props.questionPrompts ?? []).map((prompt) => [prompt.id, prompt]),
);
@ -476,10 +482,7 @@ export function projectChatTranscript(
const workExpanded = expandedToolCards.get(item.key) ?? false;
return renderWorkGroupSummary(item, {
expanded: workExpanded,
browserTabPreviews: renderBrowserTabPreviews(item.groups, {
sessionKey: props.sessionKey,
latestBrowserTabs,
}),
browserTabPreviews: workPreviews.get(item.key),
onToggle: () => toggleToolCardExpanded(item.key, workExpanded),
});
}
@ -556,7 +559,20 @@ export function projectChatTranscript(
turnRecapByGroupKey.set(tailStatusOwner.key, turnRecap);
turnRecapOwnerKey = tailStatusOwner.key;
}
const transcriptRows: TranscriptRow<ChatRenderItem>[] = [...rows];
const transcriptRows: TranscriptRow<ChatRenderItem>[] = [];
for (const row of rows) {
transcriptRows.push(row);
const previews = workPreviews.get(row.key);
if (previews && !(row.kind === "item" && row.item.kind === "work-group")) {
transcriptRows.push({
kind: "content",
key: `work-previews:${row.key}`,
content: html`<div class="chat-group tool chat-group--turn-block">
<div class="chat-group-messages">${previews}</div>
</div>`,
});
}
}
// Only ID-bearing voice captions need a history scan. Keep membership local
// to this projection so history replacement and search cannot stale it.
let persistedIds: Set<string | null> | undefined;

View file

@ -9,7 +9,8 @@ function itemDependencies(item: ChatRenderItem): readonly unknown[] {
return [item.key, ...item.parts];
}
if (item.kind === "work-group") {
return [item.key, item.durationMs, ...item.groups];
const anchors = Array.from(item.previewAfterGroup ?? []).flat();
return [item.key, item.durationMs, ...item.groups, ...anchors];
}
if (item.kind === "activity-run") {
return [item.key, ...item.groups];