From f77e5cf8fb9d9babcfc1b3ba046c0ba571489647 Mon Sep 17 00:00:00 2001 From: David Hill <1879069+iamdavidhill@users.noreply.github.com> Date: Tue, 10 Mar 2026 18:52:32 +0000 Subject: [PATCH] feat(ui): restyle Card and improve tool error cards (#16888) Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> --- packages/ui/src/components/card.css | 103 +++++++++++++--- packages/ui/src/components/card.stories.tsx | 14 +-- packages/ui/src/components/card.tsx | 107 ++++++++++++++++- packages/ui/src/components/markdown.css | 6 + packages/ui/src/components/message-part.css | 36 ------ packages/ui/src/components/message-part.tsx | 21 +--- .../ui/src/components/tool-error-card.css | 54 +++++++++ .../components/tool-error-card.stories.tsx | 96 +++++++++++++++ .../ui/src/components/tool-error-card.tsx | 112 ++++++++++++++++++ packages/ui/src/styles/index.css | 1 + packages/ui/src/styles/theme.css | 4 +- packages/ui/src/theme/themes/oc-2.json | 6 + 12 files changed, 473 insertions(+), 87 deletions(-) create mode 100644 packages/ui/src/components/tool-error-card.css create mode 100644 packages/ui/src/components/tool-error-card.stories.tsx create mode 100644 packages/ui/src/components/tool-error-card.tsx diff --git a/packages/ui/src/components/card.css b/packages/ui/src/components/card.css index 6dae47223..2d482dba7 100644 --- a/packages/ui/src/components/card.css +++ b/packages/ui/src/components/card.css @@ -1,29 +1,94 @@ [data-component="card"] { + --card-pad-y: 10px; + --card-pad-r: 12px; + --card-pad-l: 10px; + width: 100%; display: flex; flex-direction: column; - background-color: var(--surface-inset-base); - border: 1px solid var(--border-weaker-base); - transition: background-color 0.15s ease; + position: relative; + background: transparent; + border: none; border-radius: var(--radius-md); - padding: 6px 12px; - overflow: clip; + padding: var(--card-pad-y) var(--card-pad-r) var(--card-pad-y) var(--card-pad-l); - &[data-variant="error"] { - background-color: var(--surface-critical-weak); - border: 1px solid var(--border-critical-base); - color: rgba(218, 51, 25, 0.6); + /* text-14-regular */ + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--text-strong); - /* text-12-regular */ - font-family: var(--font-family-sans); - font-size: var(--font-size-small); - font-style: normal; - font-weight: var(--font-weight-regular); - line-height: var(--line-height-large); /* 166.667% */ - letter-spacing: var(--letter-spacing-normal); + --card-gap: 8px; + --card-icon: 16px; + --card-indent: 0px; + --card-line-pad: 8px; - &[data-component="icon"] { - color: var(--icon-critical-active); - } + --card-accent: var(--icon-active); + + &:has([data-slot="card-title"]) { + gap: 8px; + } + + &:has([data-slot="card-title-icon"]) { + --card-indent: calc(var(--card-icon) + var(--card-gap)); + } + + &::before { + content: ""; + position: absolute; + left: 0; + top: var(--card-line-pad); + bottom: var(--card-line-pad); + width: 2px; + border-radius: 2px; + background-color: var(--card-accent); + } + + :where([data-card="title"], [data-slot="card-title"]) { + color: var(--text-strong); + font-weight: var(--font-weight-medium); + } + + :where([data-slot="card-title"]) { + display: flex; + align-items: center; + gap: var(--card-gap); + } + + :where([data-slot="card-title"]) [data-component="icon"] { + color: var(--card-accent); + } + + :where([data-slot="card-title-icon"]) { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--card-icon); + height: var(--card-icon); + flex: 0 0 auto; + } + + :where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] { + color: var(--text-weak); + } + + :where([data-slot="card-title-icon"]) + [data-slot="icon-svg"] + :is(path, line, polyline, polygon, rect, circle, ellipse)[stroke] { + stroke-width: 1.5px !important; + } + + :where([data-card="description"], [data-slot="card-description"]) { + color: var(--text-base); + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + } + + :where([data-card="actions"], [data-slot="card-actions"]) { + padding-left: var(--card-indent); } } diff --git a/packages/ui/src/components/card.stories.tsx b/packages/ui/src/components/card.stories.tsx index befb2d34f..5b9cf3830 100644 --- a/packages/ui/src/components/card.stories.tsx +++ b/packages/ui/src/components/card.stories.tsx @@ -1,5 +1,5 @@ // @ts-nocheck -import { Card } from "./card" +import { Card, CardActions, CardDescription, CardTitle } from "./card" import { Button } from "./button" const docs = `### Overview @@ -49,15 +49,13 @@ export default { render: (props: { variant?: "normal" | "error" | "warning" | "success" | "info" }) => { return ( -
-
-
Card title
-
Small supporting text.
-
- -
+
) }, diff --git a/packages/ui/src/components/card.tsx b/packages/ui/src/components/card.tsx index 3fb225ab2..7a1bd5e45 100644 --- a/packages/ui/src/components/card.tsx +++ b/packages/ui/src/components/card.tsx @@ -1,16 +1,57 @@ import { type ComponentProps, splitProps } from "solid-js" +import { Icon, type IconProps } from "./icon" + +type Variant = "normal" | "error" | "warning" | "success" | "info" export interface CardProps extends ComponentProps<"div"> { - variant?: "normal" | "error" | "warning" | "success" | "info" + variant?: Variant +} + +export interface CardTitleProps extends ComponentProps<"div"> { + variant?: Variant + + /** + * Optional title icon. + * + * - `undefined`: picks a default icon based on `variant` (error/warning/success/info) + * - `false`/`null`: disables the icon + * - `Icon` name: forces a specific icon + */ + icon?: IconProps["name"] | false | null +} + +function pick(variant: Variant) { + if (variant === "error") return "circle-ban-sign" as const + if (variant === "warning") return "warning" as const + if (variant === "success") return "circle-check" as const + if (variant === "info") return "help" as const + return +} + +function mix(style: ComponentProps<"div">["style"], value?: string) { + if (!value) return style + if (!style) return { "--card-accent": value } + if (typeof style === "string") return `${style};--card-accent:${value};` + return { ...(style as Record), "--card-accent": value } } export function Card(props: CardProps) { - const [split, rest] = splitProps(props, ["variant", "class", "classList"]) + const [split, rest] = splitProps(props, ["variant", "style", "class", "classList"]) + const variant = () => split.variant ?? "normal" + const accent = () => { + const v = variant() + if (v === "error") return "var(--icon-critical-base)" + if (v === "warning") return "var(--icon-warning-active)" + if (v === "success") return "var(--icon-success-active)" + if (v === "info") return "var(--icon-info-active)" + return + } return (
) } + +export function CardTitle(props: CardTitleProps) { + const [split, rest] = splitProps(props, ["variant", "icon", "class", "classList", "children"]) + const show = () => split.icon !== false && split.icon !== null + const name = () => { + if (split.icon === false || split.icon === null) return + if (typeof split.icon === "string") return split.icon + return pick(split.variant ?? "normal") + } + const placeholder = () => !name() + return ( +
+ {show() ? ( + + + + ) : null} + {split.children} +
+ ) +} + +export function CardDescription(props: ComponentProps<"div">) { + const [split, rest] = splitProps(props, ["class", "classList", "children"]) + return ( +
+ {split.children} +
+ ) +} + +export function CardActions(props: ComponentProps<"div">) { + const [split, rest] = splitProps(props, ["class", "classList", "children"]) + return ( +
+ {split.children} +
+ ) +} diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 1fe11a7de..f82723807 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -60,6 +60,7 @@ ol { margin-top: 0.5rem; margin-bottom: 1rem; + margin-left: 0; padding-left: 1.5rem; list-style-position: outside; } @@ -70,6 +71,7 @@ ol { list-style-type: decimal; + padding-left: 2.25rem; } li { @@ -98,6 +100,10 @@ padding-left: 1rem; /* Minimal indent for nesting only */ } + li > ol { + padding-left: 1.75rem; + } + /* Blockquotes */ blockquote { border-left: 2px solid var(--border-weak-base); diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 704a57e5b..7ee537884 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -309,41 +309,6 @@ } } -[data-component="tool-error"] { - display: flex; - align-items: start; - gap: 8px; - - [data-slot="icon-svg"] { - color: var(--icon-critical-base); - margin-top: 4px; - } - - [data-slot="message-part-tool-error-content"] { - display: flex; - align-items: start; - gap: 8px; - } - - [data-slot="message-part-tool-error-title"] { - font-family: var(--font-family-sans); - font-size: var(--font-size-base); - font-style: normal; - font-weight: var(--font-weight-medium); - line-height: var(--line-height-large); - letter-spacing: var(--letter-spacing-normal); - color: var(--text-on-critical-base); - white-space: nowrap; - } - - [data-slot="message-part-tool-error-message"] { - color: var(--text-on-critical-weak); - max-height: 240px; - overflow-y: auto; - word-break: break-word; - } -} - [data-component="tool-output"] { white-space: pre; padding: 0; @@ -717,7 +682,6 @@ [data-component="user-message"] [data-slot="user-message-text"], [data-component="text-part"], [data-component="reasoning-part"], -[data-component="tool-error"], [data-component="tool-output"], [data-component="bash-output"], [data-component="edit-content"], diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 45b174e2b..0815c3333 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -39,6 +39,7 @@ import { Card } from "./card" import { Collapsible } from "./collapsible" import { FileIcon } from "./file-icon" import { Icon } from "./icon" +import { ToolErrorCard } from "./tool-error-card" import { Checkbox } from "./checkbox" import { DiffChanges } from "./diff-changes" import { Markdown } from "./markdown" @@ -1189,25 +1190,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
) } - const [title, ...rest] = cleaned.split(": ") - return ( - -
- - - -
-
{title}
- {rest.join(": ")} -
-
- - {cleaned} - -
-
-
- ) + return }} diff --git a/packages/ui/src/components/tool-error-card.css b/packages/ui/src/components/tool-error-card.css new file mode 100644 index 000000000..e93484f23 --- /dev/null +++ b/packages/ui/src/components/tool-error-card.css @@ -0,0 +1,54 @@ +[data-component="card"][data-kind="tool-error-card"] { + --card-pad-y: 8px; + --card-line-pad: 12px; + + > [data-component="collapsible"].tool-collapsible { + gap: 0px; + } + + > [data-component="collapsible"].tool-collapsible[data-open="true"] { + gap: 4px; + } + + [data-component="tool-error-card-icon"] [data-component="icon"] { + color: var(--card-accent); + } + + [data-slot="tool-error-card-content"] { + position: relative; + padding-left: 24px; + margin-bottom: 8px; + -webkit-user-select: text; + user-select: text; + } + + > [data-component="collapsible"].tool-collapsible[data-open="true"] [data-slot="tool-error-card-content"] { + padding-right: 40px; + } + + [data-slot="tool-error-card-copy"] { + position: absolute; + top: 0; + right: 0; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; + will-change: opacity; + } + + &:hover [data-slot="tool-error-card-copy"], + &:focus-within [data-slot="tool-error-card-copy"] { + opacity: 1; + pointer-events: auto; + } + + [data-slot="tool-error-card-content"] :where(*)::selection { + background: var(--surface-critical-base); + color: var(--text-on-critical-base); + } + + [data-slot="tool-error-card-content"] :where(*)::-moz-selection { + background: var(--surface-critical-base); + color: var(--text-on-critical-base); + } +} diff --git a/packages/ui/src/components/tool-error-card.stories.tsx b/packages/ui/src/components/tool-error-card.stories.tsx new file mode 100644 index 000000000..03349ce01 --- /dev/null +++ b/packages/ui/src/components/tool-error-card.stories.tsx @@ -0,0 +1,96 @@ +// @ts-nocheck +import { ToolErrorCard } from "./tool-error-card" + +const docs = `### Overview +Tool call failure summary styled like a tool trigger. + +### API +- Required: \`tool\` (tool id, e.g. apply_patch, bash) +- Required: \`error\` (error string) + +### Behavior +- Collapsible; click header to expand/collapse. +` + +const samples = [ + { + tool: "apply_patch", + error: + "apply_patch verification failed: Failed to find expected lines in /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-turn.tsx", + }, + { + tool: "bash", + error: "bash Command failed: exit code 1: bun test --watch", + }, + { + tool: "read", + error: + "read File not found: /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/does-not-exist.tsx", + }, + { + tool: "glob", + error: "glob Pattern error: Invalid glob pattern: **/*[", + }, + { + tool: "grep", + error: "grep Regex error: Invalid regular expression: (unterminated group", + }, + { + tool: "webfetch", + error: "webfetch Request failed: 502 Bad Gateway", + }, + { + tool: "websearch", + error: "websearch Rate limited: Please try again in 30 seconds", + }, + { + tool: "codesearch", + error: "codesearch Timeout: exceeded 120s", + }, + { + tool: "question", + error: "question Dismissed: user dismissed this question", + }, +] + +export default { + title: "UI/ToolErrorCard", + id: "components-tool-error-card", + component: ToolErrorCard, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + args: { + tool: "apply_patch", + error: samples[0].error, + }, + argTypes: { + tool: { + control: "select", + options: ["apply_patch", "bash", "read", "glob", "grep", "webfetch", "websearch", "codesearch", "question"], + }, + error: { + control: "text", + }, + }, + render: (props: { tool: string; error: string }) => { + return + }, +} + +export const All = { + render: () => { + return ( +
+ {samples.map((item) => ( + + ))} +
+ ) + }, +} diff --git a/packages/ui/src/components/tool-error-card.tsx b/packages/ui/src/components/tool-error-card.tsx new file mode 100644 index 000000000..13873ae90 --- /dev/null +++ b/packages/ui/src/components/tool-error-card.tsx @@ -0,0 +1,112 @@ +import { type ComponentProps, createMemo, createSignal, Show, splitProps } from "solid-js" +import { Card, CardDescription } from "./card" +import { Collapsible } from "./collapsible" +import { Icon } from "./icon" +import { IconButton } from "./icon-button" +import { Tooltip } from "./tooltip" +import { useI18n } from "../context/i18n" + +export interface ToolErrorCardProps extends Omit, "children" | "variant"> { + tool: string + error: string +} + +export function ToolErrorCard(props: ToolErrorCardProps) { + const i18n = useI18n() + const [open, setOpen] = createSignal(true) + const [copied, setCopied] = createSignal(false) + const [split, rest] = splitProps(props, ["tool", "error"]) + const name = createMemo(() => { + const map: Record = { + read: "ui.tool.read", + list: "ui.tool.list", + glob: "ui.tool.glob", + grep: "ui.tool.grep", + webfetch: "ui.tool.webfetch", + websearch: "ui.tool.websearch", + codesearch: "ui.tool.codesearch", + bash: "ui.tool.shell", + apply_patch: "ui.tool.patch", + question: "ui.tool.questions", + } + const key = map[split.tool] + if (!key) return split.tool + return i18n.t(key) + }) + const cleaned = createMemo(() => split.error.replace(/^Error:\s*/, "").trim()) + const tail = createMemo(() => { + const value = cleaned() + const prefix = `${split.tool} ` + if (value.startsWith(prefix)) return value.slice(prefix.length) + return value + }) + + const subtitle = createMemo(() => { + const parts = tail().split(": ") + if (parts.length <= 1) return "Failed" + const head = (parts[0] ?? "").trim() + if (!head) return "Failed" + return head[0] ? head[0].toUpperCase() + head.slice(1) : "Failed" + }) + + const body = createMemo(() => { + const parts = tail().split(": ") + if (parts.length <= 1) return cleaned() + return parts.slice(1).join(": ").trim() || cleaned() + }) + + const copy = async () => { + const text = cleaned() + if (!text) return + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + + +
+
+ + + +
+
+
+ {name()} + {subtitle()} +
+
+
+
+ +
+
+ +
+ +
+ + e.preventDefault()} + onClick={(e) => { + e.stopPropagation() + copy() + }} + aria-label={copied() ? i18n.t("ui.message.copied") : "Copy error"} + /> + +
+
+ {(value) => {value()}} +
+
+
+
+ ) +} diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index cec42f5a0..1b17f6c2b 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -13,6 +13,7 @@ @import "../components/basic-tool.css" layer(components); @import "../components/button.css" layer(components); @import "../components/card.css" layer(components); +@import "../components/tool-error-card.css" layer(components); @import "../components/checkbox.css" layer(components); @import "../components/file.css" layer(components); @import "../components/collapsible.css" layer(components); diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css index 702d1e4e6..021f959e4 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -131,7 +131,7 @@ --surface-warning-base: #fcf3cb; --surface-warning-weak: #fdfaec; --surface-warning-strong: #fbdd46; - --surface-critical-base: #feefeb; + --surface-critical-base: #fff2f0; --surface-critical-weak: #fff8f6; --surface-critical-strong: #fc533a; --surface-info-base: #fdecfe; @@ -391,7 +391,7 @@ --surface-warning-base: #fdf3cf; --surface-warning-weak: #fdfaed; --surface-warning-strong: #fcd53a; - --surface-critical-base: #42120b; + --surface-critical-base: #1f0603; --surface-critical-weak: #28110c; --surface-critical-strong: #fc533a; --surface-info-base: #feecfe; diff --git a/packages/ui/src/theme/themes/oc-2.json b/packages/ui/src/theme/themes/oc-2.json index fdf0c2caf..dc413f061 100644 --- a/packages/ui/src/theme/themes/oc-2.json +++ b/packages/ui/src/theme/themes/oc-2.json @@ -13,6 +13,9 @@ "interactive": "#034cff", "diffAdd": "#9ff29a", "diffDelete": "#fc533a" + }, + "overrides": { + "surface-critical-base": "#FFF2F0" } }, "dark": { @@ -26,6 +29,9 @@ "interactive": "#034cff", "diffAdd": "#c8ffc4", "diffDelete": "#fc533a" + }, + "overrides": { + "surface-critical-base": "#1F0603" } } }