diff options
| author | Kit Langton <[email protected]> | 2026-03-07 06:25:22 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-03-07 05:25:22 -0600 |
| commit | bbd0f3a25283b6f9567a04e79d7f6972950ab0a6 (patch) | |
| tree | 56cb41ff9c67749c6fa894fef32bb14cefd73db3 /packages/ui/src/components/basic-tool.tsx | |
| parent | b7e208b4f1e6641a1cbb1e13f59789c7b7f4c60a (diff) | |
| download | opencode-bbd0f3a25283b6f9567a04e79d7f6972950ab0a6.tar.gz opencode-bbd0f3a25283b6f9567a04e79d7f6972950ab0a6.zip | |
STUPID SEXY TIMELINE (#16420)
Diffstat (limited to 'packages/ui/src/components/basic-tool.tsx')
| -rw-r--r-- | packages/ui/src/components/basic-tool.tsx | 356 |
1 files changed, 259 insertions, 97 deletions
diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 4ad91824d..3210b4870 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -1,8 +1,20 @@ -import { createEffect, createSignal, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js" -import { animate, type AnimationPlaybackControls } from "motion" +import { + createEffect, + createSignal, + For, + Match, + on, + onCleanup, + onMount, + Show, + splitProps, + Switch, + type JSX, +} from "solid-js" +import { animate, type AnimationPlaybackControls, tunableSpringValue, COLLAPSIBLE_SPRING } from "./motion" import { Collapsible } from "./collapsible" -import type { IconProps } from "./icon" import { TextShimmer } from "./text-shimmer" +import { hold } from "./tool-utils" export type TriggerTitle = { title: string @@ -20,26 +32,99 @@ const isTriggerTitle = (val: any): val is TriggerTitle => { ) } -export interface BasicToolProps { - icon: IconProps["name"] +interface ToolCallPanelBaseProps { + icon: string trigger: TriggerTitle | JSX.Element children?: JSX.Element status?: string + animate?: boolean hideDetails?: boolean defaultOpen?: boolean forceOpen?: boolean defer?: boolean locked?: boolean - animated?: boolean + watchDetails?: boolean + springContent?: boolean onSubtitleClick?: () => void } -const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 } +function ToolCallTriggerBody(props: { + trigger: TriggerTitle | JSX.Element + pending: boolean + onSubtitleClick?: () => void + arrow?: boolean +}) { + return ( + <div data-component="tool-trigger" data-arrow={props.arrow ? "" : undefined}> + <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> + ) +} -export function BasicTool(props: BasicToolProps) { +function ToolCallPanel(props: ToolCallPanelBaseProps) { const [open, setOpen] = createSignal(props.defaultOpen ?? false) const [ready, setReady] = createSignal(open()) - const pending = () => props.status === "pending" || props.status === "running" + const pendingRaw = () => props.status === "pending" || props.status === "running" + const pending = hold(pendingRaw, 1000) + const watchDetails = () => props.watchDetails !== false let frame: number | undefined @@ -59,7 +144,7 @@ export function BasicTool(props: BasicToolProps) { on( open, (value) => { - if (!props.defer) return + if (!props.defer || props.springContent) return if (!value) { cancel() setReady(false) @@ -77,36 +162,110 @@ export function BasicTool(props: BasicToolProps) { ), ) - // Animated height for collapsible open/close + // Animated content height — single springValue drives all height changes let contentRef: HTMLDivElement | undefined - let heightAnim: AnimationPlaybackControls | undefined + let bodyRef: HTMLDivElement | undefined + let fadeAnim: AnimationPlaybackControls | undefined + let observer: ResizeObserver | undefined + let resizeFrame: number | undefined const initialOpen = open() + const heightSpring = tunableSpringValue<number>(0, COLLAPSIBLE_SPRING) + + const read = () => Math.max(0, Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0)) + + const doOpen = () => { + if (!contentRef || !bodyRef) return + contentRef.style.display = "" + // Ensure fade starts from 0 if content was hidden (first open or after close cleared styles) + if (bodyRef.style.opacity === "") { + bodyRef.style.opacity = "0" + bodyRef.style.filter = "blur(2px)" + } + const next = read() + fadeAnim?.stop() + fadeAnim = animate(bodyRef, { opacity: 1, filter: "blur(0px)" }, COLLAPSIBLE_SPRING) + fadeAnim.finished.then(() => { + if (!bodyRef) return + bodyRef.style.opacity = "" + bodyRef.style.filter = "" + }) + heightSpring.set(next) + } + + const doClose = () => { + if (!contentRef || !bodyRef) return + fadeAnim?.stop() + fadeAnim = animate(bodyRef, { opacity: 0, filter: "blur(2px)" }, COLLAPSIBLE_SPRING) + fadeAnim.finished.then(() => { + if (!contentRef || open()) return + contentRef.style.display = "none" + }) + heightSpring.set(0) + } + + const grow = () => { + if (!contentRef || !open()) return + const next = read() + if (Math.abs(next - heightSpring.get()) < 1) return + heightSpring.set(next) + } + + onMount(() => { + if (!props.springContent || props.animate === false || !contentRef || !bodyRef) return + + const offChange = heightSpring.on("change", (v) => { + if (!contentRef) return + contentRef.style.height = `${Math.max(0, Math.ceil(v))}px` + }) + onCleanup(() => { + offChange() + }) + + if (watchDetails()) { + observer = new ResizeObserver(() => { + if (resizeFrame !== undefined) return + resizeFrame = requestAnimationFrame(() => { + resizeFrame = undefined + grow() + }) + }) + observer.observe(bodyRef) + } + + if (!open()) return + if (contentRef.style.display !== "none") { + const next = read() + heightSpring.jump(next) + contentRef.style.height = `${next}px` + return + } + let mountFrame: number | undefined = requestAnimationFrame(() => { + mountFrame = undefined + if (!open()) return + doOpen() + }) + onCleanup(() => { + if (mountFrame !== undefined) cancelAnimationFrame(mountFrame) + }) + }) createEffect( on( open, (isOpen) => { - if (!props.animated || !contentRef) return - heightAnim?.stop() - if (isOpen) { - contentRef.style.overflow = "hidden" - heightAnim = animate(contentRef, { height: "auto" }, SPRING) - heightAnim.finished.then(() => { - if (!contentRef || !open()) return - contentRef.style.overflow = "visible" - contentRef.style.height = "auto" - }) - } else { - contentRef.style.overflow = "hidden" - heightAnim = animate(contentRef, { height: "0px" }, SPRING) - } + if (!props.springContent || props.animate === false || !contentRef) return + if (isOpen) doOpen() + else doClose() }, { defer: true }, ), ) onCleanup(() => { - heightAnim?.stop() + if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame) + observer?.disconnect() + fadeAnim?.stop() + heightSpring.destroy() }) const handleOpenChange = (value: boolean) => { @@ -118,85 +277,34 @@ 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.children && !props.hideDetails}> + <Show when={props.springContent && props.animate !== false && props.children && !props.hideDetails}> <div ref={contentRef} data-slot="collapsible-content" - data-animated + data-spring-content style={{ height: initialOpen ? "auto" : "0px", - overflow: initialOpen ? "visible" : "hidden", + overflow: "hidden", + display: initialOpen ? undefined : "none", }} > - {props.children} + <div ref={bodyRef} data-slot="basic-tool-content-inner"> + {props.children} + </div> </div> </Show> - <Show when={!props.animated && props.children && !props.hideDetails}> + <Show when={(!props.springContent || props.animate === false) && props.children && !props.hideDetails}> <Collapsible.Content> - <Show when={!props.defer || ready()}>{props.children}</Show> + <Show when={!props.defer || ready()}> + <div data-slot="basic-tool-content-inner">{props.children}</div> + </Show> </Collapsible.Content> </Show> </Collapsible> @@ -222,6 +330,60 @@ function args(input: Record<string, unknown> | undefined) { .slice(0, 3) } +export interface ToolCallRowProps { + variant: "row" + icon: string + trigger: TriggerTitle | JSX.Element + status?: string + animate?: boolean + onSubtitleClick?: () => void + open?: boolean + showArrow?: boolean + onOpenChange?: (value: boolean) => void +} +export interface ToolCallPanelProps extends Omit<ToolCallPanelBaseProps, "hideDetails"> { + variant: "panel" +} +export type ToolCallProps = ToolCallRowProps | ToolCallPanelProps +function ToolCallRoot(props: ToolCallProps) { + const pending = () => props.status === "pending" || props.status === "running" + if (props.variant === "row") { + return ( + <Show + when={props.onOpenChange} + fallback={ + <div data-component="collapsible" data-variant="normal" class="tool-collapsible"> + <div data-slot="collapsible-trigger"> + <ToolCallTriggerBody + trigger={props.trigger} + pending={pending()} + onSubtitleClick={props.onSubtitleClick} + /> + </div> + </div> + } + > + {(onOpenChange) => ( + <Collapsible open={props.open ?? true} onOpenChange={onOpenChange()} class="tool-collapsible"> + <Collapsible.Trigger> + <ToolCallTriggerBody + trigger={props.trigger} + pending={pending()} + onSubtitleClick={props.onSubtitleClick} + arrow={!!props.showArrow} + /> + </Collapsible.Trigger> + </Collapsible> + )} + </Show> + ) + } + + const [, rest] = splitProps(props, ["variant"]) + return <ToolCallPanel {...rest} /> +} +export const ToolCall = ToolCallRoot + export function GenericTool(props: { tool: string status?: string @@ -229,7 +391,8 @@ export function GenericTool(props: { input?: Record<string, unknown> }) { return ( - <BasicTool + <ToolCall + variant={props.hideDetails ? "row" : "panel"} icon="mcp" status={props.status} trigger={{ @@ -237,7 +400,6 @@ export function GenericTool(props: { subtitle: label(props.input), args: args(props.input), }} - hideDetails={props.hideDetails} /> ) } |
