From 540ea124e09acbd477fa65a37785ff71ff1e12c3 Mon Sep 17 00:00:00 2001 From: "Vyctor H. Brzezowski" Date: Tue, 18 Aug 2026 03:36:43 -0300 Subject: [PATCH] improve(ui): redesign tool calls as lightweight transcript activity (#125240) * improve(ui): flatten transcript tool activity * improve(ui): complete tool activity redesign * improve(ui): normalize activity row typography * improve(ui): unify activity typography contract * improve(ui): standardize all activity row text * improve(ui): increase activity text size * improve(ui): underline activity filenames * improve(ui): tighten activity row spacing * improve(ui): standardize activity chevrons * improve(ui): reveal child chevrons on interaction * improve(ui): retain active child chevrons * improve(ui): space expanded child tools * improve(ui): remove idle activity fade and gap * fix(ui): preserve tool activity contracts * fix(ui): simplify activity basename lookup * fix(ui): preserve canonical tool row actions * fix(ui): record tool outcomes and repair transcript row regressions Neutral collapsed rows are the design, but this branch left failures with nowhere to land and shipped chrome whose styles it had already deleted. - Expanded standalone tool messages close with the outcome line, so an inline rows shape still reports the failure instead of dropping it. - Progress-card receipts drop the badge and the spinner: this branch removed both styles, leaving a 0x0 invisible running indicator, and the receipt label already names the state. - Single delete patches are non-navigable; their workspace target is gone, so the file action could only ever fail to load. - Flush command padding derives from the action-button size instead of a stale 42px constant, and the running text wave covers command rows again. - Activity lane returns to the tool-shell column width the rule claims. - Drop CSS nothing references: tool-card tabs, empty, plain-output, activity scroll masks, secondary command text. - renderToolDataBlock takes an optional label instead of comparing copy. - Split the oversized tool-card test file and refresh stale row assertions. * fix(ui): fold the tool action size into the card header rule Stylelint no-duplicate-selectors rejects the separate .chat-tool-card__header block; the custom property belongs in the existing rule. --------- Co-authored-by: RoboClaw <309084314+roboclaw-bot@users.noreply.github.com> --- ui/src/e2e/chat-tool-turn-outcome.e2e.test.ts | 23 +- ui/src/i18n/locales/en.ts | 2 + ui/src/lib/chat/chat-types.ts | 2 + ui/src/lib/chat/tool-cards.ts | 30 ++ .../chat/chat-responsive.browser.test.ts | 35 +- .../pages/chat/components/chat-diff-render.ts | 7 +- .../chat/components/chat-message-bubble.ts | 40 +- .../chat/components/chat-message-group.ts | 131 +++--- .../chat/components/chat-message-stream.ts | 15 +- .../chat/components/chat-message.test.ts | 38 +- .../chat-tool-cards.outcome.test.ts | 209 +++++++++ .../chat/components/chat-tool-cards.test.ts | 279 +++--------- .../pages/chat/components/chat-tool-cards.ts | 280 ++++++++---- ui/src/pages/chat/tool-stream.ts | 12 + ui/src/styles/chat/grouped.css | 13 +- ui/src/styles/chat/tool-cards.css | 411 +++++++++++------- 16 files changed, 947 insertions(+), 580 deletions(-) create mode 100644 ui/src/pages/chat/components/chat-tool-cards.outcome.test.ts 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 {