mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-10 08:34:10 +00:00
Compare commits
6 Commits
add-api-sh
...
error-card
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f77a24ad87 | ||
|
|
0941a40953 | ||
|
|
eaaf2c64db | ||
|
|
269b6da131 | ||
|
|
16f50213a7 | ||
|
|
bdc423d622 |
@@ -2,28 +2,89 @@
|
||||
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: 10px 12px 10px 10px;
|
||||
|
||||
&[data-variant="error"] {
|
||||
background-color: var(--surface-critical-weak);
|
||||
border: 1px solid var(--border-critical-base);
|
||||
color: rgba(218, 51, 25, 0.6);
|
||||
&:has([data-slot="card-title"]) {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
/* 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);
|
||||
|
||||
&[data-component="icon"] {
|
||||
color: var(--icon-critical-active);
|
||||
}
|
||||
--card-gap: 8px;
|
||||
--card-icon: 16px;
|
||||
--card-indent: calc(var(--card-icon) + var(--card-gap));
|
||||
|
||||
--card-accent: var(--icon-active);
|
||||
|
||||
&[data-icon="none"] {
|
||||
--card-indent: 0px;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
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);
|
||||
padding-left: var(--card-indent);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
:where([data-card="actions"], [data-slot="card-actions"]) {
|
||||
padding-left: var(--card-indent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>Card title</CardTitle>
|
||||
<CardDescription>Small supporting text.</CardDescription>
|
||||
<CardActions>
|
||||
<Button size="small" variant="secondary">
|
||||
Action
|
||||
</Button>
|
||||
</div>
|
||||
</CardActions>
|
||||
</Card>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,22 +1,135 @@
|
||||
import { type ComponentProps, splitProps } from "solid-js"
|
||||
import { createContext, type ComponentProps, splitProps, useContext } from "solid-js"
|
||||
import { Icon, type IconProps } from "./icon"
|
||||
|
||||
export interface CardProps extends ComponentProps<"div"> {
|
||||
variant?: "normal" | "error" | "warning" | "success" | "info"
|
||||
|
||||
/**
|
||||
* Optional card icon used by `CardTitle`.
|
||||
*
|
||||
* - `undefined`: picks a default icon based on `variant` (error/warning/info)
|
||||
* - `false`/`null`: disables the icon
|
||||
* - `Icon` name: forces a specific icon
|
||||
*/
|
||||
icon?: IconProps["name"] | false | null
|
||||
}
|
||||
|
||||
type Ctx = {
|
||||
variant: NonNullable<CardProps["variant"]>
|
||||
mode: "none" | "set" | "placeholder"
|
||||
icon?: IconProps["name"]
|
||||
}
|
||||
|
||||
const Ctx = createContext<Ctx>()
|
||||
|
||||
function pick(variant: NonNullable<CardProps["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", "icon", "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
|
||||
}
|
||||
const mode = () => {
|
||||
if (split.icon === false || split.icon === null) return "none" as const
|
||||
if (typeof split.icon === "string") return "set" as const
|
||||
return pick(variant()) ? ("set" as const) : ("placeholder" as const)
|
||||
}
|
||||
const icon = () => {
|
||||
if (split.icon === false || split.icon === null) return
|
||||
if (typeof split.icon === "string") return split.icon
|
||||
return pick(variant())
|
||||
}
|
||||
return (
|
||||
<Ctx.Provider value={{ variant: variant(), mode: mode(), icon: icon() }}>
|
||||
<div
|
||||
{...rest}
|
||||
data-component="card"
|
||||
data-variant={variant()}
|
||||
data-icon={mode()}
|
||||
style={mix(split.style, accent())}
|
||||
classList={{
|
||||
...(split.classList ?? {}),
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</Ctx.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardTitle(props: ComponentProps<"div">) {
|
||||
const [split, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
const ctx = useContext(Ctx)
|
||||
const show = () => ctx?.mode !== "none"
|
||||
const name = () => ctx?.icon ?? ("dash" as const)
|
||||
const placeholder = () => !ctx?.icon
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
data-component="card"
|
||||
data-variant={split.variant || "normal"}
|
||||
data-slot="card-title"
|
||||
classList={{
|
||||
...(split.classList ?? {}),
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
{show() ? (
|
||||
<span data-slot="card-title-icon" data-placeholder={placeholder() || undefined}>
|
||||
<Icon name={name()} 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -305,41 +305,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;
|
||||
@@ -713,7 +678,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"],
|
||||
|
||||
@@ -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}>
|
||||
|
||||
26
packages/ui/src/components/tool-error-card.css
Normal file
26
packages/ui/src/components/tool-error-card.css
Normal file
@@ -0,0 +1,26 @@
|
||||
[data-component="card"][data-kind="tool-error-card"] {
|
||||
padding: 0;
|
||||
|
||||
&::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
[data-component="tool-error-card-icon"] [data-component="icon"] {
|
||||
color: var(--card-accent);
|
||||
}
|
||||
|
||||
&[data-open="true"] [data-slot="tool-error-card-content"] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&[data-open="true"] [data-slot="tool-error-card-content"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: calc((var(--card-icon) / 2) - 1px);
|
||||
top: calc(var(--card-gap) * -1);
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--card-accent);
|
||||
}
|
||||
}
|
||||
96
packages/ui/src/components/tool-error-card.stories.tsx
Normal file
96
packages/ui/src/components/tool-error-card.stories.tsx
Normal file
@@ -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>
|
||||
)
|
||||
},
|
||||
}
|
||||
84
packages/ui/src/components/tool-error-card.tsx
Normal file
84
packages/ui/src/components/tool-error-card.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { type ComponentProps, createMemo, createSignal, Show, splitProps } from "solid-js"
|
||||
import { Card, CardDescription } from "./card"
|
||||
import { Collapsible } from "./collapsible"
|
||||
import { Icon } from "./icon"
|
||||
import { useI18n } from "../context/i18n"
|
||||
|
||||
export interface ToolErrorCardProps extends Omit<ComponentProps<typeof Card>, "children" | "variant" | "icon"> {
|
||||
tool: string
|
||||
error: string
|
||||
}
|
||||
|
||||
export function ToolErrorCard(props: ToolErrorCardProps) {
|
||||
const i18n = useI18n()
|
||||
const [open, setOpen] = createSignal(true)
|
||||
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()
|
||||
})
|
||||
|
||||
return (
|
||||
<Card {...rest} data-kind="tool-error-card" data-open={open() ? "true" : "false"} variant="error">
|
||||
<Collapsible class="tool-collapsible" 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" />
|
||||
</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={body()}>{(value) => <CardDescription>{value()}</CardDescription>}</Show>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user