Ver Fonte

feat(ui): restyle Card and improve tool error cards (#16888)

Co-authored-by: Adam <[email protected]>
David Hill há 1 mês atrás
pai
commit
f77e5cf8fb

+ 87 - 22
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;
-
-  &[data-variant="error"] {
-    background-color: var(--surface-critical-weak);
-    border: 1px solid var(--border-critical-base);
-    color: rgba(218, 51, 25, 0.6);
-
-    /* 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);
-
-    &[data-component="icon"] {
-      color: var(--icon-critical-active);
-    }
+  padding: var(--card-pad-y) var(--card-pad-r) var(--card-pad-y) var(--card-pad-l);
+
+  /* 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);
+
+  --card-gap: 8px;
+  --card-icon: 16px;
+  --card-indent: 0px;
+  --card-line-pad: 8px;
+
+  --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);
   }
 }

+ 6 - 8
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 variant={props.variant}>
-        <div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
-          <div style={{ flex: 1 }}>
-            <div style={{ fontWeight: 500 }}>Card title</div>
-            <div style={{ color: "var(--text-weak)", fontSize: "13px" }}>Small supporting text.</div>
-          </div>
-          <Button size="small" variant="ghost">
+        <CardTitle variant={props.variant}>Card title</CardTitle>
+        <CardDescription>Small supporting text.</CardDescription>
+        <CardActions>
+          <Button size="small" variant="secondary">
             Action
           </Button>
-        </div>
+        </CardActions>
       </Card>
     )
   },

+ 104 - 3
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<string, string | number>), "--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 (
     <div
       {...rest}
       data-component="card"
-      data-variant={split.variant || "normal"}
+      data-variant={variant()}
+      style={mix(split.style, accent())}
       classList={{
         ...(split.classList ?? {}),
         [split.class ?? ""]: !!split.class,
@@ -20,3 +61,63 @@ export function Card(props: CardProps) {
     </div>
   )
 }
+
+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 (
+    <div
+      {...rest}
+      data-slot="card-title"
+      classList={{
+        ...(split.classList ?? {}),
+        [split.class ?? ""]: !!split.class,
+      }}
+    >
+      {show() ? (
+        <span data-slot="card-title-icon" data-placeholder={placeholder() || undefined}>
+          <Icon name={name() ?? "dash"} size="small" />
+        </span>
+      ) : null}
+      {split.children}
+    </div>
+  )
+}
+
+export function CardDescription(props: ComponentProps<"div">) {
+  const [split, rest] = splitProps(props, ["class", "classList", "children"])
+  return (
+    <div
+      {...rest}
+      data-slot="card-description"
+      classList={{
+        ...(split.classList ?? {}),
+        [split.class ?? ""]: !!split.class,
+      }}
+    >
+      {split.children}
+    </div>
+  )
+}
+
+export function CardActions(props: ComponentProps<"div">) {
+  const [split, rest] = splitProps(props, ["class", "classList", "children"])
+  return (
+    <div
+      {...rest}
+      data-slot="card-actions"
+      classList={{
+        ...(split.classList ?? {}),
+        [split.class ?? ""]: !!split.class,
+      }}
+    >
+      {split.children}
+    </div>
+  )
+}

+ 6 - 0
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);

+ 0 - 36
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"],

+ 2 - 19
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) {
                   </div>
                 )
               }
-              const [title, ...rest] = cleaned.split(": ")
-              return (
-                <Card variant="error">
-                  <div data-component="tool-error">
-                    <Icon name="circle-ban-sign" size="small" />
-                    <Switch>
-                      <Match when={title && title.length < 30}>
-                        <div data-slot="message-part-tool-error-content">
-                          <div data-slot="message-part-tool-error-title">{title}</div>
-                          <span data-slot="message-part-tool-error-message">{rest.join(": ")}</span>
-                        </div>
-                      </Match>
-                      <Match when={true}>
-                        <span data-slot="message-part-tool-error-message">{cleaned}</span>
-                      </Match>
-                    </Switch>
-                  </div>
-                </Card>
-              )
+              return <ToolErrorCard tool={part().tool} error={error()} />
             }}
           </Match>
           <Match when={true}>

+ 54 - 0
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);
+  }
+}

+ 96 - 0
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 <ToolErrorCard tool={props.tool} error={props.error} />
+  },
+}
+
+export const All = {
+  render: () => {
+    return (
+      <div style="display: flex; flex-direction: column; gap: 12px; max-width: 720px;">
+        {samples.map((item) => (
+          <ToolErrorCard tool={item.tool} error={item.error} />
+        ))}
+      </div>
+    )
+  },
+}

+ 112 - 0
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<ComponentProps<typeof Card>, "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<string, string> = {
+      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 (
+    <Card {...rest} data-kind="tool-error-card" data-open={open() ? "true" : "false"} variant="error">
+      <Collapsible class="tool-collapsible" data-open={open() ? "true" : "false"} open={open()} onOpenChange={setOpen}>
+        <Collapsible.Trigger>
+          <div data-component="tool-trigger">
+            <div data-slot="basic-tool-tool-trigger-content">
+              <span data-slot="basic-tool-tool-indicator" data-component="tool-error-card-icon">
+                <Icon name="circle-ban-sign" size="small" style={{ "stroke-width": 1.5 }} />
+              </span>
+              <div data-slot="basic-tool-tool-info">
+                <div data-slot="basic-tool-tool-info-structured">
+                  <div data-slot="basic-tool-tool-info-main">
+                    <span data-slot="basic-tool-tool-title">{name()}</span>
+                    <span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>
+                  </div>
+                </div>
+              </div>
+            </div>
+            <Collapsible.Arrow />
+          </div>
+        </Collapsible.Trigger>
+        <Collapsible.Content>
+          <div data-slot="tool-error-card-content">
+            <Show when={open()}>
+              <div data-slot="tool-error-card-copy">
+                <Tooltip value={copied() ? i18n.t("ui.message.copied") : "Copy error"} placement="top" gutter={4}>
+                  <IconButton
+                    icon={copied() ? "check" : "copy"}
+                    size="normal"
+                    variant="ghost"
+                    onMouseDown={(e) => e.preventDefault()}
+                    onClick={(e) => {
+                      e.stopPropagation()
+                      copy()
+                    }}
+                    aria-label={copied() ? i18n.t("ui.message.copied") : "Copy error"}
+                  />
+                </Tooltip>
+              </div>
+            </Show>
+            <Show when={body()}>{(value) => <CardDescription>{value()}</CardDescription>}</Show>
+          </div>
+        </Collapsible.Content>
+      </Collapsible>
+    </Card>
+  )
+}

+ 1 - 0
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);

+ 2 - 2
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;

+ 6 - 0
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"
     }
   }
 }