diff --git a/ui/src/e2e/chat-tool-turn-outcome.e2e.test.ts b/ui/src/e2e/chat-tool-turn-outcome.e2e.test.ts index aed30c6f1d06..ac9e4c83541e 100644 --- a/ui/src/e2e/chat-tool-turn-outcome.e2e.test.ts +++ b/ui/src/e2e/chat-tool-turn-outcome.e2e.test.ts @@ -92,18 +92,18 @@ suite.define(() => { "Tool output", "Tool output", ]); - // Each failure keeps only its per-call badge even when its turn later - // recovers; both row summaries otherwise render neutral. + // Collapsed rows stay neutral even when the call failed; the failure is + // recorded as the expanded body's outcome, with the reported exit code. const summaryClasses = await page .locator(".chat-tool-msg-summary") .evaluateAll((nodes) => nodes.map((node) => node.className)); expect(summaryClasses).toHaveLength(2); expect(summaryClasses[0]).not.toContain("chat-tool-msg-summary--error"); expect(summaryClasses[1]).not.toContain("chat-tool-msg-summary--error"); - expect(await page.locator(".chat-tool-row__badge").allTextContents()).toEqual([ - "failed", - "failed", - ]); + await page.locator(".chat-tool-msg-summary").first().click(); + await expect + .poll(() => page.locator(".chat-tool-card__outcome").first().textContent()) + .toBe("Exit code 1"); await context.close(); }); @@ -175,7 +175,7 @@ suite.define(() => { const activityGeometry = await activity.evaluate((node) => { const container = node.closest(".chat-activity-group"); const label = node.querySelector(".chat-activity-group__label"); - const chevron = node.querySelector(".chat-inline-disclosure__chevron"); + const chevron = node.querySelector(".chat-tool-row__chevron"); if (!container || !label || !chevron) { throw new Error("Expected compact activity disclosure parts"); } @@ -201,11 +201,13 @@ suite.define(() => { const rows = page.locator(".chat-activity-group__body .chat-tool-msg-summary"); expect(await rows.count()).toBe(2); - expect(await rows.locator(".chat-inline-disclosure__chevron").count()).toBe(2); + expect(await rows.locator(".chat-tool-row__chevron").count()).toBe(2); expect(await page.locator(".chat-tool-msg-summary__label", { hasText: "Tool" }).count()).toBe( 0, ); - await rows.first().click(); + // File rows put the workspace link inside the row, so toggle from the icon + // edge instead of the row centre to avoid opening the linked file. + await rows.first().click({ position: { x: 4, y: 4 } }); expect(await page.getByText("offset:", { exact: true }).count()).toBe(1); expect(await page.getByText("limit:", { exact: true }).count()).toBe(1); const patchRow = rows.filter({ hasText: "2 files" }); @@ -385,7 +387,8 @@ suite.define(() => { await card.waitFor(); expect(await card.getByText("query:", { exact: true }).count()).toBe(1); expect(await card.getByText("example", { exact: true }).count()).toBe(1); - await card.getByText("Tool output", { exact: true }).waitFor(); + // Plain output needs no "Tool output" header; the payload is the content. + expect(await card.getByText("Tool output", { exact: true }).count()).toBe(0); await card.getByText("Native result payload", { exact: true }).waitFor(); await captureToolActivityProof(page, "native-result-before-call-expanded"); await context.close(); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 8f5dde2df675..3f1dc3880bcf 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -5767,6 +5767,8 @@ export const en: TranslationMap = { attemptedChanges: "Attempted changes", failed: "failed", running: "Running", + completed: "Completed", + exitCode: "Exit code {code}", noOutputFailed: "No output — tool failed.", noOutputSucceeded: "No output — tool completed successfully.", noResult: "No result available.", diff --git a/ui/src/lib/chat/chat-types.ts b/ui/src/lib/chat/chat-types.ts index 15eb3144af3c..fc029a208165 100644 --- a/ui/src/lib/chat/chat-types.ts +++ b/ui/src/lib/chat/chat-types.ts @@ -243,6 +243,8 @@ export type ToolCard = { details?: unknown; /** Monotonic edit counts while a live tool call is still receiving input. */ liveDiffStat?: { added: number; removed: number }; + /** Producer-reported process exit code, when the result supplies one. */ + exitCode?: number; isError?: boolean; /** True when the card comes from the live tool stream of the current run. */ live?: boolean; diff --git a/ui/src/lib/chat/tool-cards.ts b/ui/src/lib/chat/tool-cards.ts index b04623bd5bac..92e66757fe08 100644 --- a/ui/src/lib/chat/tool-cards.ts +++ b/ui/src/lib/chat/tool-cards.ts @@ -59,6 +59,18 @@ function coerceArgs(value: unknown): unknown { } } +function parseJsonRecord(value: string): Record | null { + const trimmed = value.trim(); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { + return null; + } + try { + return readRecord(JSON.parse(trimmed)); + } catch { + return null; + } +} + function extractToolText(item: Record): string | undefined { if (typeof item.text === "string") { return item.text; @@ -86,6 +98,17 @@ function readToolErrorFlag(value: Record): boolean | undefined return typeof raw === "boolean" ? raw : undefined; } +function readToolExitCode(...values: unknown[]): number | undefined { + for (const value of values) { + const record = readRecord(value); + const exitCode = record?.exitCode ?? record?.exit_code; + if (typeof exitCode === "number" && Number.isInteger(exitCode)) { + return exitCode; + } + } + return undefined; +} + const TOOL_NOT_FOUND_PATTERN = /^tool not found\.?$/i; const MAX_ERROR_DETECT_CHARS = 20_000; const TOOL_ERROR_STATUSES = new Set(["error", "failed", "timeout"]); @@ -356,6 +379,7 @@ function extractToolCards(message: unknown, prefix = "tool"): ToolCard[] { const details = item.details ?? m.details; const preview = extractCanvasFromDetails(details) ?? extractToolPreview(text, name); const isError = readToolErrorFlag(item) ?? messageIsError; + const exitCode = readToolExitCode(item, details, text ? parseJsonRecord(text) : undefined, m); if (existing) { fallbackMatchedCards.add(existing); existing.callId ??= callId; @@ -374,6 +398,9 @@ function extractToolCards(message: unknown, prefix = "tool"): ToolCard[] { if (isError !== undefined) { existing.isError = isError; } + if (exitCode !== undefined) { + existing.exitCode = exitCode; + } continue; } cards.push({ @@ -385,6 +412,7 @@ function extractToolCards(message: unknown, prefix = "tool"): ToolCard[] { ...(details !== undefined ? { details } : {}), messageId: transcriptMessageId, ...(isError !== undefined ? { isError } : {}), + ...(exitCode !== undefined ? { exitCode } : {}), preview, }); } @@ -405,6 +433,7 @@ function extractToolCards(message: unknown, prefix = "tool"): ToolCard[] { "tool"; const text = extractTextCached(message) ?? undefined; const callId = resolveToolCallId({}, m); + const exitCode = readToolExitCode(m, m.details, text ? parseJsonRecord(text) : undefined); cards.push({ id: resolveToolCardId({}, m, 0, prefix), ...(callId ? { callId } : {}), @@ -414,6 +443,7 @@ function extractToolCards(message: unknown, prefix = "tool"): ToolCard[] { ...(m.details !== undefined ? { details: m.details } : {}), messageId: transcriptMessageId, ...(messageIsError !== undefined ? { isError: messageIsError } : {}), + ...(exitCode !== undefined ? { exitCode } : {}), preview: extractCanvasFromDetails(m.details) ?? extractToolPreview(text, name), }); } diff --git a/ui/src/pages/chat/chat-responsive.browser.test.ts b/ui/src/pages/chat/chat-responsive.browser.test.ts index ad1cd2056131..f818b6722645 100644 --- a/ui/src/pages/chat/chat-responsive.browser.test.ts +++ b/ui/src/pages/chat/chat-responsive.browser.test.ts @@ -203,35 +203,39 @@ function activityAlignmentHtml() {
-
A
-
-
@@ -1198,7 +1202,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { const styles = await page.evaluate(() => { const activity = document.querySelector(".chat-activity-group__summary")!; const label = activity.querySelector(".chat-activity-group__label")!; - const chevron = activity.querySelector(".chat-inline-disclosure__chevron")!; + const chevron = activity.querySelector(".chat-tool-row__chevron")!; return { activity: getComputedStyle(activity).userSelect, activityBackground: getComputedStyle(activity).backgroundColor, @@ -1210,7 +1214,8 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { expect(styles).toEqual({ activity: "text", activityBackground: "rgba(0, 0, 0, 0)", - chevronGap: 8, + // Summary gap (8px) less the chevron's own -3px inset. + chevronGap: 5, tool: "text", }); } finally { @@ -1284,7 +1289,6 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
-
A
Tool output
@@ -1292,7 +1296,6 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
-
A
Activity
diff --git a/ui/src/pages/chat/components/chat-diff-render.ts b/ui/src/pages/chat/components/chat-diff-render.ts index f52c430c3356..a3bca36f519a 100644 --- a/ui/src/pages/chat/components/chat-diff-render.ts +++ b/ui/src/pages/chat/components/chat-diff-render.ts @@ -9,12 +9,9 @@ import type { DiffLine, DiffStat } from "../../../lib/chat/tool-call-diff.ts"; export function renderDiffStatChips(stat: DiffStat & { modified?: number }) { // Tool cards omit `modified`; keep their original template byte-for-byte. if (stat.modified === undefined) { - if (stat.added === 0 && stat.removed === 0) { - return nothing; - } return html` - ${stat.added > 0 ? html`+${stat.added}` : nothing} - ${stat.removed > 0 ? html`-${stat.removed}` : nothing} + +${stat.added} + -${stat.removed} `; } if (stat.added === 0 && stat.removed === 0 && !stat.modified) { diff --git a/ui/src/pages/chat/components/chat-message-bubble.ts b/ui/src/pages/chat/components/chat-message-bubble.ts index 34b20cb271ec..44372bd44700 100644 --- a/ui/src/pages/chat/components/chat-message-bubble.ts +++ b/ui/src/pages/chat/components/chat-message-bubble.ts @@ -63,9 +63,12 @@ import { renderExpandedToolCardContent, renderRawOutputToggle, renderToolCard, + renderToolOutcome, renderToolPreview, + isRunningToolCard, resolveCollapsedToolDetail, shouldToggleSelectableDisclosure, + syncToolDisclosureOverflow, } from "./chat-tool-cards.ts"; function renderChatIcon(name: string) { @@ -326,10 +329,15 @@ export function renderGroupedMessage( } const toolMessageDisclosureId = `toolmsg:${messageKey}`; - const toolMessageExpanded = opts.isToolMessageExpanded?.(toolMessageDisclosureId) ?? false; + const toolMessageExpanded = + toolCards.some((card) => isRunningToolCard(card, opts.runActive)) || + (opts.isToolMessageExpanded?.(toolMessageDisclosureId) ?? false); const toolNames = [...new Set(toolCards.map((c) => c.name))]; const singleToolCard = toolCards.length === 1 ? toolCards[0] : null; - const toolMessageHasError = toolCards.some(isToolCardError); + // One expanded card already closes with its own outcome line; every other + // shape renders inline rows only, so the message body records the failure. + const expandsSingleToolCard = Boolean(singleToolCard) && !markdown && !hasImages; + const failedToolCard = expandsSingleToolCard ? undefined : toolCards.find(isToolCardError); const singleToolDisplay = singleToolCard ? resolveToolDisplay({ name: singleToolCard.name, @@ -468,6 +476,8 @@ export function renderGroupedMessage( class="chat-inline-disclosure chat-tool-msg-summary" type="button" aria-expanded=${String(toolMessageExpanded)} + @pointerenter=${syncToolDisclosureOverflow} + @focus=${syncToolDisclosureOverflow} @click=${(event: MouseEvent) => { if (shouldToggleSelectableDisclosure(event)) { opts.onToggleToolMessageExpanded?.(toolMessageDisclosureId); @@ -475,18 +485,15 @@ export function renderGroupedMessage( }} > ${toolMessageIcon} - ${toolMessageLabel} - ${toolSummaryLabel - ? html`${toolSummaryLabel}` - : toolPreview - ? html`${toolPreview}` - : nothing} - - ${toolMessageHasError - ? html`${t("chat.toolCards.failed")}` - : nothing} + + ${toolMessageLabel} + ${toolSummaryLabel + ? html`${toolSummaryLabel}` + : toolPreview + ? html`${toolPreview}` + : nothing} + + ${toolMessageExpanded ? html` @@ -527,7 +534,7 @@ export function renderGroupedMessage( ? renderMarkdownText(markdown, opts.isStreaming, markdownRenderOptions) : nothing} ${hasToolCards - ? singleToolCard && !markdown && !hasImages + ? expandsSingleToolCard && singleToolCard ? renderExpandedToolCardContent( singleToolCard, opts.sessionKey, @@ -552,6 +559,9 @@ export function renderGroupedMessage( allowExternalEmbedUrls: opts.allowExternalEmbedUrls ?? false, }) : nothing} + ${failedToolCard + ? renderToolOutcome("failed", failedToolCard.exitCode) + : nothing}
` : nothing} diff --git a/ui/src/pages/chat/components/chat-message-group.ts b/ui/src/pages/chat/components/chat-message-group.ts index 7b8714af2b41..485f4f322df6 100644 --- a/ui/src/pages/chat/components/chat-message-group.ts +++ b/ui/src/pages/chat/components/chat-message-group.ts @@ -37,16 +37,13 @@ import { type StreamGroupOptions, type StreamGroupPart, } from "./chat-message-stream.ts"; -import { - extractGroupMeta, - renderChatTimestamp, - renderMessageMeta, -} from "./chat-message-timestamp.ts"; +import { extractGroupMeta, renderMessageMeta } from "./chat-message-timestamp.ts"; import type { SidebarContent, SidebarFullMessageLoader } from "./chat-sidebar.ts"; import { isRunningToolCard, resolveToolRowText, shouldToggleSelectableDisclosure, + syncToolDisclosureOverflow, } from "./chat-tool-cards.ts"; import { renderTurnRecapRow } from "./chat-working-indicator.ts"; @@ -250,33 +247,13 @@ export function renderActivityGroup( : summarizeToolGroup(cards.map((card) => ({ name: card.name, args: card.args }))); const activityDisclosureId = `activity:${firstGroup.key}`; const activityBodyId = `activity-body-${fnv1aUtf16(firstGroup.key).toString(16)}`; - const activityExpanded = opts.isToolMessageExpanded?.(activityDisclosureId) ?? false; - const showAvatarGutter = opts.showAvatarGutter !== false; - const assistantName = opts.assistantName ?? "Assistant"; - + const activityExpanded = + Boolean(runningCard) || (opts.isToolMessageExpanded?.(activityDisclosureId) ?? false); return html` `; } @@ -436,7 +411,9 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup style=${senderHue === null ? nothing : `--chat-sender-hue: ${senderHue}`} data-chat-row-key=${group.key} > - ${showAvatarGutter && (normalizedRole !== "assistant" || opts.showAssistantAvatar !== false) + ${normalizedRole !== "tool" && + showAvatarGutter && + (normalizedRole !== "assistant" || opts.showAssistantAvatar !== false) ? renderChatAvatar( group.role, { @@ -491,44 +468,46 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup ? renderTurnRecapRow(opts.turnRecap, { presentation: "continuation" }) : nothing}
- + ${normalizedRole === "tool" + ? nothing + : html``}
`; } diff --git a/ui/src/pages/chat/components/chat-message-stream.ts b/ui/src/pages/chat/components/chat-message-stream.ts index c8ca27cddc6b..702a3f7539d5 100644 --- a/ui/src/pages/chat/components/chat-message-stream.ts +++ b/ui/src/pages/chat/components/chat-message-stream.ts @@ -11,7 +11,7 @@ import { renderGroupedMessage } from "./chat-message-bubble.ts"; import { renderChatTimestamp } from "./chat-message-timestamp.ts"; import { renderChatQuestionSummary } from "./chat-question-card.ts"; import type { SidebarContent } from "./chat-sidebar.ts"; -import { shouldToggleSelectableDisclosure } from "./chat-tool-cards.ts"; +import { shouldToggleSelectableDisclosure, syncToolDisclosureOverflow } 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. */ @@ -156,25 +156,26 @@ export function renderWorkGroupSummary( const label = duration ? t("chat.workRun.workedFor", { duration }) : t("chat.workRun.worked"); return html`
-
+
diff --git a/ui/src/pages/chat/components/chat-message.test.ts b/ui/src/pages/chat/components/chat-message.test.ts index 636d0eaa65cd..5ea0e2ea08ca 100644 --- a/ui/src/pages/chat/components/chat-message.test.ts +++ b/ui/src/pages/chat/components/chat-message.test.ts @@ -2362,8 +2362,8 @@ describe("grouped chat rendering", () => { ".chat-activity-group__summary", HTMLButtonElement, ); - expect(container.querySelector(".chat-activity-group.is-open")).toBeNull(); - expect(activitySummary.getAttribute("aria-expanded")).toBe("false"); + expect(container.querySelector(".chat-activity-group.is-open")).not.toBeNull(); + expect(activitySummary.getAttribute("aria-expanded")).toBe("true"); expect(activitySummary.getAttribute("aria-label")).toBeNull(); expect(activitySummary.classList.contains("chat-activity-group__summary--error")).toBe(false); expect(container.querySelector(".chat-activity-group__label")?.textContent).toBe( @@ -2428,7 +2428,6 @@ describe("grouped chat rendering", () => { ); expect(container.querySelector(".chat-activity-group__summary--error")).toBeNull(); expect(container.querySelectorAll(".chat-tool-msg-summary--error")).toHaveLength(0); - expect(container.querySelector(".chat-tool-row__badge")?.textContent).toBe("failed"); render( renderActivityGroup([successful, failed], { @@ -2594,7 +2593,6 @@ describe("grouped chat rendering", () => { "failed", ); expect(container.querySelectorAll(".chat-tool-msg-summary--error")).toHaveLength(0); - expect(container.querySelector(".chat-tool-row__badge")?.textContent).toBe("failed"); // Command calls render a `$ command` row instead of the tool-name label. expect(summaries[0]?.querySelector(".chat-tool-row__cmd")?.textContent).toBe("run fallback"); }); @@ -2645,9 +2643,8 @@ describe("grouped chat rendering", () => { expect(kvRow?.querySelector(".chat-tool-kv__key")?.textContent).toBe("url:"); expect(kvRow?.querySelector(".chat-tool-kv__value")?.textContent).toBe("https://example.com"); const blocks = Array.from(container.querySelectorAll(".chat-tool-card__block")); - expect( - blocks.map((block) => block.querySelector(".chat-tool-card__block-label")?.textContent), - ).toEqual(["Tool output"]); + // Plain output is the block's default content, so it carries no header. + expect(blocks[0]?.querySelector(".chat-tool-card__block-label")).toBeNull(); expect(blocks[0]?.querySelector("code")?.textContent).toBe("Opened page"); }); @@ -2898,7 +2895,7 @@ describe("grouped chat rendering", () => { expect(summary.querySelector(".chat-tool-msg-summary__error-badge")).toBeNull(); }); - it("marks status-only standalone tool-result summaries with only a failed badge", () => { + it("keeps status-only standalone tool-result summaries neutral until expanded", () => { const container = document.createElement("div"); document.body.append(container); const onToggleToolMessageExpanded = vi.fn(); @@ -2925,7 +2922,6 @@ describe("grouped chat rendering", () => { expect(summary.querySelector(".chat-tool-msg-summary__names")?.textContent).toBe( "sessions_spawn", ); - expect(summary.querySelector(".chat-tool-row__badge")?.textContent).toBe("failed"); selectText(expectElement(summary, ".chat-tool-msg-summary__label", HTMLElement)); pointerClick(summary); expect(onToggleToolMessageExpanded).not.toHaveBeenCalled(); @@ -2941,13 +2937,35 @@ describe("grouped chat rendering", () => { summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement); expect(summary.classList.contains("chat-tool-msg-summary--error")).toBe(false); expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("Tool output"); - expect(summary.querySelector(".chat-tool-row__badge")?.textContent).toBe("failed"); + // The failure stays recorded: the expanded body closes with the outcome. + expect(container.querySelector(".chat-tool-card__outcome")?.textContent).toBe("failed"); expect( JSON.parse(container.querySelector(".chat-json-content code")?.textContent ?? "{}"), ).toEqual({ status: "error" }); container.remove(); }); + it("surfaces a producer-reported exit code in the expanded failure outcome", () => { + const container = document.createElement("div"); + document.body.append(container); + const groups = [ + createMessageGroup( + createToolResultMessage( + "call-exit-code", + "shell", + JSON.stringify({ status: "failed", exitCode: 1 }), + { id: "tool-exit-code" }, + ), + "tool", + ), + ]; + + renderMessageGroups(container, groups, { isToolMessageExpanded: () => true }); + + expect(container.querySelector(".chat-tool-card__outcome")?.textContent).toBe("Exit code 1"); + container.remove(); + }); + it("collapses an inline tool call while keeping matching tool output visible", () => { const container = document.createElement("div"); const groups = [ diff --git a/ui/src/pages/chat/components/chat-tool-cards.outcome.test.ts b/ui/src/pages/chat/components/chat-tool-cards.outcome.test.ts new file mode 100644 index 000000000000..d93a1d67dfad --- /dev/null +++ b/ui/src/pages/chat/components/chat-tool-cards.outcome.test.ts @@ -0,0 +1,209 @@ +/* @vitest-environment jsdom */ + +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { renderToolCard } from "./chat-tool-cards.ts"; + +// Outcome presentation for tool cards: neutral collapsed rows, the expanded +// outcome line, and the compact progress_card receipt. +describe("tool-card outcomes", () => { + it("renders error details with the failure outcome in the expanded body", () => { + const container = document.createElement("div"); + render( + renderToolCard( + { + id: "msg:err:1", + name: "web_search", + args: { query: "python stable version" }, + inputText: '{\n "query": "python stable version"\n}', + outputText: JSON.stringify({ + error: "missing_brave_api_key", + message: "BRAVE_API_KEY is not configured", + }), + }, + { expanded: true, onToggleExpanded: vi.fn() }, + ), + container, + ); + + const summaryButton = container.querySelector("button.chat-tool-msg-summary"); + expect(summaryButton?.classList.contains("chat-tool-msg-summary--error")).toBe(false); + expect(summaryButton?.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe( + "Web Search", + ); + const expandedCard = container.querySelector(".chat-tool-card"); + expect(expandedCard?.classList.contains("chat-tool-card--error")).toBe(true); + expect(container.querySelector(".chat-tool-card__status-badge")).toBeNull(); + expect(container.querySelector(".chat-tool-card__outcome")?.textContent).toBe("failed"); + expect( + Array.from(container.querySelectorAll(".chat-tool-card__block-label")).map( + (label) => label.textContent, + ), + ).toContain("Tool error"); + }); + + it("renders a neutral summary for a status-only error payload", () => { + const container = document.createElement("div"); + render( + renderToolCard( + { + id: "msg:err:status-only", + name: "sessions_spawn", + outputText: JSON.stringify({ status: "error" }), + }, + { expanded: true, onToggleExpanded: vi.fn() }, + ), + container, + ); + + const summary = container.querySelector(".chat-tool-msg-summary"); + expect(summary?.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("Sub-agent"); + expect(container.querySelector(".chat-tool-msg-summary--error")).toBeNull(); + expect(container.querySelector(".chat-tool-card--error")).not.toBeNull(); + expect(container.querySelector(".chat-tool-card__outcome")?.textContent).toBe("failed"); + }); + + it("renders a neutral summary when output is the literal 'Tool not found'", () => { + const container = document.createElement("div"); + render( + renderToolCard( + { + id: "msg:err:2", + name: "Unknown", + outputText: "Tool not found", + }, + { expanded: false, onToggleExpanded: vi.fn() }, + ), + container, + ); + + const summaryButton = container.querySelector("button.chat-tool-msg-summary"); + expect(summaryButton?.classList.contains("chat-tool-msg-summary--error")).toBe(false); + expect(summaryButton?.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe( + "Unknown", + ); + expect(container.querySelector(".chat-tool-msg-body")).toBeNull(); + }); + + it("renders a neutral summary when the tool card has an explicit error flag", () => { + const container = document.createElement("div"); + render( + renderToolCard( + { + id: "msg:err:explicit", + name: "lookup", + outputText: "lookup failed", + isError: true, + }, + { expanded: true, onToggleExpanded: vi.fn() }, + ), + container, + ); + + const summary = container.querySelector(".chat-tool-msg-summary"); + expect(summary?.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("Lookup"); + expect(container.querySelector(".chat-tool-msg-summary--error")).toBeNull(); + expect(container.querySelector(".chat-tool-card--error")).not.toBeNull(); + expect(container.querySelector(".chat-tool-card__outcome")?.textContent).toBe("failed"); + }); + + it("renders a plain error detail when a failed tool has no output", () => { + const container = document.createElement("div"); + render( + renderToolCard( + { + id: "msg:err:no-output", + name: "lookup", + isError: true, + }, + { expanded: true, onToggleExpanded: vi.fn() }, + ), + container, + ); + + expect(container.querySelector(".chat-tool-card__status-badge")).toBeNull(); + expect(container.querySelector(".chat-tool-card__block-label")?.textContent).toBe("Tool error"); + expect(container.querySelector(".chat-tool-card__block-content")?.textContent).toBe( + "No output — tool failed.", + ); + }); + + it("respects an explicit success flag even when the payload looks like an error", () => { + const container = document.createElement("div"); + render( + renderToolCard( + { + id: "msg:err:status-false", + name: "web_search", + outputText: JSON.stringify({ + error: "missing_brave_api_key", + }), + isError: false, + }, + { expanded: false, onToggleExpanded: vi.fn() }, + ), + container, + ); + + expect(container.textContent).toContain("Web Search"); + expect(container.textContent).not.toContain("Tool error"); + expect(container.querySelector(".chat-tool-msg-summary--error")).toBeNull(); + expect(container.querySelector(".chat-tool-msg-summary__error-badge")).toBeNull(); + }); + + it("renders successful output without redundant Tool output labelling", () => { + const container = document.createElement("div"); + render( + renderToolCard( + { + id: "msg:ok:1", + name: "browser.open", + outputText: "Opened page", + }, + { expanded: true, onToggleExpanded: vi.fn() }, + ), + container, + ); + + expect(container.textContent).toContain("Opened page"); + expect(container.textContent).not.toContain("Tool output"); + expect(container.textContent).not.toContain("Tool error"); + expect(container.querySelector(".chat-tool-msg-summary--error")).toBeNull(); + expect(container.querySelector(".chat-tool-card__status-badge")).toBeNull(); + }); + + it.each([ + { + args: { + markdown: "Implementation is moving.", + plan: [ + { step: "Inspect", status: "completed" }, + { step: "Implement", status: "in_progress" }, + { step: "Verify", status: "pending" }, + ], + }, + expected: "Progress updated — 1/3 · Implement", + }, + { args: { markdown: "Waiting on review." }, expected: "Progress note updated" }, + ])("renders progress_card as a compact receipt: $expected", ({ args, expected }) => { + const container = document.createElement("div"); + render( + renderToolCard( + { + id: `progress:${expected}`, + name: "progress_card", + args, + outputText: "Progress card updated", + completed: true, + }, + { expanded: true, onToggleExpanded: vi.fn() }, + ), + container, + ); + + expect(container.textContent?.trim()).toBe(expected); + expect(container.querySelector("button")).toBeNull(); + expect(container.querySelector(".chat-tool-msg-body")).toBeNull(); + expect(container.textContent).not.toContain("Waiting on review."); + }); +}); diff --git a/ui/src/pages/chat/components/chat-tool-cards.test.ts b/ui/src/pages/chat/components/chat-tool-cards.test.ts index 54bf75193741..3bd4a4156264 100644 --- a/ui/src/pages/chat/components/chat-tool-cards.test.ts +++ b/ui/src/pages/chat/components/chat-tool-cards.test.ts @@ -159,9 +159,7 @@ describe("tool-cards", () => { "https://example.com", ); const blocks = Array.from(container.querySelectorAll(".chat-tool-card__block")); - expect( - blocks.map((block) => block.querySelector(".chat-tool-card__block-label")?.textContent), - ).toEqual(["Tool output"]); + expect(blocks[0]?.querySelector(".chat-tool-card__block-label")).toBeNull(); expect(blocks[0]?.querySelector("code")?.textContent).toBe("Opened page"); }); @@ -225,6 +223,7 @@ describe("tool-cards", () => { it("labels a completed Codex file creation from its recorded operation", () => { const container = document.createElement("div"); + const onOpenWorkspaceFile = vi.fn(); render( renderToolCard( { @@ -241,13 +240,76 @@ describe("tool-cards", () => { }, completed: true, }, - { expanded: false, onToggleExpanded: vi.fn() }, + { expanded: false, onOpenWorkspaceFile, onToggleExpanded: vi.fn() }, ), container, ); expect(container.querySelector(".chat-tool-row__verb")?.textContent).toBe("Created"); - expect(container.querySelector(".chat-tool-row__target")?.textContent).toBe("new.ts"); + expect(container.querySelector(".chat-tool-row__file-link")?.textContent?.trim()).toBe( + "new.ts", + ); + container.querySelector(".chat-tool-row__file-link")?.click(); + expect(onOpenWorkspaceFile).toHaveBeenCalledWith({ path: "src/new.ts" }); + }); + + it.each([ + { + label: "multi-file", + patch: [ + "*** Begin Patch", + "*** Update File: src/a.ts", + "@@", + "-old", + "+new", + "*** Add File: src/b.ts", + "+added", + "*** End Patch", + ].join("\n"), + target: "2 files", + }, + { + label: "moved", + patch: [ + "*** Begin Patch", + "*** Update File: src/old.ts", + "*** Move to: src/new.ts", + "@@", + "-old", + "+new", + "*** End Patch", + ].join("\n"), + target: "old.ts → new.ts", + }, + { + // A successful delete removes its own target, so the workspace loader + // would only ever report "Failed to load". + label: "deleted", + patch: ["*** Begin Patch", "*** Delete File: src/gone.ts", "*** End Patch"].join("\n"), + target: "gone.ts", + }, + ])("keeps $label patch summaries non-navigable", ({ patch, target }) => { + const container = document.createElement("div"); + const onOpenWorkspaceFile = vi.fn(); + const onToggleExpanded = vi.fn(); + render( + renderToolCard( + { + id: `msg:patch:${target}`, + name: "apply_patch", + args: { patch }, + completed: true, + }, + { expanded: false, onOpenWorkspaceFile, onToggleExpanded }, + ), + container, + ); + + expect(container.querySelector(".chat-tool-row--file")).toBeNull(); + expect(container.querySelector(".chat-tool-row__target")?.textContent).toBe(target); + container.querySelector(".chat-tool-msg-summary")?.click(); + expect(onToggleExpanded).toHaveBeenCalledOnce(); + expect(onOpenWorkspaceFile).not.toHaveBeenCalled(); }); it("renders edit and write rows from their result outcome", () => { @@ -336,9 +398,9 @@ describe("tool-cards", () => { ); expect(container.querySelector(".chat-diff")?.getAttribute("aria-label")).toBe(state.label); expect(container.querySelector(".chat-diffstat") !== null).toBe(state.hasStat); - expect(container.querySelector(".chat-tool-row__badge")?.textContent === "failed").toBe( - state.failed, - ); + if (state.failed) { + expect(container.querySelector(".chat-tool-card__outcome")?.textContent).toBe("failed"); + } expect(container.querySelector(".chat-tool-msg-summary--error")).toBeNull(); } } @@ -702,7 +764,7 @@ describe("tool-cards", () => { expect(rawToggle!.getAttribute("aria-expanded")).toBe("true"); expect(rawBody!.hidden).toBe(false); - expect(rawBody!.querySelector(".chat-tool-card__block-label")?.textContent).toBe("Tool output"); + expect(rawBody!.querySelector(".chat-tool-card__block-label")).toBeNull(); expect(rawBody!.querySelector("code.markdown-block-art")).toBeNull(); expect(JSON.parse(rawBody!.querySelector("code")?.textContent ?? "{}")).toEqual({ kind: "canvas", @@ -799,205 +861,6 @@ describe("tool-cards", () => { expect(sidebar.entryUrl).toBe("/__openclaw__/canvas/documents/cv_sidebar/index.html"); }); - it("renders error details with only a failed summary badge", () => { - const container = document.createElement("div"); - render( - renderToolCard( - { - id: "msg:err:1", - name: "web_search", - args: { query: "python stable version" }, - inputText: '{\n "query": "python stable version"\n}', - outputText: JSON.stringify({ - error: "missing_brave_api_key", - message: "BRAVE_API_KEY is not configured", - }), - }, - { expanded: true, onToggleExpanded: vi.fn() }, - ), - container, - ); - - const summaryButton = container.querySelector("button.chat-tool-msg-summary"); - expect(summaryButton?.classList.contains("chat-tool-msg-summary--error")).toBe(false); - expect(summaryButton?.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe( - "Web Search", - ); - expect(summaryButton?.querySelector(".chat-tool-row__badge")?.textContent).toBe("failed"); - const expandedCard = container.querySelector(".chat-tool-card"); - expect(expandedCard?.classList.contains("chat-tool-card--error")).toBe(true); - expect(container.querySelector(".chat-tool-card__status-badge")).toBeNull(); - expect( - Array.from(container.querySelectorAll(".chat-tool-card__block-label")).map( - (label) => label.textContent, - ), - ).toContain("Tool error"); - }); - - it("renders a neutral summary for a status-only error payload", () => { - const container = document.createElement("div"); - render( - renderToolCard( - { - id: "msg:err:status-only", - name: "sessions_spawn", - outputText: JSON.stringify({ status: "error" }), - }, - { expanded: true, onToggleExpanded: vi.fn() }, - ), - container, - ); - - const summary = container.querySelector(".chat-tool-msg-summary"); - expect(summary?.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("Sub-agent"); - expect(summary?.querySelector(".chat-tool-row__badge")?.textContent).toBe("failed"); - expect(container.querySelector(".chat-tool-msg-summary--error")).toBeNull(); - expect(container.querySelector(".chat-tool-card--error")).not.toBeNull(); - }); - - it("renders a neutral summary when output is the literal 'Tool not found'", () => { - const container = document.createElement("div"); - render( - renderToolCard( - { - id: "msg:err:2", - name: "Unknown", - outputText: "Tool not found", - }, - { expanded: false, onToggleExpanded: vi.fn() }, - ), - container, - ); - - const summaryButton = container.querySelector("button.chat-tool-msg-summary"); - expect(summaryButton?.classList.contains("chat-tool-msg-summary--error")).toBe(false); - expect(summaryButton?.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe( - "Unknown", - ); - expect(summaryButton?.querySelector(".chat-tool-row__badge")?.textContent).toBe("failed"); - }); - - it("renders a neutral summary when the tool card has an explicit error flag", () => { - const container = document.createElement("div"); - render( - renderToolCard( - { - id: "msg:err:explicit", - name: "lookup", - outputText: "lookup failed", - isError: true, - }, - { expanded: true, onToggleExpanded: vi.fn() }, - ), - container, - ); - - const summary = container.querySelector(".chat-tool-msg-summary"); - expect(summary?.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("Lookup"); - expect(summary?.querySelector(".chat-tool-row__badge")?.textContent).toBe("failed"); - expect(container.querySelector(".chat-tool-msg-summary--error")).toBeNull(); - expect(container.querySelector(".chat-tool-card--error")).not.toBeNull(); - }); - - it("renders a plain error detail when a failed tool has no output", () => { - const container = document.createElement("div"); - render( - renderToolCard( - { - id: "msg:err:no-output", - name: "lookup", - isError: true, - }, - { expanded: true, onToggleExpanded: vi.fn() }, - ), - container, - ); - - expect(container.querySelector(".chat-tool-card__status-badge")).toBeNull(); - expect(container.querySelector(".chat-tool-card__block-label")?.textContent).toBe("Tool error"); - expect(container.querySelector(".chat-tool-card__block-content")?.textContent).toBe( - "No output — tool failed.", - ); - }); - - it("respects an explicit success flag even when the payload looks like an error", () => { - const container = document.createElement("div"); - render( - renderToolCard( - { - id: "msg:err:status-false", - name: "web_search", - outputText: JSON.stringify({ - error: "missing_brave_api_key", - }), - isError: false, - }, - { expanded: false, onToggleExpanded: vi.fn() }, - ), - container, - ); - - expect(container.textContent).toContain("Web Search"); - expect(container.textContent).not.toContain("Tool error"); - expect(container.querySelector(".chat-tool-msg-summary--error")).toBeNull(); - expect(container.querySelector(".chat-tool-msg-summary__error-badge")).toBeNull(); - }); - - it("keeps Tool output labelling for successful results", () => { - const container = document.createElement("div"); - render( - renderToolCard( - { - id: "msg:ok:1", - name: "browser.open", - outputText: "Opened page", - }, - { expanded: true, onToggleExpanded: vi.fn() }, - ), - container, - ); - - expect(container.textContent).toContain("Tool output"); - expect(container.textContent).not.toContain("Tool error"); - expect(container.querySelector(".chat-tool-msg-summary--error")).toBeNull(); - expect(container.querySelector(".chat-tool-card__status-badge")).toBeNull(); - }); - - it.each([ - { - args: { - markdown: "Implementation is moving.", - plan: [ - { step: "Inspect", status: "completed" }, - { step: "Implement", status: "in_progress" }, - { step: "Verify", status: "pending" }, - ], - }, - expected: "Progress updated — 1/3 · Implement", - }, - { args: { markdown: "Waiting on review." }, expected: "Progress note updated" }, - ])("renders progress_card as a compact receipt: $expected", ({ args, expected }) => { - const container = document.createElement("div"); - render( - renderToolCard( - { - id: `progress:${expected}`, - name: "progress_card", - args, - outputText: "Progress card updated", - completed: true, - }, - { expanded: true, onToggleExpanded: vi.fn() }, - ), - container, - ); - - expect(container.textContent?.trim()).toBe(expected); - expect(container.querySelector("button")).toBeNull(); - expect(container.querySelector(".chat-tool-msg-body")).toBeNull(); - expect(container.textContent).not.toContain("Waiting on review."); - }); - it("does not add a full-message request for ambiguous tool details", () => { const container = document.createElement("div"); const onOpenSidebar = vi.fn(); diff --git a/ui/src/pages/chat/components/chat-tool-cards.ts b/ui/src/pages/chat/components/chat-tool-cards.ts index 8effb95375f9..06224eb6b945 100644 --- a/ui/src/pages/chat/components/chat-tool-cards.ts +++ b/ui/src/pages/chat/components/chat-tool-cards.ts @@ -22,6 +22,7 @@ import { resolveToolDisplay, type EmbedSandboxMode, } from "../../../lib/chat/tool-display.ts"; +import { copyToClipboard } from "../../../lib/clipboard.ts"; import { getToolCallTitle } from "../tool-titles.ts"; import { renderDiffBlock, renderDiffStatChips } from "./chat-diff-render.ts"; import type { SidebarContent } from "./chat-sidebar.ts"; @@ -200,22 +201,24 @@ export function renderRawOutputToggle(text: string) { ${t("chat.toolCards.rawDetails")} - +
`; } -function renderToolDataBlock(params: { label: string; text: string }) { +// Plain tool output is the block's default content, so it carries no header; +// only input/error blocks need a label to stay distinguishable. +function renderToolDataBlock(params: { label?: string; text: string }) { const { label, text } = params; const codeClass = isMarkdownBlockArtText(text) ? "markdown-block-art" : ""; return html`
-
- ${icons.zap} - ${label} -
+ ${label + ? html`
+ ${icons.zap} + ${label} +
` + : nothing}
${text}
`; @@ -294,20 +297,31 @@ const TOOL_ROW_ICONS: Partial> = { }; function firstCommandLine(command: string): string { - const line = command.split("\n")[0]?.trim() ?? ""; - return truncateUtf16Safe(line, 120); + return command.split("\n")[0]?.trim() ?? ""; +} + +function compactToolTarget(target: string, kind: ToolCallView["kind"]): string { + if (kind !== "edit" && kind !== "write") { + return target; + } + return target.split(/[\\/]/u).findLast(Boolean) ?? target; +} + +export function syncToolDisclosureOverflow(event: Event): void { + const disclosure = event.currentTarget; + if (!(disclosure instanceof HTMLElement)) { + return; + } + const content = disclosure.querySelector(".chat-tool-disclosure__content"); + disclosure.classList.toggle( + "chat-tool-disclosure--overflowing", + Boolean(content && content.scrollWidth > content.clientWidth), + ); } function renderToolRowContent(card: ToolCard, view: ToolCallView, outcome: ToolCardOutcome) { if (view.kind === "command" && view.command) { const commandPreview = firstCommandLine(view.command); - const aiTitle = getToolCallTitle(card.name, card.args); - if (aiTitle) { - return html` - ${aiTitle} - ${commandPreview} - `; - } return html` ${renderHighlightedCommand(commandPreview)} @@ -324,9 +338,9 @@ function renderToolRowContent(card: ToolCard, view: ToolCallView, outcome: ToolC : undefined; return html` ${verb} - ${view.target} + ${compactToolTarget(view.target, view.kind)} ${stat ? renderDiffStatChips(stat) : nothing} - ${view.targetDetail + ${view.targetDetail && view.kind !== "edit" && view.kind !== "write" ? html`${view.targetDetail}` : nothing} `; @@ -403,23 +417,53 @@ function renderProgressCardReceipt(card: ToolCard, outcome: ToolCardOutcome) { : markdown ? t("sessionProgressCard.receipt.noteUpdated") : t("sessionProgressCard.receipt.cleared"); + // The label already names the running/failed state, so the row stays neutral + // like every other transcript activity row instead of adding its own chrome. return html`
${renderToolIcon("listChecks")} ${label} - ${outcome === "failed" - ? html`${t("chat.toolCards.failed")}` - : nothing} - ${outcome === "running" - ? html`` - : nothing}
`; } +function renderFileToolRowContent( + card: ToolCard, + view: ToolCallView, + outcome: ToolCardOutcome, + workspaceFilePath: string | null, + onOpenWorkspaceFile?: (target: { path: string; line?: number | null }) => void, +) { + const verb = resolveToolRowVerb(view, outcome); + if (!verb || !view.target) { + return renderToolRowContent(card, view, outcome); + } + const stat = + outcome === "succeeded" + ? view.stat + : outcome === "running" && (view.kind === "edit" || view.kind === "write") + ? card.liveDiffStat + : undefined; + const filename = compactToolTarget(view.target, view.kind); + return html` + ${verb} + ${workspaceFilePath && onOpenWorkspaceFile + ? html`` + : html`${filename}`} + ${stat ? renderDiffStatChips(stat) : nothing} + `; +} + // ── Command syntax highlighting ── type CommandToken = { text: string; cls: "name" | "flag" | "str" | "num" | "op" | "plain" | "ws" }; @@ -569,6 +613,12 @@ function extraArgsBeyondRowTarget( } function resolveToolWorkspaceFilePath(card: ToolCard, view: ToolCallView): string | null { + const singleOperation = view.fileOperations?.length === 1 ? view.fileOperations[0] : undefined; + // A delete removes its own target, so the workspace loader would always + // report "Failed to load"; the row keeps its disclosure but no file action. + if (singleOperation?.operation === "delete") { + return null; + } const args = asNullableRecord(card.args); if (args) { for (const key of ["path", "file_path", "filePath", "notebook_path"]) { @@ -579,7 +629,13 @@ function resolveToolWorkspaceFilePath(card: ToolCard, view: ToolCallView): strin } } const fallback = `${view.targetDetail ? `${view.targetDetail}/` : ""}${view.target ?? ""}`; - return fallback.trim() || null; + const fallbackPath = fallback.trim(); + // Aggregate patch labels ("2 files", "a.ts → b.ts") name no single file, so + // only a recorded operation matching the rendered path stays navigable. + if (view.fileOperations) { + return singleOperation?.path === fallbackPath ? singleOperation.path : null; + } + return fallbackPath || null; } function renderToolWorkspaceFilePath( @@ -601,9 +657,29 @@ function renderToolWorkspaceFilePath( : html`
${label}
`; } -function renderTerminalBlock(command: string, output: string | undefined, isError: boolean) { +/** Neutral end-state line every expanded tool surface closes with. */ +export function renderToolOutcome(outcome: ToolCardOutcome, exitCode?: number) { + const label = + outcome === "failed" + ? exitCode === undefined + ? t("chat.toolCards.failed") + : t("chat.toolCards.exitCode", { code: String(exitCode) }) + : outcome === "running" + ? t("chat.toolCards.running") + : outcome === "succeeded" + ? t("chat.toolCards.completed") + : null; + return label ? html`
${label}
` : nothing; +} + +function renderTerminalBlock( + command: string, + output: string | undefined, + outcome: ToolCardOutcome, + exitCode?: number, +) { return html` -
+
$${renderHighlightedCommand(command)} @@ -611,10 +687,17 @@ function renderTerminalBlock(command: string, output: string | undefined, isErro ${output?.trim() ? html`
${output}
` : nothing} + ${renderToolOutcome(outcome, exitCode)}
`; } +function serializeDiff(lines: readonly { kind: string; text: string }[]): string { + return lines + .map((line) => `${line.kind === "add" ? "+" : line.kind === "del" ? "-" : " "}${line.text}`) + .join("\n"); +} + export function resolveCollapsedToolDetail(card: ToolCard, displayDetail: string | undefined) { const directDetail = displayDetail?.trim(); if (directDetail) { @@ -687,42 +770,65 @@ export function renderToolCard( } const view = resolveToolCallView({ name: card.name, args: card.args, details: card.details }); const display = resolveToolDisplay({ name: card.name, args: card.args, detailMode: "explain" }); - const isError = outcome === "failed"; const isRunning = outcome === "running"; + const expanded = opts.expanded || isRunning; const icon = TOOL_ROW_ICONS[view.kind] ?? display.icon; + const workspaceFilePath = + view.kind === "read" || view.kind === "edit" || view.kind === "write" + ? resolveToolWorkspaceFilePath(card, view) + : null; + const isFileRow = Boolean(workspaceFilePath); return html` -
- - ${opts.expanded +
+ ${isFileRow + ? html`
+ + ${renderToolIcon(icon)} + ${renderFileToolRowContent( + card, + view, + outcome, + workspaceFilePath, + opts.onOpenWorkspaceFile, + )} + +
` + : html``} + ${expanded ? html`
${renderExpandedToolCardContent( @@ -792,20 +898,33 @@ export function renderExpandedToolCardContent( : nothing; const sidebarAction = canOpenSidebar ? html` -
- + + + + ` + : nothing; + const diffCopyAction = + view.diff && view.diff.length > 0 + ? html` + -
- ` - : nothing; + ` + : nothing; // Command calls render terminal-style: `$ command` + raw output. Remaining // args (workdir, timeout, env…) stay visible as key-value rows so identical @@ -817,12 +936,8 @@ export function renderExpandedToolCardContent( ); return html`
- ${sidebarAction} - ${renderTerminalBlock( - view.command, - card.outputText ?? (isError ? t("chat.toolCards.noOutputFailed") : undefined), - isError, - )} +
${sidebarAction}
+ ${renderTerminalBlock(view.command, card.outputText, outcome, card.exitCode)} ${Object.keys(extraArgs).length > 0 ? renderArgsKeyValueList(extraArgs) : nothing}
`; @@ -835,13 +950,13 @@ export function renderExpandedToolCardContent(
${renderToolWorkspaceFilePath( - `${view.targetDetail ? `${view.targetDetail}/` : ""}${view.target ?? ""}`, + workspaceFilePath ?? view.target ?? "", workspaceFilePath, onOpenWorkspaceFile, )} - ${sidebarAction} +
${diffCopyAction}${sidebarAction}
- ${renderDiffBlock(view.diff, outcome)} + ${renderDiffBlock(view.diff, outcome)} ${renderToolOutcome(outcome, card.exitCode)} ${isError && hasOutput ? renderToolDataBlock({ label: t("chat.toolCards.toolError"), text: card.outputText! }) : hasOutput @@ -870,7 +985,7 @@ export function renderExpandedToolCardContent( ? renderToolWorkspaceFilePath(detail, workspaceFilePath, onOpenWorkspaceFile) : html`
${detail}
` : nothing} - ${sidebarAction} +
${sidebarAction}
` : nothing} @@ -886,7 +1001,7 @@ export function renderExpandedToolCardContent( ? card.preview ? html`${visiblePreview} ${renderRawOutputToggle(card.outputText!)}` : renderToolDataBlock({ - label: t(isError ? "chat.toolCards.toolError" : "chat.toolCards.toolOutput"), + ...(isError ? { label: t("chat.toolCards.toolError") } : {}), text: card.outputText!, }) : isError @@ -895,6 +1010,7 @@ export function renderExpandedToolCardContent( text: t("chat.toolCards.noOutputFailed"), }) : nothing} + ${renderToolOutcome(outcome, card.exitCode)}
`; } diff --git a/ui/src/pages/chat/tool-stream.ts b/ui/src/pages/chat/tool-stream.ts index eaaa761c7c43..d571c1e68b67 100644 --- a/ui/src/pages/chat/tool-stream.ts +++ b/ui/src/pages/chat/tool-stream.ts @@ -57,6 +57,7 @@ export type ToolStreamEntry = { /** Monotonic edit counts received while the tool arguments stream. */ liveDiffStat?: DiffStat; isError?: boolean; + exitCode?: number; /** True once a result event landed, even when the output text is empty. */ resultReceived?: boolean; startedAt: number; @@ -280,6 +281,7 @@ function buildToolStreamMessage(entry: ToolStreamEntry): Record text: entry.output ?? "", ...(entry.details !== undefined ? { details: entry.details } : {}), ...(entry.isError !== undefined ? { isError: entry.isError } : {}), + ...(entry.exitCode !== undefined ? { exitCode: entry.exitCode } : {}), }); } return { @@ -1029,6 +1031,12 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo const resultDetails = phase === "result" ? readRecord(data.result)?.details : undefined; const resultIsError = phase === "result" && typeof data.isError === "boolean" ? data.isError : undefined; + const resultRecord = phase === "result" ? readRecord(data.result) : undefined; + const resultExitCode = resultRecord?.exitCode; + const exitCode = + typeof resultExitCode === "number" && Number.isInteger(resultExitCode) + ? resultExitCode + : undefined; const liveDiffStat = phase === "input_delta" ? readLiveDiffStat(data.diff) : undefined; if (name === "session_status" && phase === "result") { syncSessionStatusModelOverride(host, data); @@ -1047,6 +1055,7 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo output: output || undefined, ...(resultDetails !== undefined ? { details: resultDetails } : {}), ...(resultIsError !== undefined ? { isError: resultIsError } : {}), + ...(exitCode !== undefined ? { exitCode } : {}), ...(liveDiffStat ? { liveDiffStat } : {}), ...(phase === "result" ? { resultReceived: true } : {}), startedAt: typeof payload.ts === "number" ? payload.ts : now, @@ -1069,6 +1078,9 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo if (resultIsError !== undefined) { entry.isError = resultIsError; } + if (exitCode !== undefined) { + entry.exitCode = exitCode; + } if (liveDiffStat) { entry.liveDiffStat = liveDiffStat; } diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index fa815ffc793f..896f10f8ea2e 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -108,7 +108,18 @@ } .chat-group.tool { - --chat-message-column-max: var(--chat-message-max-width, min(980px, calc(100% - 46px))); + --chat-message-column-max: var(--chat-message-max-width, min(980px, 100%)); + display: block; + margin-inline-start: 0; + padding-inline-start: 0; +} + +.chat-group.tool.chat-group--with-footer { + display: block; +} + +.chat-group.tool.chat-group--with-footer > .chat-group-messages { + max-width: var(--chat-message-column-max); } .chat-group.user .chat-group-footer { diff --git a/ui/src/styles/chat/tool-cards.css b/ui/src/styles/chat/tool-cards.css index 3942962da3b3..4e70470db4d6 100644 --- a/ui/src/styles/chat/tool-cards.css +++ b/ui/src/styles/chat/tool-cards.css @@ -70,15 +70,142 @@ margin-top: 0; } +.chat-tool-msg-summary, +.chat-activity-group__summary { + --chat-tool-activity-color: color-mix(in srgb, var(--muted) 88%, transparent); + + font-family: var(--font-body); + font-size: calc(var(--control-ui-text-sm) + 1px); + font-weight: 400; + line-height: 1.5; + color: var(--chat-tool-activity-color); +} + +.chat-tool-msg-summary :where(span, code, button), +.chat-activity-group__summary :where(span, code, button) { + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: inherit; + letter-spacing: inherit; +} + .chat-tool-msg-summary { gap: 7px; - font-size: var(--control-ui-text-sm); - line-height: 1.5; + width: fit-content; + max-width: 100%; + padding: 3px 0; list-style: none; appearance: none; -webkit-appearance: none; } +.chat-tool-disclosure__content { + display: inline-flex; + align-items: baseline; + gap: 6px; + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + white-space: nowrap; +} + +.chat-tool-row__chevron { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 14px; + width: 14px; + height: 14px; + margin-inline-start: -3px; + color: var(--muted); + opacity: 0.55; + transition: + opacity 150ms ease-out, + transform 150ms ease-out; +} + +.chat-tool-row__chevron svg { + width: 14px; + height: 14px; +} + +.chat-tool-row--file { + position: relative; +} + +.chat-tool-row__toggle { + position: absolute; + inset: 0; + border: 0; + background: transparent; + cursor: var(--cursor-action); +} + +.chat-tool-row--file > :not(.chat-tool-row__toggle) { + position: relative; + pointer-events: none; +} + +.chat-tool-row--file .chat-tool-row__file-link { + pointer-events: auto; +} + +.chat-tool-row__file-link { + min-width: 0; + overflow: hidden; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, currentColor 18%, transparent); + text-underline-offset: 2px; + white-space: nowrap; + cursor: var(--cursor-action); +} + +.chat-tool-row__file-link:hover, +.chat-tool-row__file-link:focus-visible { + text-decoration-color: currentColor; +} + +.chat-inline-disclosure[aria-expanded="true"] .chat-tool-row__chevron { + transform: rotate(90deg); +} + +.chat-tool-row--file:has(.chat-tool-row__toggle[aria-expanded="true"]) .chat-tool-row__chevron { + transform: rotate(90deg); +} + +.chat-inline-disclosure:hover .chat-tool-row__chevron, +.chat-inline-disclosure:focus-visible .chat-tool-row__chevron { + opacity: 1; +} + +.chat-activity-group__body .chat-tool-row__chevron { + opacity: 0; +} + +.chat-activity-group__body .chat-tool-msg-summary:hover .chat-tool-row__chevron, +.chat-activity-group__body .chat-tool-msg-summary:focus-within .chat-tool-row__chevron, +.chat-activity-group__body + .chat-tool-msg-summary:has([aria-expanded="true"]) + .chat-tool-row__chevron, +.chat-activity-group__body .chat-tool-msg-summary[aria-expanded="true"] .chat-tool-row__chevron { + opacity: 1; +} + +.chat-tool-disclosure--overflowing { + width: 100%; +} + +.chat-tool-disclosure--overflowing .chat-tool-disclosure__content { + flex: 1 1 auto; + mask-image: linear-gradient(to right, var(--text) calc(100% - 44px), transparent 100%); +} + .chat-tool-msg-summary::-webkit-details-marker { display: none; } @@ -87,15 +214,15 @@ display: inline-flex; align-items: center; justify-content: center; - width: 15px; - height: 15px; + width: 16px; + height: 16px; color: var(--muted); flex-shrink: 0; } .chat-tool-msg-summary__icon svg { - width: 15px; - height: 15px; + width: 16px; + height: 16px; stroke: currentColor; fill: none; stroke-width: 1.5px; @@ -103,15 +230,15 @@ stroke-linejoin: round; } -/* Collapsed rows clamp to one line; the full text is available on expand. */ +/* Collapsed rows stay on one line; measured overflow gets a fade while the + complete value remains available in the expanded detail. */ .chat-tool-msg-summary__label { flex: 0 1 auto; min-width: 0; overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; - font-weight: 500; - color: var(--text); + font: inherit; + color: inherit; } .chat-tool-msg-summary__names, @@ -119,29 +246,24 @@ flex: 0 1 auto; min-width: 0; overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; - font-family: var(--mono); - font-size: calc(var(--control-ui-text-sm) - 1px); + font: inherit; color: color-mix(in srgb, var(--muted) 82%, var(--text) 18%); } /* ── Kind-aware tool rows ── */ .chat-tool-row__prompt { flex-shrink: 0; - font-family: var(--mono); - font-size: calc(var(--control-ui-text-sm) - 1px); - font-weight: 600; - color: color-mix(in srgb, var(--ok) 70%, var(--muted) 30%); + font: inherit; + color: inherit; } .chat-tool-row__title { flex: 0 1 auto; min-width: 0; overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; - font-weight: 500; + font: inherit; color: var(--text); } @@ -149,49 +271,36 @@ flex: 0 1 auto; min-width: 0; overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; - font-family: var(--mono); - font-size: calc(var(--control-ui-text-sm) - 1px); - color: var(--text); -} - -.chat-tool-row__cmd--secondary { - flex: 0 1 auto; - color: color-mix(in srgb, var(--muted) 85%, var(--text) 15%); + color: inherit; } .chat-tool-row__verb { flex-shrink: 0; - font-weight: 500; - color: var(--text); + font: inherit; + color: inherit; } .chat-tool-row__target { flex: 0 1 auto; min-width: 0; overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; - font-family: var(--mono); - font-size: calc(var(--control-ui-text-sm) - 1px); - font-weight: 500; - color: var(--text); + font: inherit; + color: inherit; } .chat-tool-row__detail { flex: 0 1 auto; min-width: 24px; overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; - font-family: var(--mono); - font-size: calc(var(--control-ui-text-sm) - 1px); - color: color-mix(in srgb, var(--muted) 88%, var(--text) 12%); + font: inherit; + color: inherit; } .chat-tool-row--running .chat-tool-msg-summary__icon { - color: var(--accent-2); + color: var(--muted); } /* Active-task text wave: a subtle left-to-right color sweep across the @@ -200,14 +309,10 @@ correct in both themes. Scoped to no-preference so reduced-motion users keep plain static text and syntax colors. */ @media (prefers-reduced-motion: no-preference) { - .chat-tool-row--running - :is( - .chat-tool-msg-summary__label, - .chat-tool-row__title, - .chat-tool-row__verb, - .chat-tool-row__target, - .chat-tool-row__cmd:not(.chat-tool-row__cmd--secondary) - ) { + /* Whichever element names the work in progress: the action verb on file rows, + the command itself on terminal rows. Syntax tokens inherit `color`, so the + clipped gradient paints their glyphs too. */ + .chat-tool-row--running :is(.chat-tool-row__verb, .chat-tool-row__cmd) { background-image: linear-gradient( 90deg, var(--text) 0%, @@ -222,12 +327,6 @@ color: transparent; animation: chatToolRowTextWave 2.2s ease-in-out infinite; } - - /* Syntax-highlight tokens go transparent while running so the parent's - clipped gradient paints their glyphs; token colors return on completion. */ - .chat-tool-row--running .chat-tool-row__cmd:not(.chat-tool-row__cmd--secondary) > span { - color: transparent; - } } /* Gradient is 2x element width with identical end stops, so one full period @@ -242,46 +341,6 @@ } } -.chat-tool-row__spinner { - flex-shrink: 0; - width: 7px; - height: 7px; - border-radius: var(--radius-full, 999px); - background: var(--accent-2); - animation: chatToolRowPulse 1.1s ease-in-out infinite; -} - -@keyframes chatToolRowPulse { - 0%, - 100% { - opacity: 0.35; - transform: scale(0.85); - } - - 50% { - opacity: 1; - transform: scale(1); - } -} - -@media (prefers-reduced-motion: reduce) { - .chat-tool-row__spinner { - animation: none; - opacity: 0.8; - } -} - -.chat-tool-row__badge { - flex-shrink: 0; - padding: 1px 6px; - border-radius: var(--radius-full, 999px); - background: color-mix(in srgb, var(--destructive) 14%, transparent); - color: var(--destructive); - font-size: 11px; - font-weight: 600; - line-height: 1.5; -} - /* ── Diffstat chips (+N -M) ── */ .chat-diffstat { display: inline-flex; @@ -289,15 +348,21 @@ gap: 4px; flex-shrink: 0; font-family: var(--mono); - font-size: calc(var(--control-ui-text-sm) - 1px); - font-weight: 600; + color: var(--muted); } -.chat-diffstat__add { +.chat-diffstat__add, +.chat-diffstat__del { + color: inherit; +} + +.chat-tool-msg-summary:hover .chat-diffstat__add, +.chat-tool-msg-summary:focus-visible .chat-diffstat__add { color: var(--ok); } -.chat-diffstat__del { +.chat-tool-msg-summary:hover .chat-diffstat__del, +.chat-tool-msg-summary:focus-visible .chat-diffstat__del { color: var(--destructive); } @@ -387,9 +452,8 @@ /* ── Terminal-style command block ── */ .chat-tool-term { - margin-top: 6px; - border-radius: var(--radius-sm); - background: color-mix(in srgb, var(--secondary) 82%, transparent); + margin-top: 0; + background: transparent; overflow: hidden; } @@ -413,7 +477,7 @@ .chat-tool-term__prompt { flex-shrink: 0; font-weight: 600; - color: color-mix(in srgb, var(--ok) 70%, var(--muted) 30%); + color: var(--muted); user-select: none; } @@ -434,13 +498,18 @@ padding-bottom: 10px; } -.chat-tool-term--error .chat-tool-term__out { - color: color-mix(in srgb, var(--destructive) 70%, var(--text) 30%); +.chat-tool-card__outcome { + padding: 7px 14px 9px; + border-top: 1px solid var(--border); + color: var(--muted); + font-size: 11px; + text-align: end; } /* Command/diff text reuses for semantics only; kill the markdown pill. */ .chat-tool-row__cmd, .chat-tool-row__cmd code, +.chat-tool-row__cmd span, .chat-tool-term__cmd code, .chat-tool-term__out code { background: none; @@ -452,24 +521,28 @@ /* ── Command token colors (display-only highlighting) ── */ .chat-cmd--name { - color: var(--accent-2); - font-weight: 600; + color: inherit; + font: inherit; } .chat-cmd--str { - color: color-mix(in srgb, var(--ok) 78%, var(--text) 22%); + font: inherit; + color: inherit; } .chat-cmd--num { - color: color-mix(in srgb, var(--accent) 82%, var(--text) 18%); + font: inherit; + color: inherit; } .chat-cmd--flag { - color: color-mix(in srgb, var(--muted) 70%, var(--text) 30%); + font: inherit; + color: inherit; } .chat-cmd--op { - color: color-mix(in srgb, var(--accent-2) 60%, var(--muted) 40%); + font: inherit; + color: inherit; } /* ── Key-value args (generic tools) ── */ @@ -508,12 +581,30 @@ box-sizing: border-box; min-width: 0; max-width: 100%; - padding: 2px 8px 8px 24px; + margin-top: 5px; + padding: 0; + overflow: auto; + max-height: min(520px, 60vh); + border: 1px solid color-mix(in srgb, var(--text) 22%, transparent); + border-radius: calc(14px * var(--openclaw-corner-radius-scale)); + corner-shape: superellipse(1.5); + background: transparent; +} + +.chat-activity-group__body .chat-tool-msg-collapse.is-open { + margin-bottom: 8px; +} + +.chat-tool-msg-body .chat-diff { + margin-top: 0; + border-radius: 0; + background: transparent; } .chat-bubble--tool-shell .chat-tool-msg-body > .chat-text { max-height: min(46vh, 540px); margin: 0; + padding: 12px 14px 14px; overflow: auto; font-family: var(--mono); font-size: 12px; @@ -535,11 +626,18 @@ } .chat-tool-card__header { + /* Header actions sit in a bordered row, so they run one size smaller than the + free-floating ones; the flush command reservation tracks whichever applies. */ + --chat-tool-action-size: 28px; + display: flex; justify-content: space-between; - align-items: flex-start; + align-items: center; gap: 10px; min-width: 0; + min-height: 30px; + padding: 4px 8px 4px 14px; + border-bottom: 1px solid var(--border); } /* Cards whose body is a single block (terminal, diff) skip the header row and @@ -557,8 +655,20 @@ transition: opacity 120ms ease; } +.chat-tool-card__header > .chat-tool-card__actions { + opacity: 0; + transition: opacity 120ms ease-out; +} + +.chat-tool-card__header:hover > .chat-tool-card__actions, +.chat-tool-card__header:focus-within > .chat-tool-card__actions { + opacity: 1; +} + +/* Reserve the floating action box (button + its 6px inset) plus the block's own + 12px padding so command text never runs underneath it. */ .chat-tool-card--flush:has(> .chat-tool-card__actions) .chat-tool-term__cmd { - padding-right: 42px; + padding-right: calc(var(--chat-tool-action-size, 40px) + 18px); } .chat-tool-card--flush:hover > .chat-tool-card__actions, @@ -567,7 +677,8 @@ } @media (pointer: coarse) { - .chat-tool-card--flush > .chat-tool-card__actions { + .chat-tool-card--flush > .chat-tool-card__actions, + .chat-tool-card__header > .chat-tool-card__actions { opacity: 1; } } @@ -584,8 +695,8 @@ display: inline-flex; align-items: center; justify-content: center; - width: 24px; - height: 24px; + width: var(--chat-tool-action-size, 40px); + height: var(--chat-tool-action-size, 40px); padding: 0; border: 0; border-radius: var(--radius-sm); @@ -610,8 +721,8 @@ } .chat-tool-card__action-icon svg { - width: 12px; - height: 12px; + width: 16px; + height: 16px; stroke: currentColor; fill: none; stroke-width: 1.5px; @@ -622,7 +733,7 @@ .chat-tool-card__detail { flex: 1 1 auto; min-width: 0; - font-size: var(--control-ui-text-sm); + font: 11px/1.4 var(--mono); color: var(--muted); } @@ -631,20 +742,20 @@ padding: 0; border: 0; background: transparent; - font: inherit; + font: 11px/1.4 var(--mono); text-align: left; cursor: var(--cursor-action); } .chat-tool-card__detail-link:hover, .chat-tool-card__detail-link:focus-visible { - color: var(--accent); - text-decoration: underline; - text-underline-offset: 2px; + color: var(--text); + text-decoration: none; } .chat-tool-card__block { - margin-top: 8px; + margin: 0; + padding: 10px 12px; min-width: 0; } @@ -687,9 +798,9 @@ max-width: 100%; min-width: 0; margin: 0; - padding: 10px 12px; - border-radius: var(--radius-sm); - background: color-mix(in srgb, var(--secondary) 82%, transparent); + padding: 0; + border-radius: 0; + background: transparent; color: var(--text); font-size: 12px; line-height: 1.45; @@ -960,7 +1071,6 @@ .chat-activity-group__summary { gap: 8px; padding-block: 5px; - color: var(--text); } .chat-activity-group__icon { @@ -972,8 +1082,8 @@ } .chat-activity-group__icon svg { - width: 14px; - height: 14px; + width: 16px; + height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8px; @@ -985,22 +1095,21 @@ flex: 0 1 auto; min-width: 0; overflow: hidden; - font-size: var(--control-ui-text-sm); - font-weight: 600; - line-height: 1.4; - text-overflow: ellipsis; + font: inherit; white-space: nowrap; } -/* Rows form a flat indented list below the summary. The rail marks the group - extent while shared row hover pills remain free of per-row chrome. */ +/* Parent and child rows share one icon/text grid; order and whitespace carry + hierarchy without shrinking the available command width. */ .chat-activity-group__body { display: flex; flex-direction: column; - gap: 2px; - margin: 4px 0 0 10px; - padding-left: 10px; - border-left: 2px solid color-mix(in srgb, var(--border) 82%, transparent); + gap: 0; + max-height: min(420px, 58vh); + margin: 0; + padding-left: 0; + border-left: 0; + overflow-y: auto; } /* Completed-turn work rollup: slim "Worked for X" disclosure standing in for @@ -1010,16 +1119,18 @@ padding-bottom: 8px; } -.chat-work-group__gutter { - flex: 0 0 36px; -} - -.chat-thread--direct .chat-work-group__gutter { - display: none; -} - .chat-work-group .chat-activity-group__label { - color: var(--muted); + color: inherit; +} + +.chat-group--activity .chat-activity-group__label { + color: inherit; +} + +.chat-work-group__separator { + width: 100%; + height: 1px; + background: var(--border); } /* Reading indicator = working claw: the brand pincer claws in place where the @@ -2054,7 +2165,7 @@ openclaw-tooltip.chat-tasks-status__preview { @media (max-width: 768px) { .chat-tool-msg-body { - padding-left: 16px; + padding-left: 0; } .chat-json-content {