refactor(ui): standardize tool call variants

This commit is contained in:
Kit Langton
2026-03-03 10:16:18 -05:00
parent 8851c619b4
commit 45eb4be7f2
5 changed files with 243 additions and 149 deletions

View File

@@ -4,19 +4,21 @@ import * as mod from "./basic-tool"
import { create } from "../storybook/scaffold"
const docs = `### Overview
Expandable tool panel with a structured trigger and optional details.
Tool call surface with explicit row and panel variants.
Use structured triggers for consistent layout; custom triggers allowed.
### API
- Required: \`icon\` and \`trigger\` (structured or custom JSX).
- Optional: \`status\`, \`defaultOpen\`, \`forceOpen\`, \`defer\`, \`locked\`.
- Required: \`variant\`, \`icon\`, and \`trigger\`.
- Row tools render summary-only.
- Panel/group tools support \`defaultOpen\`, \`forceOpen\`, \`defer\`, and \`locked\`.
### Variants and states
- Pending/running status animates the title via TextShimmer.
### Behavior
- Uses Collapsible; can defer content rendering until open.
- Row tools skip collapsible state and render lightweight trigger-only markup.
- Panel/group tools use Collapsible and can defer content rendering until open.
- Locked state prevents closing.
### Accessibility
@@ -28,13 +30,15 @@ Use structured triggers for consistent layout; custom triggers allowed.
`
const story = create({
title: "UI/Basic Tool",
title: "UI/Tool Call",
mod,
name: "ToolCall",
args: {
variant: "panel",
icon: "mcp",
defaultOpen: true,
trigger: {
title: "Basic Tool",
title: "Tool Call",
subtitle: "Example subtitle",
args: ["--flag", "value"],
},
@@ -43,8 +47,8 @@ const story = create({
})
export default {
title: "UI/Basic Tool",
id: "components-basic-tool",
title: "UI/Tool Call",
id: "components-tool-call",
component: story.meta.component,
tags: ["autodocs"],
parameters: {
@@ -60,6 +64,7 @@ export const Basic = story.Basic
export const Pending = {
args: {
variant: "panel",
status: "pending",
trigger: {
title: "Running tool",
@@ -71,6 +76,7 @@ export const Pending = {
export const Locked = {
args: {
variant: "panel",
locked: true,
trigger: {
title: "Locked tool",
@@ -82,6 +88,7 @@ export const Locked = {
export const Deferred = {
args: {
variant: "panel",
defer: true,
defaultOpen: false,
trigger: {
@@ -94,6 +101,7 @@ export const Deferred = {
export const ForceOpen = {
args: {
variant: "panel",
forceOpen: true,
trigger: {
title: "Forced open",
@@ -103,14 +111,14 @@ export const ForceOpen = {
},
}
export const HideDetails = {
export const Row = {
args: {
hideDetails: true,
variant: "row",
icon: "mcp",
trigger: {
title: "Summary only",
subtitle: "Details hidden",
subtitle: "Lightweight row",
},
children: "Hidden content",
},
}
@@ -120,13 +128,14 @@ export const SubtitleAction = {
return (
<div style={{ display: "grid", gap: "8px" }}>
<div style={{ "font-size": "12px", color: "var(--text-weak)" }}>{message()}</div>
<mod.BasicTool
<mod.ToolCall
variant="panel"
icon="mcp"
trigger={{ title: "Clickable subtitle", subtitle: "Click me" }}
onSubtitleClick={() => setMessage("Subtitle clicked")}
>
Subtitle action details
</mod.BasicTool>
</mod.ToolCall>
</div>
)
},

View File

@@ -1,4 +1,16 @@
import { createEffect, createSignal, For, Match, on, onCleanup, onMount, Show, Switch, type JSX } from "solid-js"
import {
createEffect,
createSignal,
For,
Match,
on,
onCleanup,
onMount,
Show,
splitProps,
Switch,
type JSX,
} from "solid-js"
import {
animate,
type AnimationPlaybackControls,
@@ -26,7 +38,7 @@ const isTriggerTitle = (val: any): val is TriggerTitle => {
)
}
export interface BasicToolProps {
interface ToolCallPanelBaseProps {
icon: IconProps["name"]
trigger: TriggerTitle | JSX.Element
children?: JSX.Element
@@ -44,7 +56,78 @@ export interface BasicToolProps {
onSubtitleClick?: () => void
}
export function BasicTool(props: BasicToolProps) {
function ToolCallTriggerBody(props: {
trigger: TriggerTitle | JSX.Element
pending: boolean
onSubtitleClick?: () => void
arrow?: boolean
}) {
return (
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
<div data-slot="basic-tool-tool-info">
<Switch>
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
{(trigger) => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span
data-slot="basic-tool-tool-title"
classList={{
[trigger().titleClass ?? ""]: !!trigger().titleClass,
}}
>
<TextShimmer text={trigger().title} active={props.pending} />
</span>
<Show when={!props.pending}>
<Show when={trigger().subtitle}>
<span
data-slot="basic-tool-tool-subtitle"
classList={{
[trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
clickable: !!props.onSubtitleClick,
}}
onClick={(e) => {
if (!props.onSubtitleClick) return
e.stopPropagation()
props.onSubtitleClick()
}}
>
{trigger().subtitle}
</span>
</Show>
<Show when={trigger().args?.length}>
<For each={trigger().args}>
{(arg) => (
<span
data-slot="basic-tool-tool-arg"
classList={{
[trigger().argsClass ?? ""]: !!trigger().argsClass,
}}
>
{arg}
</span>
)}
</For>
</Show>
</Show>
</div>
<Show when={!props.pending && trigger().action}>{trigger().action}</Show>
</div>
)}
</Match>
<Match when={true}>{props.trigger as JSX.Element}</Match>
</Switch>
</div>
</div>
<Show when={props.arrow}>
<Collapsible.Arrow />
</Show>
</div>
)
}
function ToolCallPanel(props: ToolCallPanelBaseProps) {
const [open, setOpen] = createSignal(props.defaultOpen ?? false)
const [ready, setReady] = createSignal(open())
const pending = () => props.status === "pending" || props.status === "running"
@@ -197,68 +280,12 @@ export function BasicTool(props: BasicToolProps) {
return (
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
<Collapsible.Trigger>
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
<div data-slot="basic-tool-tool-info">
<Switch>
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
{(trigger) => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span
data-slot="basic-tool-tool-title"
classList={{
[trigger().titleClass ?? ""]: !!trigger().titleClass,
}}
>
<TextShimmer text={trigger().title} active={pending()} />
</span>
<Show when={!pending()}>
<Show when={trigger().subtitle}>
<span
data-slot="basic-tool-tool-subtitle"
classList={{
[trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
clickable: !!props.onSubtitleClick,
}}
onClick={(e) => {
if (props.onSubtitleClick) {
e.stopPropagation()
props.onSubtitleClick()
}
}}
>
{trigger().subtitle}
</span>
</Show>
<Show when={trigger().args?.length}>
<For each={trigger().args}>
{(arg) => (
<span
data-slot="basic-tool-tool-arg"
classList={{
[trigger().argsClass ?? ""]: !!trigger().argsClass,
}}
>
{arg}
</span>
)}
</For>
</Show>
</Show>
</div>
<Show when={!pending() && trigger().action}>{trigger().action}</Show>
</div>
)}
</Match>
<Match when={true}>{props.trigger as JSX.Element}</Match>
</Switch>
</div>
</div>
<Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
<Collapsible.Arrow />
</Show>
</div>
<ToolCallTriggerBody
trigger={props.trigger}
pending={pending()}
onSubtitleClick={props.onSubtitleClick}
arrow={!!props.children && !props.hideDetails && !props.locked && !pending()}
/>
</Collapsible.Trigger>
<Show when={props.animated && props.animate !== false && props.children && !props.hideDetails}>
<div
@@ -287,6 +314,67 @@ export function BasicTool(props: BasicToolProps) {
)
}
export function GenericTool(props: { tool: string; status?: string; hideDetails?: boolean }) {
return <BasicTool icon="mcp" status={props.status} trigger={{ title: props.tool }} hideDetails={props.hideDetails} />
export interface ToolCallRowProps {
variant: "row"
icon: IconProps["name"]
trigger: TriggerTitle | JSX.Element
status?: string
animate?: boolean
onSubtitleClick?: () => void
}
// `group` currently shares the same behavior as `panel`; the separate variant is semantic so grouped call sites can stay explicit.
export interface ToolCallPanelProps extends Omit<ToolCallPanelBaseProps, "hideDetails"> {
variant: "panel" | "group"
}
export type ToolCallProps = ToolCallRowProps | ToolCallPanelProps
function ToolCallRoot(props: ToolCallProps) {
const [local, rest] = splitProps(props, ["variant", "trigger", "status", "onSubtitleClick"])
const pending = () => local.status === "pending" || local.status === "running"
if (local.variant === "row") {
return (
<div data-component="collapsible" data-variant="normal" class="tool-collapsible">
<div data-slot="collapsible-trigger">
<ToolCallTriggerBody trigger={local.trigger} pending={pending()} onSubtitleClick={local.onSubtitleClick} />
</div>
</div>
)
}
return (
<ToolCallPanel {...rest} trigger={local.trigger} status={local.status} onSubtitleClick={local.onSubtitleClick} />
)
}
function ToolCallList(props: { children?: JSX.Element }) {
return <div data-component="tool-call-list">{props.children}</div>
}
function ToolCallRow(props: { children: JSX.Element }) {
return (
<div data-slot="tool-call-item">
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
<div data-slot="basic-tool-tool-info">{props.children}</div>
</div>
</div>
</div>
)
}
export const ToolCall = Object.assign(ToolCallRoot, {
List: ToolCallList,
Row: ToolCallRow,
})
export function GenericTool(props: { tool: string; status?: string; hideDetails?: boolean }) {
return (
<ToolCall
variant={props.hideDetails ? "row" : "panel"}
icon="mcp"
status={props.status}
trigger={{ title: props.tool }}
/>
)
}

View File

@@ -637,16 +637,18 @@
}
}
[data-component="context-tool-group-list"] {
[data-component="context-tool-group-list"],
[data-component="tool-call-list"] {
margin-top: -4px;
display: flex;
flex-direction: column;
gap: 0px;
}
[data-slot="context-tool-group-item"] {
min-width: 0;
padding: 4px 0;
}
[data-component="context-tool-group-list"] [data-slot="context-tool-group-item"],
[data-component="tool-call-list"] [data-slot="tool-call-item"] {
min-width: 0;
padding: 4px 0;
}
[data-component="diagnostics"] {

View File

@@ -33,8 +33,7 @@ import { useData } from "../context"
import { useFileComponent } from "../context/file"
import { useDialog } from "../context/dialog"
import { useI18n } from "../context/i18n"
import { BasicTool } from "./basic-tool"
import { GenericTool } from "./basic-tool"
import { GenericTool, ToolCall } from "./basic-tool"
import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { Card } from "./card"
@@ -653,7 +652,8 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; animate?:
const summary = createMemo(() => contextToolSummary(props.parts))
return (
<BasicTool
<ToolCall
variant="group"
icon="magnifying-glass-menu"
animated
animate
@@ -703,39 +703,33 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; animate?:
</div>
}
>
<div data-component="context-tool-group-list">
<ToolCall.List>
<For each={props.parts}>
{(part) => {
const trigger = contextToolTrigger(part, i18n)
const running = createMemo(() => busy(part.state.status))
const reveal = useToolReveal(running, () => props.animate !== false)
return (
<div data-slot="context-tool-group-item">
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
<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">
<TextShimmer text={trigger.title} active={running()} />
</span>
<Show when={trigger.subtitle}>{(text) => <ToolText text={text()} animate={reveal()} />}</Show>
<Show when={trigger.args?.length}>
<For each={trigger.args}>
{(arg, idx) => <ToolArg text={arg} delay={0.02 * (idx() + 1)} animate={reveal()} />}
</For>
</Show>
</div>
</div>
</div>
<ToolCall.Row>
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<TextShimmer text={trigger.title} active={running()} />
</span>
<Show when={trigger.subtitle}>{(text) => <ToolText text={text()} animate={reveal()} />}</Show>
<Show when={trigger.args?.length}>
<For each={trigger.args}>
{(arg, idx) => <ToolArg text={arg} delay={0.02 * (idx() + 1)} animate={reveal()} />}
</For>
</Show>
</div>
</div>
</div>
</ToolCall.Row>
)
}}
</For>
</div>
</BasicTool>
</ToolCall.List>
</ToolCall>
)
}
@@ -1251,7 +1245,8 @@ ToolRegistry.register({
const pending = createMemo(() => busy(props.status))
return (
<>
<BasicTool
<ToolCall
variant="row"
{...props}
icon="glasses"
trigger={
@@ -1285,7 +1280,8 @@ ToolRegistry.register({
const i18n = useI18n()
const pending = createMemo(() => busy(props.status))
return (
<BasicTool
<ToolCall
variant="panel"
{...props}
icon="bullet-list"
trigger={
@@ -1304,7 +1300,7 @@ ToolRegistry.register({
</div>
)}
</Show>
</BasicTool>
</ToolCall>
)
},
})
@@ -1315,7 +1311,8 @@ ToolRegistry.register({
const i18n = useI18n()
const pending = createMemo(() => busy(props.status))
return (
<BasicTool
<ToolCall
variant="panel"
{...props}
icon="magnifying-glass-menu"
trigger={
@@ -1335,7 +1332,7 @@ ToolRegistry.register({
</div>
)}
</Show>
</BasicTool>
</ToolCall>
)
},
})
@@ -1349,7 +1346,8 @@ ToolRegistry.register({
if (props.input.include) args.push("include=" + props.input.include)
const pending = createMemo(() => busy(props.status))
return (
<BasicTool
<ToolCall
variant="panel"
{...props}
icon="magnifying-glass-menu"
trigger={
@@ -1369,7 +1367,7 @@ ToolRegistry.register({
</div>
)}
</Show>
</BasicTool>
</ToolCall>
)
},
})
@@ -1623,9 +1621,9 @@ ToolRegistry.register({
return value
})
return (
<BasicTool
<ToolCall
variant="row"
{...props}
hideDetails
icon="window-cursor"
trigger={
<div data-slot="basic-tool-tool-info-structured">
@@ -1716,7 +1714,7 @@ ToolRegistry.register({
</div>
)
return <BasicTool icon="task" status={props.status} trigger={trigger()} hideDetails animate />
return <ToolCall variant="row" icon="task" status={props.status} trigger={trigger()} animate />
},
})
@@ -1753,7 +1751,8 @@ ToolRegistry.register({
}
return (
<BasicTool
<ToolCall
variant="panel"
{...props}
icon="console"
animate
@@ -1794,7 +1793,7 @@ ToolRegistry.register({
</pre>
</div>
</div>
</BasicTool>
</ToolCall>
)
},
})
@@ -1811,7 +1810,8 @@ ToolRegistry.register({
const reveal = useToolReveal(pending, () => props.reveal !== false)
return (
<div data-component="edit-tool">
<BasicTool
<ToolCall
variant="panel"
{...props}
icon="code-lines"
animated
@@ -1861,7 +1861,7 @@ ToolRegistry.register({
</ToolFileAccordion>
</Show>
<DiagnosticsDisplay diagnostics={diagnostics()} />
</BasicTool>
</ToolCall>
</div>
)
},
@@ -1879,7 +1879,8 @@ ToolRegistry.register({
const reveal = useToolReveal(pending, () => props.reveal !== false)
return (
<div data-component="write-tool">
<BasicTool
<ToolCall
variant="panel"
{...props}
icon="code-lines"
animated
@@ -1919,7 +1920,7 @@ ToolRegistry.register({
</ToolFileAccordion>
</Show>
<DiagnosticsDisplay diagnostics={diagnostics()} />
</BasicTool>
</ToolCall>
</div>
)
},
@@ -1967,7 +1968,8 @@ ToolRegistry.register({
return (
<div data-component="apply-patch-tool">
<BasicTool
<ToolCall
variant="panel"
{...props}
icon="code-lines"
defer
@@ -2122,7 +2124,7 @@ ToolRegistry.register({
</ToolFileAccordion>
)}
</Show>
</BasicTool>
</ToolCall>
</div>
)
},
@@ -2150,7 +2152,8 @@ ToolRegistry.register({
})
return (
<BasicTool
<ToolCall
variant="panel"
{...props}
defaultOpen
icon="checklist"
@@ -2179,7 +2182,7 @@ ToolRegistry.register({
</For>
</div>
</Show>
</BasicTool>
</ToolCall>
)
},
})
@@ -2201,7 +2204,8 @@ ToolRegistry.register({
})
return (
<BasicTool
<ToolCall
variant="panel"
{...props}
defaultOpen={false}
icon="bubble-5"
@@ -2229,7 +2233,7 @@ ToolRegistry.register({
</For>
</div>
</Show>
</BasicTool>
</ToolCall>
)
},
})
@@ -2252,6 +2256,6 @@ ToolRegistry.register({
</div>
)
return <BasicTool icon="brain" status={props.status} trigger={trigger()} hideDetails animate />
return <ToolCall variant="row" icon="brain" status={props.status} trigger={trigger()} animate />
},
})

View File

@@ -1,6 +1,6 @@
// @ts-nocheck
import { createEffect, createSignal, onCleanup } from "solid-js"
import { BasicTool } from "./basic-tool"
import { ToolCall } from "./basic-tool"
import { animate } from "motion"
export default {
@@ -97,12 +97,7 @@ const ease = {
linear: "linear",
}
function SpringSubmessage(props: {
text: string
visible: boolean
visualDuration: number
bounce: number
}) {
function SpringSubmessage(props: { text: string; visible: boolean; visualDuration: number; bounce: number }) {
let ref: HTMLSpanElement | undefined
let widthRef: HTMLSpanElement | undefined
@@ -194,19 +189,15 @@ export const Playground = {
>
<style>{shellCss}</style>
<BasicTool
<ToolCall
variant="panel"
icon="console"
defaultOpen
trigger={
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">Shell</span>
<SpringSubmessage
text={text()}
visible={show()}
visualDuration={visualDuration()}
bounce={bounce()}
/>
<SpringSubmessage text={text()} visible={show()} visualDuration={visualDuration()} bounce={bounce()} />
</div>
</div>
}
@@ -225,7 +216,7 @@ export const Playground = {
>
{"$ cat <<'TOPIC1'"}
</div>
</BasicTool>
</ToolCall>
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
<button onClick={replay} style={btn()}>