mirror of
https://github.com/openclaw/openclaw.git
synced 2026-11-03 22:24:47 +00:00
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:
parent
a0a93bdde7
commit
3e4daf8497
8 changed files with 312 additions and 133 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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% Control UI 84% 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";
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue