diff options
| author | Adam <[email protected]> | 2026-03-09 07:36:39 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-03-09 07:36:39 -0500 |
| commit | c71d1bde5e8dcc8be49c15697ad2e5d0f2607e5e (patch) | |
| tree | a30482cedb38dc24cad70e24ad717817065620d6 /packages/ui/src/components | |
| parent | f27ef595f65aa719be3f8d08665d683e95083ed3 (diff) | |
| download | opencode-c71d1bde5e8dcc8be49c15697ad2e5d0f2607e5e.tar.gz opencode-c71d1bde5e8dcc8be49c15697ad2e5d0f2607e5e.zip | |
revert(app): "STUPID SEXY TIMELINE (#16420)" (#16745)
Diffstat (limited to 'packages/ui/src/components')
30 files changed, 1598 insertions, 4217 deletions
diff --git a/packages/ui/src/components/animated-number.css b/packages/ui/src/components/animated-number.css index b69ce6508..022b347e9 100644 --- a/packages/ui/src/components/animated-number.css +++ b/packages/ui/src/components/animated-number.css @@ -9,20 +9,19 @@ display: inline-flex; flex-direction: row-reverse; align-items: baseline; - justify-content: flex-start; + justify-content: flex-end; line-height: inherit; width: var(--animated-number-width, 1ch); - overflow: clip; - transition: width var(--tool-motion-spring-ms, 800ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); + overflow: hidden; + transition: width var(--tool-motion-spring-ms, 560ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); } [data-slot="animated-number-digit"] { display: inline-block; - flex-shrink: 0; width: 1ch; height: 1em; line-height: 1em; - overflow: clip; + overflow: hidden; vertical-align: baseline; -webkit-mask-image: linear-gradient( to bottom, @@ -47,7 +46,7 @@ flex-direction: column; transform: translateY(calc(var(--animated-number-offset, 10) * -1em)); transition-property: transform; - transition-duration: var(--animated-number-duration, 600ms); + transition-duration: var(--animated-number-duration, 560ms); transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); } diff --git a/packages/ui/src/components/animated-number.tsx b/packages/ui/src/components/animated-number.tsx index dfe368b8b..b5fceba25 100644 --- a/packages/ui/src/components/animated-number.tsx +++ b/packages/ui/src/components/animated-number.tsx @@ -1,7 +1,7 @@ import { For, Index, createEffect, createMemo, createSignal, on } from "solid-js" const TRACK = Array.from({ length: 30 }, (_, index) => index % 10) -const DURATION = 800 +const DURATION = 600 function normalize(value: number) { return ((value % 10) + 10) % 10 @@ -90,35 +90,10 @@ export function AnimatedNumber(props: { value: number; class?: string }) { ) const width = createMemo(() => `${digits().length}ch`) - const [exitingDigits, setExitingDigits] = createSignal<number[]>([]) - let exitTimer: number | undefined - - createEffect( - on( - digits, - (current, prev) => { - if (prev && current.length < prev.length) { - setExitingDigits(prev.slice(current.length)) - clearTimeout(exitTimer) - exitTimer = window.setTimeout(() => setExitingDigits([]), DURATION) - } else { - clearTimeout(exitTimer) - setExitingDigits([]) - } - }, - { defer: true }, - ), - ) - - const displayDigits = createMemo(() => { - const exiting = exitingDigits() - return exiting.length ? [...digits(), ...exiting] : digits() - }) - return ( <span data-component="animated-number" class={props.class} aria-label={label()}> <span data-slot="animated-number-value" style={{ "--animated-number-width": width() }}> - <Index each={displayDigits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index> + <Index each={digits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index> </span> </span> ) diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css index ad25bef32..1dbfce26e 100644 --- a/packages/ui/src/components/basic-tool.css +++ b/packages/ui/src/components/basic-tool.css @@ -8,28 +8,54 @@ justify-content: flex-start; [data-slot="basic-tool-tool-trigger-content"] { - width: 100%; - min-width: 0; + width: auto; display: flex; align-items: center; align-self: stretch; gap: 8px; } + [data-slot="basic-tool-tool-indicator"] { + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + [data-component="spinner"] { + width: 16px; + height: 16px; + } + } + + [data-slot="basic-tool-tool-spinner"] { + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--text-weak); + + [data-component="spinner"] { + width: 16px; + height: 16px; + } + } + [data-slot="icon-svg"] { flex-shrink: 0; } [data-slot="basic-tool-tool-info"] { - flex: 1 1 auto; + flex: 0 1 auto; min-width: 0; font-size: 14px; } [data-slot="basic-tool-tool-info-structured"] { width: auto; - max-width: 100%; - min-width: 0; display: flex; align-items: center; gap: 8px; @@ -37,12 +63,11 @@ } [data-slot="basic-tool-tool-info-main"] { - flex: 0 1 auto; display: flex; - align-items: center; + align-items: baseline; gap: 8px; min-width: 0; - overflow: clip; + overflow: hidden; } [data-slot="basic-tool-tool-title"] { @@ -54,14 +79,22 @@ line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); color: var(--text-strong); + + &.capitalize { + text-transform: capitalize; + } + + &.agent-title { + color: var(--text-strong); + font-weight: var(--font-weight-medium); + } } [data-slot="basic-tool-tool-subtitle"] { - display: inline-block; - flex: 0 1 auto; - max-width: 100%; + flex-shrink: 1; min-width: 0; - overflow: clip; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-family-sans); font-variant-numeric: tabular-nums; @@ -106,7 +139,8 @@ [data-slot="basic-tool-tool-arg"] { flex-shrink: 1; min-width: 0; - overflow: clip; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-family-sans); font-variant-numeric: tabular-nums; diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 3210b4870..4ad91824d 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -1,20 +1,8 @@ -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 { createEffect, createSignal, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js" +import { animate, type AnimationPlaybackControls } 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 @@ -32,99 +20,26 @@ const isTriggerTitle = (val: any): val is TriggerTitle => { ) } -interface ToolCallPanelBaseProps { - icon: string +export interface BasicToolProps { + icon: IconProps["name"] trigger: TriggerTitle | JSX.Element children?: JSX.Element status?: string - animate?: boolean hideDetails?: boolean defaultOpen?: boolean forceOpen?: boolean defer?: boolean locked?: boolean - watchDetails?: boolean - springContent?: boolean + animated?: boolean onSubtitleClick?: () => void } -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> - ) -} +const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 } -function ToolCallPanel(props: ToolCallPanelBaseProps) { +export function BasicTool(props: BasicToolProps) { const [open, setOpen] = createSignal(props.defaultOpen ?? false) const [ready, setReady] = createSignal(open()) - const pendingRaw = () => props.status === "pending" || props.status === "running" - const pending = hold(pendingRaw, 1000) - const watchDetails = () => props.watchDetails !== false + const pending = () => props.status === "pending" || props.status === "running" let frame: number | undefined @@ -144,7 +59,7 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) { on( open, (value) => { - if (!props.defer || props.springContent) return + if (!props.defer) return if (!value) { cancel() setReady(false) @@ -162,110 +77,36 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) { ), ) - // Animated content height — single springValue drives all height changes + // Animated height for collapsible open/close let contentRef: HTMLDivElement | undefined - let bodyRef: HTMLDivElement | undefined - let fadeAnim: AnimationPlaybackControls | undefined - let observer: ResizeObserver | undefined - let resizeFrame: number | undefined + let heightAnim: AnimationPlaybackControls | 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.springContent || props.animate === false || !contentRef) return - if (isOpen) doOpen() - else doClose() + 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) + } }, { defer: true }, ), ) onCleanup(() => { - if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame) - observer?.disconnect() - fadeAnim?.stop() - heightSpring.destroy() + heightAnim?.stop() }) const handleOpenChange = (value: boolean) => { @@ -277,34 +118,85 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) { return ( <Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible"> <Collapsible.Trigger> - <ToolCallTriggerBody - trigger={props.trigger} - pending={pending()} - onSubtitleClick={props.onSubtitleClick} - arrow={!!props.children && !props.hideDetails && !props.locked && !pending()} - /> + <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> </Collapsible.Trigger> - <Show when={props.springContent && props.animate !== false && props.children && !props.hideDetails}> + <Show when={props.animated && props.children && !props.hideDetails}> <div ref={contentRef} data-slot="collapsible-content" - data-spring-content + data-animated style={{ height: initialOpen ? "auto" : "0px", - overflow: "hidden", - display: initialOpen ? undefined : "none", + overflow: initialOpen ? "visible" : "hidden", }} > - <div ref={bodyRef} data-slot="basic-tool-content-inner"> - {props.children} - </div> + {props.children} </div> </Show> - <Show when={(!props.springContent || props.animate === false) && props.children && !props.hideDetails}> + <Show when={!props.animated && props.children && !props.hideDetails}> <Collapsible.Content> - <Show when={!props.defer || ready()}> - <div data-slot="basic-tool-content-inner">{props.children}</div> - </Show> + <Show when={!props.defer || ready()}>{props.children}</Show> </Collapsible.Content> </Show> </Collapsible> @@ -330,60 +222,6 @@ 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 @@ -391,8 +229,7 @@ export function GenericTool(props: { input?: Record<string, unknown> }) { return ( - <ToolCall - variant={props.hideDetails ? "row" : "panel"} + <BasicTool icon="mcp" status={props.status} trigger={{ @@ -400,6 +237,7 @@ export function GenericTool(props: { subtitle: label(props.input), args: args(props.input), }} + hideDetails={props.hideDetails} /> ) } diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css index 1a86338bd..bab2c4f92 100644 --- a/packages/ui/src/components/collapsible.css +++ b/packages/ui/src/components/collapsible.css @@ -8,18 +8,14 @@ border-radius: var(--radius-md); overflow: visible; - &.tool-collapsible [data-slot="collapsible-trigger"] { - height: 37px; - } - - &.tool-collapsible [data-slot="basic-tool-content-inner"] { - padding-top: 0; + &.tool-collapsible { + gap: 8px; } [data-slot="collapsible-trigger"] { width: 100%; display: flex; - height: 36px; + height: 32px; padding: 0; align-items: center; align-self: stretch; @@ -27,17 +23,6 @@ user-select: none; color: var(--text-base); - > [data-component="tool-trigger"][data-arrow] { - width: auto; - max-width: 100%; - flex: 0 1 auto; - - [data-slot="basic-tool-tool-trigger-content"] { - width: auto; - max-width: 100%; - } - } - [data-slot="collapsible-arrow"] { opacity: 0; transition: opacity 0.15s ease; @@ -65,6 +50,9 @@ line-height: var(--line-height-large); /* 166.667% */ letter-spacing: var(--letter-spacing-normal); + /* &:hover { */ + /* background-color: var(--surface-base); */ + /* } */ &:focus-visible { outline: none; background-color: var(--surface-raised-base-hover); @@ -94,16 +82,16 @@ } [data-slot="collapsible-content"] { - overflow: clip; + overflow: hidden; + /* animation: slideUp 250ms ease-out; */ &[data-expanded] { overflow: visible; } - /* JS-animated content: overflow managed by animate() */ - &[data-spring-content] { - overflow: clip; - } + /* &[data-expanded] { */ + /* animation: slideDown 250ms ease-out; */ + /* } */ } &[data-variant="ghost"] { @@ -115,6 +103,9 @@ border: none; padding: 0; + /* &:hover { */ + /* color: var(--text-strong); */ + /* } */ &:focus-visible { outline: none; background-color: var(--surface-raised-base-hover); @@ -131,3 +122,21 @@ } } } + +@keyframes slideDown { + from { + height: 0; + } + to { + height: var(--kb-collapsible-content-height); + } +} + +@keyframes slideUp { + from { + height: var(--kb-collapsible-content-height); + } + to { + height: 0; + } +} diff --git a/packages/ui/src/components/context-tool-results.tsx b/packages/ui/src/components/context-tool-results.tsx deleted file mode 100644 index a0d9311de..000000000 --- a/packages/ui/src/components/context-tool-results.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { createMemo, createSignal, For, onMount } from "solid-js" -import type { ToolPart } from "@opencode-ai/sdk/v2" -import { getFilename } from "@opencode-ai/util/path" -import { useReducedMotion } from "../hooks/use-reduced-motion" -import { useI18n } from "../context/i18n" -import { ToolCall } from "./basic-tool" -import { ToolStatusTitle } from "./tool-status-title" -import { AnimatedCountList } from "./tool-count-summary" -import { RollingResults } from "./rolling-results" -import { GROW_SPRING } from "./motion" -import { useSpring } from "./motion-spring" -import { busy, updateScrollMask, useCollapsible, useRowWipe } from "./tool-utils" - -function contextToolLabel(part: ToolPart): { action: string; detail: string } { - const state = part.state - const title = "title" in state ? (state.title as string | undefined) : undefined - const input = state.input - if (part.tool === "read") { - const path = input?.filePath as string | undefined - return { action: "Read", detail: title || (path ? getFilename(path) : "") } - } - if (part.tool === "grep") { - const pattern = input?.pattern as string | undefined - return { action: "Search", detail: title || (pattern ? `"${pattern}"` : "") } - } - if (part.tool === "glob") { - const pattern = input?.pattern as string | undefined - return { action: "Find", detail: title || (pattern ?? "") } - } - if (part.tool === "list") { - const path = input?.path as string | undefined - return { action: "List", detail: title || (path ? getFilename(path) : "") } - } - return { action: part.tool, detail: title || "" } -} - -function contextToolSummary(parts: ToolPart[]) { - let read = 0 - let search = 0 - let list = 0 - for (const part of parts) { - if (part.tool === "read") read++ - else if (part.tool === "glob" || part.tool === "grep") search++ - else if (part.tool === "list") list++ - } - return { read, search, list } -} - -export function ContextToolGroupHeader(props: { - parts: ToolPart[] - pending: boolean - open: boolean - onOpenChange: (value: boolean) => void -}) { - const i18n = useI18n() - const summary = createMemo(() => contextToolSummary(props.parts)) - return ( - <ToolCall - variant="row" - icon="magnifying-glass-menu" - open={props.open} - showArrow - onOpenChange={props.onOpenChange} - trigger={ - <div data-component="context-tool-group-trigger" data-pending={props.pending || undefined}> - <span - data-slot="context-tool-group-title" - class="min-w-0 flex items-center gap-2 text-14-medium text-text-strong" - > - <span data-slot="context-tool-group-label" class="shrink-0"> - <ToolStatusTitle - active={props.pending} - activeText={i18n.t("ui.sessionTurn.status.gatheringContext")} - doneText={i18n.t("ui.sessionTurn.status.gatheredContext")} - split={false} - /> - </span> - <span - data-slot="context-tool-group-summary" - class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal text-text-base" - > - <AnimatedCountList - items={[ - { - key: "read", - count: summary().read, - one: i18n.t("ui.messagePart.context.read.one"), - other: i18n.t("ui.messagePart.context.read.other"), - }, - { - key: "search", - count: summary().search, - one: i18n.t("ui.messagePart.context.search.one"), - other: i18n.t("ui.messagePart.context.search.other"), - }, - { - key: "list", - count: summary().list, - one: i18n.t("ui.messagePart.context.list.one"), - other: i18n.t("ui.messagePart.context.list.other"), - }, - ]} - fallback="" - /> - </span> - </span> - </div> - } - /> - ) -} - -export function ContextToolExpandedList(props: { parts: ToolPart[]; expanded: boolean }) { - let contentRef: HTMLDivElement | undefined - let bodyRef: HTMLDivElement | undefined - let scrollRef: HTMLDivElement | undefined - const updateMask = () => { - if (scrollRef) updateScrollMask(scrollRef) - } - - useCollapsible({ - content: () => contentRef, - body: () => bodyRef, - open: () => props.expanded, - onOpen: updateMask, - }) - - return ( - <div ref={contentRef} style={{ overflow: "clip", height: "0px", display: "none" }}> - <div ref={bodyRef}> - <div ref={scrollRef} data-component="context-tool-expanded-list" onScroll={updateMask}> - <For each={props.parts}> - {(part) => { - const label = createMemo(() => contextToolLabel(part)) - return ( - <div data-component="context-tool-expanded-row"> - <span data-slot="context-tool-expanded-action">{label().action}</span> - <span data-slot="context-tool-expanded-detail">{label().detail}</span> - </div> - ) - }} - </For> - </div> - </div> - </div> - ) -} - -export function ContextToolRollingResults(props: { parts: ToolPart[]; pending: boolean }) { - const reduce = useReducedMotion() - const wiped = new Set<string>() - const [mounted, setMounted] = createSignal(false) - onMount(() => setMounted(true)) - const show = () => mounted() && props.pending - const opacity = useSpring(() => (show() ? 1 : 0), GROW_SPRING) - const blur = useSpring(() => (show() ? 0 : 2), GROW_SPRING) - return ( - <div style={{ opacity: reduce() ? (show() ? 1 : 0) : opacity(), filter: `blur(${reduce() ? 0 : blur()}px)` }}> - <RollingResults - items={props.parts} - rows={5} - rowHeight={22} - rowGap={0} - open={props.pending} - animate - getKey={(part) => part.callID || part.id} - render={(part) => { - const label = createMemo(() => contextToolLabel(part)) - const k = part.callID || part.id - return ( - <div data-component="context-tool-rolling-row"> - <span data-slot="context-tool-rolling-action">{label().action}</span> - {(() => { - const [detailRef, setDetailRef] = createSignal<HTMLSpanElement>() - useRowWipe({ - id: () => k, - text: () => label().detail, - ref: detailRef, - seen: wiped, - }) - return ( - <span - ref={setDetailRef} - data-slot="context-tool-rolling-detail" - style={{ display: label().detail ? undefined : "none" }} - > - {label().detail} - </span> - ) - })()} - </div> - ) - }} - /> - </div> - ) -} diff --git a/packages/ui/src/components/grow-box.tsx b/packages/ui/src/components/grow-box.tsx deleted file mode 100644 index c8ea6f3b3..000000000 --- a/packages/ui/src/components/grow-box.tsx +++ /dev/null @@ -1,432 +0,0 @@ -import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js" -import { useReducedMotion } from "../hooks/use-reduced-motion" -import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion" - -export interface GrowBoxProps { - children: JSX.Element - /** Enable animation. When false, content shows immediately at full height. */ - animate?: boolean - /** Animate height from 0 to content height. Default: true. */ - grow?: boolean - /** Keep watching body size and animate subsequent height changes. Default: false. */ - watch?: boolean - /** Fade in body content (opacity + blur). Default: true. */ - fade?: boolean - /** Top padding in px on the body wrapper. Default: 0. */ - gap?: number - /** Reset to height:auto after grow completes, or stay at fixed px. Default: true. */ - autoHeight?: boolean - /** Controlled visibility for animating open/close without unmounting children. */ - open?: boolean - /** Animate controlled open/close changes after mount. Default: true. */ - animateToggle?: boolean - /** data-slot attribute on the root div. */ - slot?: string - /** CSS class on the root div. */ - class?: string - /** Override mount and resize spring config. Default: GROW_SPRING. */ - spring?: SpringConfig - /** Override controlled open/close spring config. Default: spring. */ - toggleSpring?: SpringConfig - /** Show a temporary bottom edge fade while height animation is running. */ - edge?: boolean - /** Edge fade height in px. Default: 20. */ - edgeHeight?: number - /** Edge fade opacity (0-1). Default: 1. */ - edgeOpacity?: number - /** Delay before edge fades out after height settles. Default: 320. */ - edgeIdle?: number - /** Edge fade-out duration in seconds. Default: 0.24. */ - edgeFade?: number - /** Edge fade-in duration in seconds. Default: 0.2. */ - edgeRise?: number -} - -/** - * Wraps children in a container that animates from zero height on mount. - * - * Includes a ResizeObserver so content changes after mount are also spring-animated. - * Used for timeline turns, assistant part groups, and user messages. - */ -export function GrowBox(props: GrowBoxProps) { - const reduce = useReducedMotion() - const spring = () => props.spring ?? GROW_SPRING - const toggleSpring = () => props.toggleSpring ?? spring() - let mode: "mount" | "toggle" = "mount" - let root: HTMLDivElement | undefined - let body: HTMLDivElement | undefined - let fadeAnim: AnimationPlaybackControls | undefined - let edgeRef: HTMLDivElement | undefined - let edgeAnim: AnimationPlaybackControls | undefined - let edgeTimer: ReturnType<typeof setTimeout> | undefined - let edgeOn = false - let mountFrame: number | undefined - let resizeFrame: number | undefined - let observer: ResizeObserver | undefined - let springTarget = -1 - const height = tunableSpringValue<number>(0, { - type: "spring", - get visualDuration() { - return (mode === "toggle" ? toggleSpring() : spring()).visualDuration - }, - get bounce() { - return (mode === "toggle" ? toggleSpring() : spring()).bounce - }, - }) - - const gap = () => Math.max(0, props.gap ?? 0) - const grow = () => props.grow !== false - const watch = () => props.watch === true - const open = () => props.open !== false - const animateToggle = () => props.animateToggle !== false - const edge = () => props.edge === true - const edgeHeight = () => Math.max(0, props.edgeHeight ?? 20) - const edgeOpacity = () => Math.min(1, Math.max(0, props.edgeOpacity ?? 1)) - const edgeIdle = () => Math.max(0, props.edgeIdle ?? 320) - const edgeFade = () => Math.max(0.05, props.edgeFade ?? 0.24) - const edgeRise = () => Math.max(0.05, props.edgeRise ?? 0.2) - const animated = () => props.animate !== false && !reduce() - const edgeReady = () => animated() && open() && edge() && edgeHeight() > 0 - - const stopEdgeTimer = () => { - if (edgeTimer === undefined) return - clearTimeout(edgeTimer) - edgeTimer = undefined - } - - const hideEdge = (instant = false) => { - stopEdgeTimer() - if (!edgeRef) { - edgeOn = false - return - } - edgeAnim?.stop() - edgeAnim = undefined - if (instant || reduce()) { - edgeRef.style.opacity = "0" - edgeOn = false - return - } - if (!edgeOn) { - edgeRef.style.opacity = "0" - return - } - const current = animate(edgeRef, { opacity: 0 }, { type: "spring", visualDuration: edgeFade(), bounce: 0 }) - edgeAnim = current - current.finished - .catch(() => {}) - .finally(() => { - if (edgeAnim !== current) return - edgeAnim = undefined - if (!edgeRef) return - edgeRef.style.opacity = "0" - edgeOn = false - }) - } - - const showEdge = () => { - stopEdgeTimer() - if (!edgeRef) return - if (reduce()) { - edgeRef.style.opacity = `${edgeOpacity()}` - edgeOn = true - return - } - if (edgeOn && edgeAnim === undefined) { - edgeRef.style.opacity = `${edgeOpacity()}` - return - } - edgeAnim?.stop() - edgeAnim = undefined - if (!edgeOn) edgeRef.style.opacity = "0" - const current = animate( - edgeRef, - { opacity: edgeOpacity() }, - { type: "spring", visualDuration: edgeRise(), bounce: 0 }, - ) - edgeAnim = current - edgeOn = true - current.finished - .catch(() => {}) - .finally(() => { - if (edgeAnim !== current) return - edgeAnim = undefined - if (!edgeRef) return - edgeRef.style.opacity = `${edgeOpacity()}` - }) - } - - const queueEdgeHide = () => { - stopEdgeTimer() - if (!edgeOn) return - if (edgeIdle() <= 0) { - hideEdge() - return - } - edgeTimer = setTimeout(() => { - edgeTimer = undefined - hideEdge() - }, edgeIdle()) - } - - const hideBody = () => { - if (!body) return - body.style.opacity = "0" - body.style.filter = "blur(2px)" - } - - const clearBody = () => { - if (!body) return - body.style.opacity = "" - body.style.filter = "" - } - - const fadeBodyIn = (nextMode: "mount" | "toggle" = "mount") => { - if (props.fade === false || !body) return - if (reduce()) { - clearBody() - return - } - hideBody() - fadeAnim?.stop() - fadeAnim = animate(body, { opacity: 1, filter: "blur(0px)" }, nextMode === "toggle" ? toggleSpring() : spring()) - fadeAnim.finished.then(() => { - if (!body || !open()) return - clearBody() - }) - } - - const setInstant = (visible: boolean) => { - const next = visible ? targetHeight() : 0 - springTarget = next - height.jump(next) - root!.style.height = visible ? "" : "0px" - root!.style.overflow = visible ? "" : "clip" - hideEdge(true) - if (visible || props.fade === false) clearBody() - else hideBody() - } - - const currentHeight = () => { - if (!root) return 0 - const v = root.style.height - if (v && v !== "auto") { - const n = Number.parseFloat(v) - if (!Number.isNaN(n)) return n - } - return Math.max(0, root.getBoundingClientRect().height) - } - - const targetHeight = () => Math.max(0, Math.ceil(body?.getBoundingClientRect().height ?? 0)) - - const setHeight = (nextMode: "mount" | "toggle" = "mount") => { - if (!root || !open()) return - const next = targetHeight() - if (reduce()) { - springTarget = next - height.jump(next) - if (props.autoHeight === false || watch()) { - root.style.height = `${next}px` - root.style.overflow = next > 0 ? "visible" : "clip" - return - } - root.style.height = "auto" - root.style.overflow = next > 0 ? "visible" : "clip" - return - } - if (next === springTarget) return - const prev = currentHeight() - if (Math.abs(next - prev) < 1) { - springTarget = next - if (props.autoHeight === false || watch()) { - root.style.height = `${next}px` - root.style.overflow = next > 0 ? "visible" : "clip" - } - return - } - root.style.overflow = "clip" - springTarget = next - mode = nextMode - height.set(next) - } - - onMount(() => { - if (!root || !body) return - - const offChange = height.on("change", (next) => { - if (!root) return - root.style.height = `${Math.max(0, next)}px` - }) - const offStart = height.on("animationStart", () => { - if (!root) return - root.style.overflow = "clip" - root.style.willChange = "height" - root.style.contain = "layout style" - if (edgeReady()) showEdge() - }) - const offComplete = height.on("animationComplete", () => { - if (!root) return - root.style.willChange = "" - root.style.contain = "" - if (!open()) { - springTarget = 0 - root.style.height = "0px" - root.style.overflow = "clip" - return - } - const next = targetHeight() - springTarget = next - if (props.autoHeight === false || watch()) { - root.style.height = `${next}px` - root.style.overflow = next > 0 ? "visible" : "clip" - if (edgeReady()) queueEdgeHide() - return - } - root.style.height = "auto" - root.style.overflow = "visible" - if (edgeReady()) queueEdgeHide() - }) - - onCleanup(() => { - offComplete() - offStart() - offChange() - }) - - if (watch()) { - observer = new ResizeObserver(() => { - if (!open()) return - if (resizeFrame !== undefined) return - resizeFrame = requestAnimationFrame(() => { - resizeFrame = undefined - setHeight("mount") - }) - }) - observer.observe(body) - } - - if (!animated()) { - setInstant(open()) - return - } - - if (props.fade !== false) hideBody() - hideEdge(true) - - if (!open()) { - root.style.height = "0px" - root.style.overflow = "clip" - } else { - if (grow()) { - root.style.height = "0px" - root.style.overflow = "clip" - } else { - root.style.height = "auto" - root.style.overflow = "visible" - } - mountFrame = requestAnimationFrame(() => { - mountFrame = undefined - fadeBodyIn("mount") - if (grow()) setHeight("mount") - }) - } - }) - - createEffect( - on( - () => props.open, - (value) => { - if (value === undefined) return - if (!root || !body) return - if (!animateToggle() || reduce()) { - setInstant(value) - return - } - fadeAnim?.stop() - if (!value) hideEdge(true) - if (!value) { - const next = currentHeight() - if (Math.abs(next - height.get()) >= 1) { - springTarget = next - height.jump(next) - root.style.height = `${next}px` - } - if (props.fade !== false) { - fadeAnim = animate(body, { opacity: 0, filter: "blur(2px)" }, toggleSpring()) - } - root.style.overflow = "clip" - springTarget = 0 - mode = "toggle" - height.set(0) - return - } - fadeBodyIn("toggle") - setHeight("toggle") - }, - { defer: true }, - ), - ) - - createEffect(() => { - if (!edgeRef) return - edgeRef.style.height = `${edgeHeight()}px` - if (!animated() || !open() || edgeHeight() <= 0) { - hideEdge(true) - return - } - if (edge()) return - hideEdge() - }) - - createEffect(() => { - if (!root || !body) return - if (!reduce()) return - fadeAnim?.stop() - edgeAnim?.stop() - setInstant(open()) - }) - - onCleanup(() => { - stopEdgeTimer() - if (mountFrame !== undefined) cancelAnimationFrame(mountFrame) - if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame) - observer?.disconnect() - height.destroy() - fadeAnim?.stop() - edgeAnim?.stop() - edgeAnim = undefined - edgeOn = false - }) - - return ( - <div - ref={root} - data-slot={props.slot} - class={props.class} - style={{ - transform: "translateZ(0)", - position: "relative", - height: open() ? undefined : "0px", - overflow: open() ? undefined : "clip", - }} - > - <div ref={body} style={{ "padding-top": gap() > 0 ? `${gap()}px` : undefined }}> - {props.children} - </div> - <div - ref={edgeRef} - data-slot="grow-box-edge" - style={{ - position: "absolute", - left: "0", - right: "0", - bottom: "0", - height: `${edgeHeight()}px`, - opacity: 0, - "pointer-events": "none", - background: "linear-gradient(to bottom, transparent 0%, var(--background-stronger) 100%)", - }} - /> - </div> - ) -} diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 9a6784d70..8fc709013 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -1,20 +1,10 @@ [data-component="assistant-message"] { content-visibility: auto; width: 100%; -} - -[data-component="assistant-parts"] { - width: 100%; - min-width: 0; display: flex; flex-direction: column; align-items: flex-start; - gap: 0; -} - -[data-component="assistant-part-item"] { - width: 100%; - min-width: 0; + gap: 12px; } [data-component="user-message"] { @@ -37,14 +27,6 @@ color: var(--text-weak); } - [data-slot="user-message-inner"] { - position: relative; - display: flex; - flex-direction: column; - align-items: flex-end; - width: 100%; - gap: 4px; - } [data-slot="user-message-attachments"] { display: flex; flex-wrap: wrap; @@ -53,7 +35,6 @@ width: fit-content; max-width: min(82%, 64ch); margin-left: auto; - margin-bottom: 4px; } [data-slot="user-message-attachment"] { @@ -153,7 +134,7 @@ [data-slot="user-message-copy-wrapper"] { min-height: 24px; - margin-top: 0; + margin-top: 4px; display: flex; align-items: center; justify-content: flex-end; @@ -163,6 +144,7 @@ pointer-events: none; transition: opacity 0.15s ease; will-change: opacity; + [data-component="tooltip-trigger"] { display: inline-flex; width: fit-content; @@ -205,21 +187,56 @@ opacity: 1; pointer-events: auto; } + + .text-text-strong { + color: var(--text-strong); + } + + .font-medium { + font-weight: var(--font-weight-medium); + } } [data-component="text-part"] { width: 100%; - margin-top: 0; - padding-block: 4px; - position: relative; + margin-top: 24px; [data-slot="text-part-body"] { margin-top: 0; } - [data-slot="text-part-turn-summary"] { + [data-slot="text-part-copy-wrapper"] { + min-height: 24px; + margin-top: 4px; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 10px; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; + will-change: opacity; + + [data-component="tooltip-trigger"] { + display: inline-flex; + width: fit-content; + } + } + + [data-slot="text-part-meta"] { + user-select: none; + } + + [data-slot="text-part-copy-wrapper"][data-interrupted] { width: 100%; - min-width: 0; + justify-content: flex-end; + gap: 12px; + } + + &:hover [data-slot="text-part-copy-wrapper"], + &:focus-within [data-slot="text-part-copy-wrapper"] { + opacity: 1; + pointer-events: auto; } [data-component="markdown"] { @@ -228,10 +245,6 @@ } } -[data-component="assistant-part-item"][data-kind="text"][data-last="true"] [data-component="text-part"] { - padding-bottom: 0; -} - [data-component="compaction-part"] { width: 100%; display: flex; @@ -265,6 +278,7 @@ line-height: var(--line-height-normal); [data-component="markdown"] { + margin-top: 24px; font-style: normal; font-size: inherit; color: var(--text-weak); @@ -358,16 +372,13 @@ height: auto; max-height: 240px; overflow-y: auto; - overscroll-behavior: contain; scrollbar-width: none; -ms-overflow-style: none; - -webkit-mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%); - mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%); - -webkit-mask-repeat: no-repeat; - mask-repeat: no-repeat; + &::-webkit-scrollbar { display: none; } + [data-component="markdown"] { overflow: visible; } @@ -437,7 +448,7 @@ [data-component="write-trigger"] { display: flex; align-items: center; - justify-content: flex-start; + justify-content: space-between; gap: 8px; width: 100%; @@ -450,8 +461,7 @@ } [data-slot="message-part-title"] { - flex-shrink: 1; - min-width: 0; + flex-shrink: 0; display: flex; align-items: center; gap: 8px; @@ -483,45 +493,40 @@ [data-slot="message-part-title-text"] { text-transform: capitalize; color: var(--text-strong); - flex-shrink: 0; } - [data-slot="message-part-meta-line"], - .message-part-meta-line { - min-width: 0; - display: inline-flex; - align-items: center; - gap: 6px; + [data-slot="message-part-title-filename"] { + /* No text-transform - preserve original filename casing */ font-weight: var(--font-weight-regular); - - [data-component="diff-changes"] { - flex-shrink: 0; - gap: 6px; - } } - .message-part-meta-line.soft { - [data-slot="message-part-title-filename"] { - color: var(--text-base); - } - } - - [data-slot="message-part-title-filename"] { - /* No text-transform - preserve original filename casing */ - color: var(--text-strong); - flex-shrink: 0; + [data-slot="message-part-path"] { + display: flex; + flex-grow: 1; + min-width: 0; + font-weight: var(--font-weight-regular); } - [data-slot="message-part-directory-inline"] { + [data-slot="message-part-directory"] { color: var(--text-weak); - min-width: 0; - max-width: min(48vw, 36ch); text-overflow: ellipsis; overflow: hidden; white-space: nowrap; direction: rtl; text-align: left; } + + [data-slot="message-part-filename"] { + color: var(--text-strong); + flex-shrink: 0; + } + + [data-slot="message-part-actions"] { + display: flex; + gap: 16px; + align-items: center; + justify-content: flex-end; + } } [data-component="edit-content"] { @@ -612,17 +617,6 @@ } } -[data-slot="webfetch-meta"] { - min-width: 0; - display: inline-flex; - align-items: center; - gap: 8px; - - [data-component="tool-action"] { - flex-shrink: 0; - } -} - [data-component="todos"] { padding: 10px 0 24px 0; display: flex; @@ -645,6 +639,7 @@ } [data-component="context-tool-group-trigger"] { + width: 100%; min-height: 24px; display: flex; align-items: center; @@ -652,352 +647,28 @@ gap: 0px; cursor: pointer; - &[data-pending] { - cursor: default; - } - [data-slot="context-tool-group-title"] { flex-shrink: 1; min-width: 0; } -} - -/* Prevent the trigger content from stretching full-width so the arrow sits after the text */ -[data-slot="basic-tool-tool-trigger-content"]:has([data-component="context-tool-group-trigger"]) { - width: auto; - flex: 0 1 auto; - [data-slot="basic-tool-tool-info"] { - flex: 0 1 auto; + [data-slot="collapsible-arrow"] { + color: var(--icon-weaker); } } -[data-component="context-tool-step"] { - width: 100%; - min-width: 0; - padding-left: 12px; -} - -[data-component="context-tool-expanded-list"] { +[data-component="context-tool-group-list"] { + padding: 6px 0 4px 0; display: flex; flex-direction: column; - padding: 4px 0 4px 12px; - max-height: 200px; - overflow-y: auto; - overscroll-behavior: contain; - scrollbar-width: none; - -ms-overflow-style: none; - -webkit-mask-repeat: no-repeat; - mask-repeat: no-repeat; + gap: 2px; - &::-webkit-scrollbar { - display: none; - } -} - -[data-component="context-tool-expanded-row"] { - display: flex; - align-items: center; - gap: 6px; - min-width: 0; - height: 22px; - flex-shrink: 0; - white-space: nowrap; - overflow: hidden; - - [data-slot="context-tool-expanded-action"] { - flex-shrink: 0; - font-size: var(--font-size-base); - font-weight: 500; - color: var(--text-base); - } - - [data-slot="context-tool-expanded-detail"] { - flex-shrink: 1; + [data-slot="context-tool-group-item"] { min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - font-size: var(--font-size-base); - color: var(--text-base); - opacity: 0.75; + padding: 6px 0; } } -[data-component="context-tool-rolling-row"] { - display: inline-flex; - align-items: center; - gap: 6px; - width: 100%; - min-width: 0; - white-space: nowrap; - overflow: hidden; - padding-left: 12px; - - [data-slot="context-tool-rolling-action"] { - flex-shrink: 0; - font-size: var(--font-size-base); - font-weight: 500; - color: var(--text-base); - } - - [data-slot="context-tool-rolling-detail"] { - flex-shrink: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - font-size: var(--font-size-base); - color: var(--text-weak); - } -} - -[data-component="shell-rolling-results"] { - width: 100%; - min-width: 0; - display: flex; - flex-direction: column; - - [data-slot="shell-rolling-header-clip"] { - &:hover [data-slot="shell-rolling-actions"] { - opacity: 1; - } - - &[data-clickable="true"] { - cursor: pointer; - } - } - - [data-slot="shell-rolling-header"] { - display: inline-flex; - align-items: center; - gap: 8px; - min-width: 0; - max-width: 100%; - height: 37px; - box-sizing: border-box; - } - - [data-slot="shell-rolling-title"] { - flex-shrink: 0; - font-family: var(--font-family-sans); - font-size: 14px; - font-style: normal; - font-weight: var(--font-weight-medium); - line-height: var(--line-height-large); - letter-spacing: var(--letter-spacing-normal); - color: var(--text-strong); - } - - [data-slot="shell-rolling-subtitle"] { - flex: 0 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: var(--font-family-sans); - font-size: 14px; - font-weight: var(--font-weight-normal); - line-height: var(--line-height-large); - color: var(--text-weak); - } - - [data-slot="shell-rolling-actions"] { - flex-shrink: 0; - display: inline-flex; - align-items: center; - gap: 2px; - opacity: 0; - transition: opacity 0.15s ease; - } - - .shell-rolling-copy { - border: none !important; - outline: none !important; - box-shadow: none !important; - background: transparent !important; - - [data-slot="icon-svg"] { - color: var(--icon-weaker); - } - - &:hover:not(:disabled) { - background: color-mix(in srgb, var(--text-base) 8%, transparent) !important; - box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important; - border-radius: var(--radius-sm); - - [data-slot="icon-svg"] { - color: var(--icon-base); - } - } - } - - [data-slot="shell-rolling-arrow"] { - display: inline-flex; - align-items: center; - justify-content: center; - color: var(--icon-weaker); - transform: rotate(-90deg); - transition: transform 0.15s ease; - } - - [data-slot="shell-rolling-arrow"][data-open="true"] { - transform: rotate(0deg); - } -} - -[data-component="shell-rolling-output"] { - width: 100%; - min-width: 0; -} - -[data-slot="shell-rolling-preview"] { - width: 100%; - min-width: 0; -} - -[data-component="shell-expanded-output"] { - width: 100%; - max-width: 100%; - overflow-y: auto; - overflow-x: hidden; - scrollbar-width: none; - -ms-overflow-style: none; - - &::-webkit-scrollbar { - display: none; - } -} - -[data-component="shell-expanded-shell"] { - position: relative; - width: 100%; - min-width: 0; - border: 1px solid var(--border-weak-base); - border-radius: 6px; - background: transparent; - overflow: hidden; -} - -[data-slot="shell-expanded-body"] { - position: relative; - width: 100%; - min-width: 0; -} - -[data-slot="shell-expanded-top"] { - position: relative; - width: 100%; - min-width: 0; - padding: 9px 44px 9px 16px; - box-sizing: border-box; -} - -[data-slot="shell-expanded-command"] { - display: flex; - align-items: flex-start; - gap: 8px; - width: 100%; - min-width: 0; - font-family: var(--font-family-mono); - font-feature-settings: var(--font-family-mono--font-feature-settings); - font-size: 13px; - line-height: 1.45; -} - -[data-slot="shell-expanded-prompt"] { - flex-shrink: 0; - color: var(--text-weaker); -} - -[data-slot="shell-expanded-input"] { - min-width: 0; - color: var(--text-strong); - white-space: pre-wrap; - overflow-wrap: anywhere; -} - -[data-slot="shell-expanded-actions"] { - position: absolute; - top: 50%; - right: 8px; - z-index: 1; - transform: translateY(-50%); -} - -.shell-expanded-copy { - border: none !important; - outline: none !important; - box-shadow: none !important; - background: transparent !important; - - [data-slot="icon-svg"] { - color: var(--icon-weaker); - } - - &:hover:not(:disabled) { - background: color-mix(in srgb, var(--text-base) 8%, transparent) !important; - box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important; - border-radius: var(--radius-sm); - - [data-slot="icon-svg"] { - color: var(--icon-base); - } - } -} - -[data-slot="shell-expanded-divider"] { - width: 100%; - height: 1px; - background: var(--border-weak-base); -} - -[data-slot="shell-expanded-pre"] { - margin: 0; - padding: 12px 16px; - white-space: pre-wrap; - overflow-wrap: anywhere; - - code { - font-family: var(--font-family-mono); - font-feature-settings: var(--font-family-mono--font-feature-settings); - font-size: 13px; - line-height: 1.45; - color: var(--text-base); - } -} - -[data-component="shell-rolling-command"], -[data-component="shell-rolling-row"] { - display: inline-flex; - align-items: center; - width: 100%; - min-width: 0; - overflow: hidden; - white-space: pre; - padding-left: 12px; -} - -[data-slot="shell-rolling-text"] { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - font-family: var(--font-family-mono); - font-feature-settings: var(--font-family-mono--font-feature-settings); - font-size: var(--font-size-small); - line-height: var(--line-height-large); -} - -[data-component="shell-rolling-command"] [data-slot="shell-rolling-text"] { - color: var(--text-base); -} - -[data-component="shell-rolling-command"] [data-slot="shell-rolling-prompt"] { - color: var(--text-weaker); -} - -[data-component="shell-rolling-row"] [data-slot="shell-rolling-text"] { - color: var(--text-weak); -} - [data-component="diagnostics"] { display: flex; flex-direction: column; @@ -1058,30 +729,6 @@ width: 100%; } -[data-slot="assistant-part-grow"] { - width: 100%; - min-width: 0; - overflow: visible; -} - -[data-component="tool-part-wrapper"][data-tool="bash"] { - [data-component="tool-trigger"] { - width: auto; - max-width: 100%; - } - - [data-slot="basic-tool-tool-info-main"] { - align-items: center; - } - - [data-slot="basic-tool-tool-title"], - [data-slot="basic-tool-tool-subtitle"] { - display: inline-flex; - align-items: center; - line-height: var(--line-height-large); - } -} - [data-component="dock-prompt"][data-kind="permission"] { position: relative; display: flex; @@ -1540,7 +1187,8 @@ position: sticky; top: var(--sticky-accordion-top, 0px); z-index: 20; - height: 37px; + height: 40px; + padding-bottom: 8px; background-color: var(--background-stronger); } } @@ -1551,12 +1199,11 @@ } [data-slot="apply-patch-trigger-content"] { - display: inline-flex; + display: flex; align-items: center; - justify-content: flex-start; - max-width: 100%; - min-width: 0; - gap: 8px; + justify-content: space-between; + width: 100%; + gap: 20px; } [data-slot="apply-patch-file-info"] { @@ -1590,9 +1237,9 @@ [data-slot="apply-patch-trigger-actions"] { flex-shrink: 0; display: flex; - gap: 8px; + gap: 16px; align-items: center; - justify-content: flex-start; + justify-content: flex-end; } [data-slot="apply-patch-change"] { @@ -1632,11 +1279,10 @@ } [data-component="tool-loaded-file"] { - min-width: 0; display: flex; align-items: center; gap: 8px; - padding: 4px 0 4px 12px; + padding: 4px 0 4px 28px; font-family: var(--font-family-sans); font-size: var(--font-size-small); font-weight: var(--font-weight-regular); @@ -1647,11 +1293,4 @@ flex-shrink: 0; color: var(--icon-weak); } - - span { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } } diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index d82121159..45b174e2b 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1,6 +1,18 @@ -import { Component, createEffect, createMemo, createSignal, For, Match, on, Show, Switch, type JSX } from "solid-js" +import { + Component, + createEffect, + createMemo, + createSignal, + For, + Match, + onMount, + Show, + Switch, + onCleanup, + Index, + type JSX, +} from "solid-js" import stripAnsi from "strip-ansi" -import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" import { AgentPart, @@ -20,10 +32,11 @@ import { useData } from "../context" import { useFileComponent } from "../context/file" import { useDialog } from "../context/dialog" import { type UiI18n, useI18n } from "../context/i18n" -import { GenericTool, ToolCall } from "./basic-tool" +import { BasicTool, GenericTool } from "./basic-tool" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { Card } from "./card" +import { Collapsible } from "./collapsible" import { FileIcon } from "./file-icon" import { Icon } from "./icon" import { Checkbox } from "./checkbox" @@ -35,12 +48,43 @@ import { checksum } from "@opencode-ai/util/encode" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" -import { list } from "./text-utils" -import { GrowBox } from "./grow-box" -import { COLLAPSIBLE_SPRING } from "./motion" -import { busy, createThrottledValue, useToolFade, useContextToolPending } from "./tool-utils" -import { ContextToolGroupHeader, ContextToolExpandedList, ContextToolRollingResults } from "./context-tool-results" -import { ShellRollingResults } from "./shell-rolling-results" +import { AnimatedCountList } from "./tool-count-summary" +import { ToolStatusTitle } from "./tool-status-title" +import { animate } from "motion" +import { useLocation } from "@solidjs/router" + +function ShellSubmessage(props: { text: string; animate?: boolean }) { + let widthRef: HTMLSpanElement | undefined + let valueRef: HTMLSpanElement | undefined + + onMount(() => { + if (!props.animate) return + requestAnimationFrame(() => { + if (widthRef) { + animate(widthRef, { width: "auto" }, { type: "spring", visualDuration: 0.25, bounce: 0 }) + } + if (valueRef) { + animate(valueRef, { opacity: 1, filter: "blur(0px)" }, { duration: 0.32, ease: [0.16, 1, 0.3, 1] }) + } + }) + }) + + return ( + <span data-component="shell-submessage"> + <span ref={widthRef} data-slot="shell-submessage-width" style={{ width: props.animate ? "0px" : undefined }}> + <span data-slot="basic-tool-tool-subtitle"> + <span + ref={valueRef} + data-slot="shell-submessage-value" + style={props.animate ? { opacity: 0, filter: "blur(2px)" } : undefined} + > + {props.text} + </span> + </span> + </span> + </span> + ) +} interface Diagnostic { range: { @@ -81,22 +125,64 @@ function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element { ) } +export interface MessageProps { + message: MessageType + parts: PartType[] + showAssistantCopyPartID?: string | null + interrupted?: boolean + queued?: boolean + showReasoningSummaries?: boolean +} + export interface MessagePartProps { part: PartType message: MessageType hideDetails?: boolean defaultOpen?: boolean showAssistantCopyPartID?: string | null - showTurnDiffSummary?: boolean - turnDiffSummary?: () => JSX.Element - animate?: boolean - working?: boolean + turnDurationMs?: number } export type PartComponent = Component<MessagePartProps> export const PART_MAPPING: Record<string, PartComponent | undefined> = {} +const TEXT_RENDER_THROTTLE_MS = 100 + +function createThrottledValue(getValue: () => string) { + const [value, setValue] = createSignal(getValue()) + let timeout: ReturnType<typeof setTimeout> | undefined + let last = 0 + + createEffect(() => { + const next = getValue() + const now = Date.now() + + const remaining = TEXT_RENDER_THROTTLE_MS - (now - last) + if (remaining <= 0) { + if (timeout) { + clearTimeout(timeout) + timeout = undefined + } + last = now + setValue(next) + return + } + if (timeout) clearTimeout(timeout) + timeout = setTimeout(() => { + last = Date.now() + setValue(next) + timeout = undefined + }, remaining) + }) + + onCleanup(() => { + if (timeout) clearTimeout(timeout) + }) + + return value +} + function relativizeProjectPath(path: string, directory?: string) { if (!path) return "" if (!directory) return path @@ -228,8 +314,7 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { case "skill": return { icon: "brain", - title: i18n.t("ui.tool.skill"), - subtitle: typeof input.name === "string" ? input.name : undefined, + title: input.name || "skill", } default: return { @@ -254,22 +339,105 @@ function urls(text: string | undefined) { const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"]) const HIDDEN_TOOLS = new Set(["todowrite", "todoread"]) -function createGroupOpenState() { - const [state, setState] = createStore<Record<string, boolean>>({}) - const read = (key?: string, collapse?: boolean) => { - if (!key) return true - const value = state[key] - if (value !== undefined) return value - return !collapse - } - const controlled = (key?: string) => { - if (!key) return false - return state[key] !== undefined +function list<T>(value: T[] | undefined | null, fallback: T[]) { + if (Array.isArray(value)) return value + return fallback +} + +function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) { + if (a === b) return true + if (!a || !b) return false + if (a.length !== b.length) return false + return a.every((x, i) => x === b[i]) +} + +type PartRef = { + messageID: string + partID: string +} + +type PartGroup = + | { + key: string + type: "part" + ref: PartRef + } + | { + key: string + type: "context" + refs: PartRef[] + } + +function sameRef(a: PartRef, b: PartRef) { + return a.messageID === b.messageID && a.partID === b.partID +} + +function sameGroup(a: PartGroup, b: PartGroup) { + if (a === b) return true + if (a.key !== b.key) return false + if (a.type !== b.type) return false + if (a.type === "part") { + if (b.type !== "part") return false + return sameRef(a.ref, b.ref) } - const write = (key: string, value: boolean) => { - setState(key, value) + if (b.type !== "context") return false + if (a.refs.length !== b.refs.length) return false + return a.refs.every((ref, i) => sameRef(ref, b.refs[i]!)) +} + +function sameGroups(a: readonly PartGroup[] | undefined, b: readonly PartGroup[] | undefined) { + if (a === b) return true + if (!a || !b) return false + if (a.length !== b.length) return false + return a.every((item, i) => sameGroup(item, b[i]!)) +} + +function groupParts(parts: { messageID: string; part: PartType }[]) { + const result: PartGroup[] = [] + let start = -1 + + const flush = (end: number) => { + if (start < 0) return + const first = parts[start] + const last = parts[end] + if (!first || !last) { + start = -1 + return + } + result.push({ + key: `context:${first.part.id}`, + type: "context", + refs: parts.slice(start, end + 1).map((item) => ({ + messageID: item.messageID, + partID: item.part.id, + })), + }) + start = -1 } - return { read, controlled, write } + + parts.forEach((item, index) => { + if (isContextGroupTool(item.part)) { + if (start < 0) start = index + return + } + + flush(index - 1) + result.push({ + key: `part:${item.messageID}:${item.part.id}`, + type: "part", + ref: { + messageID: item.messageID, + partID: item.part.id, + }, + }) + }) + + flush(parts.length - 1) + return result +} + +function partByID(parts: readonly PartType[], partID: string) { + return parts.find((part) => part.id === partID) } function renderable(part: PartType, showReasoningSummaries = true) { @@ -285,8 +453,7 @@ function renderable(part: PartType, showReasoningSummaries = true) { function toolDefaultOpen(tool: string, shell = false, edit = false) { if (tool === "bash") return shell - if (tool === "edit" || tool === "write") return edit - if (tool === "apply_patch") return false + if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit } function partDefaultOpen(part: PartType, shell = false, edit = false) { @@ -294,323 +461,98 @@ function partDefaultOpen(part: PartType, shell = false, edit = false) { return toolDefaultOpen(part.tool, shell, edit) } -function PartGrow(props: { - children: JSX.Element - animate?: boolean - animateToggle?: boolean - gap?: number - fade?: boolean - edge?: boolean - edgeHeight?: number - edgeOpacity?: number - edgeIdle?: number - edgeFade?: number - edgeRise?: number - grow?: boolean - watch?: boolean - open?: boolean - spring?: import("./motion").SpringConfig - toggleSpring?: import("./motion").SpringConfig -}) { - return ( - <GrowBox - animate={props.animate !== false} - animateToggle={props.animateToggle} - fade={props.fade} - edge={props.edge} - edgeHeight={props.edgeHeight} - edgeOpacity={props.edgeOpacity} - edgeIdle={props.edgeIdle} - edgeFade={props.edgeFade} - edgeRise={props.edgeRise} - gap={props.gap} - grow={props.grow} - watch={props.watch} - open={props.open} - spring={props.spring} - toggleSpring={props.toggleSpring} - slot="assistant-part-grow" - > - {props.children} - </GrowBox> - ) -} - export function AssistantParts(props: { messages: AssistantMessage[] showAssistantCopyPartID?: string | null - showTurnDiffSummary?: boolean - turnDiffSummary?: () => JSX.Element + turnDurationMs?: number working?: boolean showReasoningSummaries?: boolean shellToolDefaultOpen?: boolean editToolDefaultOpen?: boolean - animate?: boolean }) { const data = useData() const emptyParts: PartType[] = [] - const groupState = createGroupOpenState() - const grouped = createMemo(() => { - const keys: string[] = [] - const items: Record< - string, - | { - type: "part" - part: PartType - message: AssistantMessage - context?: boolean - groupKey?: string - afterTool?: boolean - groupTail?: boolean - groupParts?: { part: ToolPart; message: AssistantMessage }[] - } - | { - type: "context" - groupKey: string - parts: { part: ToolPart; message: AssistantMessage }[] - tail: boolean - afterTool: boolean - } - > = {} - const push = (key: string, item: (typeof items)[string]) => { - keys.push(key) - items[key] = item - } - const id = (part: PartType) => { - if (part.type === "tool") return part.callID || part.id - return part.id - } - const parts = props.messages.flatMap((message) => - list(data.store.part?.[message.id], emptyParts) - .filter((part) => renderable(part, props.showReasoningSummaries ?? true)) - .map((part) => ({ message, part })), - ) - - let start = -1 - - const flush = (end: number, tail: boolean, afterTool: boolean) => { - if (start < 0) return - const group = parts - .slice(start, end + 1) - .filter((entry): entry is { part: ToolPart; message: AssistantMessage } => isContextGroupTool(entry.part)) - if (!group.length) { - start = -1 - return - } - const groupKey = `context:${group[0].message.id}:${id(group[0].part)}` - push(groupKey, { - type: "context", - groupKey, - parts: group, - tail, - afterTool, - }) - group.forEach((entry) => { - push(`part:${entry.message.id}:${id(entry.part)}`, { - type: "part", - part: entry.part, - message: entry.message, - context: true, - groupKey, - afterTool, - groupTail: tail, - groupParts: group, - }) - }) - start = -1 - } - parts.forEach((item, index) => { - if (isContextGroupTool(item.part)) { - if (start < 0) start = index - return - } + const emptyTools: ToolPart[] = [] + + const grouped = createMemo( + () => + groupParts( + props.messages.flatMap((message) => + list(data.store.part?.[message.id], emptyParts) + .filter((part) => renderable(part, props.showReasoningSummaries ?? true)) + .map((part) => ({ + messageID: message.id, + part, + })), + ), + ), + [] as PartGroup[], + { equals: sameGroups }, + ) - flush(index - 1, false, (item as { part: PartType }).part.type === "tool") - push(`part:${item.message.id}:${id(item.part)}`, { type: "part", part: item.part, message: item.message }) - }) + const last = createMemo(() => grouped().at(-1)?.key) - flush(parts.length - 1, true, false) - return { keys, items } - }) + return ( + <Index each={grouped()}> + {(entryAccessor) => { + const entryType = createMemo(() => entryAccessor().type) + + return ( + <Switch> + <Match when={entryType() === "context"}> + {(() => { + const parts = createMemo( + () => { + const entry = entryAccessor() + if (entry.type !== "context") return emptyTools + return entry.refs + .map((ref) => partByID(list(data.store.part?.[ref.messageID], emptyParts), ref.partID)) + .filter((part): part is ToolPart => !!part && isContextGroupTool(part)) + }, + emptyTools, + { equals: same }, + ) + const busy = createMemo(() => props.working && last() === entryAccessor().key) - const last = createMemo(() => grouped().keys.at(-1)) + return ( + <Show when={parts().length > 0}> + <ContextToolGroup parts={parts()} busy={busy()} /> + </Show> + ) + })()} + </Match> + <Match when={entryType() === "part"}> + {(() => { + const message = createMemo(() => { + const entry = entryAccessor() + if (entry.type !== "part") return + return props.messages.find((item) => item.id === entry.ref.messageID) + }) + const part = createMemo(() => { + const entry = entryAccessor() + if (entry.type !== "part") return + return partByID(list(data.store.part?.[entry.ref.messageID], emptyParts), entry.ref.partID) + }) - return ( - <div data-component="assistant-parts"> - <For each={grouped().keys}> - {(key) => { - const item = createMemo(() => grouped().items[key]) - const ctx = createMemo(() => { - const value = item() - if (!value) return - if (value.type !== "context") return - return value - }) - const part = createMemo(() => { - const value = item() - if (!value) return - if (value.type !== "part") return - return value - }) - const tail = createMemo(() => last() === key) - const tool = createMemo(() => { - const value = part() - if (!value) return false - return value.part.type === "tool" - }) - const context = createMemo(() => !!part()?.context) - const contextSpring = createMemo(() => { - const entry = part() - if (!entry?.context) return undefined - if (!groupState.controlled(entry.groupKey)) return undefined - return COLLAPSIBLE_SPRING - }) - const contextOpen = createMemo(() => { - const value = ctx() - if (value) return groupState.read(value.groupKey, true) - return groupState.read(part()?.groupKey, true) - }) - const visible = createMemo(() => { - if (!context()) return true - if (ctx()) return true - return false - }) - - const turnSummary = createMemo(() => { - const value = part() - if (!value) return false - if (value.part.type !== "text") return false - if (!props.showTurnDiffSummary) return false - return props.showAssistantCopyPartID === value.part.id - }) - const fade = createMemo(() => { - if (ctx()) return true - return tool() - }) - const edge = createMemo(() => { - const entry = part() - if (!entry) return false - if (entry.part.type !== "text") return false - if (!props.working) return false - return tail() - }) - const watch = createMemo(() => !context() && !tool() && tail() && !turnSummary()) - const ctxPartsCache = new Map<string, ToolPart>() - let ctxPartsPrev: ToolPart[] = [] - const ctxParts = createMemo(() => { - const parts = ctx()?.parts ?? [] - if (parts.length === 0 && ctxPartsPrev.length > 0) return ctxPartsPrev - const result: ToolPart[] = [] - for (const item of parts) { - const k = item.part.callID || item.part.id - const cached = ctxPartsCache.get(k) - if (cached) { - result.push(cached) - } else { - ctxPartsCache.set(k, item.part) - result.push(item.part) - } - } - ctxPartsPrev = result - return result - }) - const ctxPending = useContextToolPending(ctxParts, () => !!(props.working && ctx()?.tail)) - const shell = createMemo(() => { - const value = part() - if (!value) return - if (value.part.type !== "tool") return - if (value.part.tool !== "bash") return - return value.part - }) - const kind = createMemo(() => { - if (ctx()) return "context" - if (shell()) return "shell" - const value = part() - if (!value) return "part" - return value.part.type - }) - const shown = createMemo(() => { - if (ctx()) return true - if (shell()) return true - const entry = part() - if (!entry) return false - return !entry.context - }) - const partGrowProps = () => ({ - animate: props.animate, - gap: 0, - fade: fade(), - edge: edge(), - edgeHeight: 20, - edgeOpacity: 0.95, - edgeIdle: 100, - edgeFade: 0.6, - edgeRise: 0.1, - grow: true, - watch: watch(), - animateToggle: true, - open: visible(), - toggleSpring: contextSpring(), - }) - return ( - <Show when={shown()}> - <div data-component="assistant-part-item" data-kind={kind()} data-last={tail() ? "true" : "false"}> - <Show when={ctx()}> - {(entry) => ( - <> - <PartGrow {...partGrowProps()}> - <ContextToolGroupHeader - parts={ctxParts()} - pending={ctxPending()} - open={contextOpen()} - onOpenChange={(value: boolean) => groupState.write(entry().groupKey, value)} - /> - </PartGrow> - <ContextToolExpandedList parts={ctxParts()} expanded={contextOpen() && !ctxPending()} /> - <ContextToolRollingResults parts={ctxParts()} pending={contextOpen() && ctxPending()} /> - </> - )} - </Show> - <Show when={shell()}> - {(value) => ( - <ShellRollingResults - part={value()} - animate={props.animate} - defaultOpen={props.shellToolDefaultOpen} - /> - )} - </Show> - <Show when={!shell() ? part() : undefined}> - {(entry) => ( - <Show when={!entry().context}> - <PartGrow {...partGrowProps()}> - <div> - <Part - part={entry().part} - message={entry().message} - showAssistantCopyPartID={props.showAssistantCopyPartID} - showTurnDiffSummary={props.showTurnDiffSummary} - turnDiffSummary={props.turnDiffSummary} - defaultOpen={partDefaultOpen( - entry().part, - props.shellToolDefaultOpen, - props.editToolDefaultOpen, - )} - hideDetails={false} - animate={props.animate} - working={props.working} - /> - </div> - </PartGrow> + return ( + <Show when={message()}> + <Show when={part()}> + <Part + part={part()!} + message={message()!} + showAssistantCopyPartID={props.showAssistantCopyPartID} + turnDurationMs={props.turnDurationMs} + defaultOpen={partDefaultOpen(part()!, props.shellToolDefaultOpen, props.editToolDefaultOpen)} + /> </Show> - )} - </Show> - </div> - </Show> - ) - }} - </For> - </div> + </Show> + ) + })()} + </Match> + </Switch> + ) + }} + </Index> ) } @@ -618,6 +560,76 @@ function isContextGroupTool(part: PartType): part is ToolPart { return part.type === "tool" && CONTEXT_GROUP_TOOLS.has(part.tool) } +function contextToolDetail(part: ToolPart): string | undefined { + const info = getToolInfo(part.tool, part.state.input ?? {}) + if (info.subtitle) return info.subtitle + if (part.state.status === "error") return part.state.error + if ((part.state.status === "running" || part.state.status === "completed") && part.state.title) + return part.state.title + const description = part.state.input?.description + if (typeof description === "string") return description + return undefined +} + +function contextToolTrigger(part: ToolPart, i18n: ReturnType<typeof useI18n>) { + const input = (part.state.input ?? {}) as Record<string, unknown> + const path = typeof input.path === "string" ? input.path : "/" + const filePath = typeof input.filePath === "string" ? input.filePath : undefined + const pattern = typeof input.pattern === "string" ? input.pattern : undefined + const include = typeof input.include === "string" ? input.include : undefined + const offset = typeof input.offset === "number" ? input.offset : undefined + const limit = typeof input.limit === "number" ? input.limit : undefined + + switch (part.tool) { + case "read": { + const args: string[] = [] + if (offset !== undefined) args.push("offset=" + offset) + if (limit !== undefined) args.push("limit=" + limit) + return { + title: i18n.t("ui.tool.read"), + subtitle: filePath ? getFilename(filePath) : "", + args, + } + } + case "list": + return { + title: i18n.t("ui.tool.list"), + subtitle: getDirectory(path), + } + case "glob": + return { + title: i18n.t("ui.tool.glob"), + subtitle: getDirectory(path), + args: pattern ? ["pattern=" + pattern] : [], + } + case "grep": { + const args: string[] = [] + if (pattern) args.push("pattern=" + pattern) + if (include) args.push("include=" + include) + return { + title: i18n.t("ui.tool.grep"), + subtitle: getDirectory(path), + args, + } + } + default: { + const info = getToolInfo(part.tool, input) + return { + title: info.title, + subtitle: info.subtitle || contextToolDetail(part), + args: [], + } + } + } +} + +function contextToolSummary(parts: ToolPart[]) { + const read = parts.filter((part) => part.tool === "read").length + const search = parts.filter((part) => part.tool === "glob" || part.tool === "grep").length + const list = parts.filter((part) => part.tool === "list").length + return { read, search, list } +} + function ExaOutput(props: { output?: string }) { const links = createMemo(() => urls(props.output)) @@ -648,11 +660,210 @@ export function registerPartComponent(type: string, component: PartComponent) { PART_MAPPING[type] = component } +export function Message(props: MessageProps) { + return ( + <Switch> + <Match when={props.message.role === "user" && props.message}> + {(userMessage) => ( + <UserMessageDisplay + message={userMessage() as UserMessage} + parts={props.parts} + interrupted={props.interrupted} + queued={props.queued} + /> + )} + </Match> + <Match when={props.message.role === "assistant" && props.message}> + {(assistantMessage) => ( + <AssistantMessageDisplay + message={assistantMessage() as AssistantMessage} + parts={props.parts} + showAssistantCopyPartID={props.showAssistantCopyPartID} + showReasoningSummaries={props.showReasoningSummaries} + /> + )} + </Match> + </Switch> + ) +} + +export function AssistantMessageDisplay(props: { + message: AssistantMessage + parts: PartType[] + showAssistantCopyPartID?: string | null + showReasoningSummaries?: boolean +}) { + const emptyTools: ToolPart[] = [] + const grouped = createMemo( + () => + groupParts( + props.parts + .filter((part) => renderable(part, props.showReasoningSummaries ?? true)) + .map((part) => ({ + messageID: props.message.id, + part, + })), + ), + [] as PartGroup[], + { equals: sameGroups }, + ) + + return ( + <Index each={grouped()}> + {(entryAccessor) => { + const entryType = createMemo(() => entryAccessor().type) + + return ( + <Switch> + <Match when={entryType() === "context"}> + {(() => { + const parts = createMemo( + () => { + const entry = entryAccessor() + if (entry.type !== "context") return emptyTools + return entry.refs + .map((ref) => partByID(props.parts, ref.partID)) + .filter((part): part is ToolPart => !!part && isContextGroupTool(part)) + }, + emptyTools, + { equals: same }, + ) + + return ( + <Show when={parts().length > 0}> + <ContextToolGroup parts={parts()} /> + </Show> + ) + })()} + </Match> + <Match when={entryType() === "part"}> + {(() => { + const part = createMemo(() => { + const entry = entryAccessor() + if (entry.type !== "part") return + return partByID(props.parts, entry.ref.partID) + }) + + return ( + <Show when={part()}> + <Part + part={part()!} + message={props.message} + showAssistantCopyPartID={props.showAssistantCopyPartID} + /> + </Show> + ) + })()} + </Match> + </Switch> + ) + }} + </Index> + ) +} + +function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) { + const i18n = useI18n() + const [open, setOpen] = createSignal(false) + const pending = createMemo( + () => + !!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"), + ) + const summary = createMemo(() => contextToolSummary(props.parts)) + + return ( + <Collapsible open={open()} onOpenChange={setOpen} variant="ghost"> + <Collapsible.Trigger> + <div data-component="context-tool-group-trigger"> + <span + data-slot="context-tool-group-title" + class="min-w-0 flex items-center gap-2 text-14-medium text-text-strong" + > + <span data-slot="context-tool-group-label" class="shrink-0"> + <ToolStatusTitle + active={pending()} + activeText={i18n.t("ui.sessionTurn.status.gatheringContext")} + doneText={i18n.t("ui.sessionTurn.status.gatheredContext")} + split={false} + /> + </span> + <span + data-slot="context-tool-group-summary" + class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal text-text-base" + > + <AnimatedCountList + items={[ + { + key: "read", + count: summary().read, + one: i18n.t("ui.messagePart.context.read.one"), + other: i18n.t("ui.messagePart.context.read.other"), + }, + { + key: "search", + count: summary().search, + one: i18n.t("ui.messagePart.context.search.one"), + other: i18n.t("ui.messagePart.context.search.other"), + }, + { + key: "list", + count: summary().list, + one: i18n.t("ui.messagePart.context.list.one"), + other: i18n.t("ui.messagePart.context.list.other"), + }, + ]} + fallback="" + /> + </span> + </span> + <Collapsible.Arrow /> + </div> + </Collapsible.Trigger> + <Collapsible.Content> + <div data-component="context-tool-group-list"> + <Index each={props.parts}> + {(partAccessor) => { + const trigger = createMemo(() => contextToolTrigger(partAccessor(), i18n)) + const running = createMemo( + () => partAccessor().state.status === "pending" || partAccessor().state.status === "running", + ) + 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={!running() && trigger().subtitle}> + <span data-slot="basic-tool-tool-subtitle">{trigger().subtitle}</span> + </Show> + <Show when={!running() && trigger().args?.length}> + <For each={trigger().args}> + {(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>} + </For> + </Show> + </div> + </div> + </div> + </div> + </div> + </div> + ) + }} + </Index> + </div> + </Collapsible.Content> + </Collapsible> + ) +} + export function UserMessageDisplay(props: { message: UserMessage parts: PartType[] interrupted?: boolean - animate?: boolean queued?: boolean }) { const data = useData() @@ -702,9 +913,14 @@ export function UserMessageDisplay(props: { return `${hour12}:${minute} ${hours < 12 ? "AM" : "PM"}` }) - const userMeta = createMemo(() => { + const metaHead = createMemo(() => { const agent = props.message.agent - const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model(), stamp()] + const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model()] + return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0") + }) + + const metaTail = createMemo(() => { + const items = [stamp(), props.interrupted ? i18n.t("ui.message.interrupted") : ""] return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0") }) @@ -721,83 +937,93 @@ export function UserMessageDisplay(props: { } return ( - <GrowBox animate={!!props.animate} fade class="w-full min-w-0 self-stretch max-w-full"> - <div data-component="user-message" data-interrupted={props.interrupted ? "" : undefined}> - <div data-slot="user-message-inner"> - <Show when={attachments().length > 0}> - <div data-slot="user-message-attachments"> - <For each={attachments()}> - {(file) => ( - <div - data-slot="user-message-attachment" - data-type={file.mime.startsWith("image/") ? "image" : "file"} - data-queued={props.queued ? "" : undefined} - onClick={() => { - if (file.mime.startsWith("image/") && file.url) { - openImagePreview(file.url, file.filename) - } - }} - > - <Show - when={file.mime.startsWith("image/") && file.url} - fallback={ - <div data-slot="user-message-attachment-icon"> - <Icon name="folder" /> - </div> - } - > - <img - data-slot="user-message-attachment-image" - src={file.url} - alt={file.filename ?? i18n.t("ui.message.attachment.alt")} - /> - </Show> - </div> - )} - </For> + <div data-component="user-message" data-interrupted={props.interrupted ? "" : undefined}> + <Show when={attachments().length > 0}> + <div data-slot="user-message-attachments"> + <For each={attachments()}> + {(file) => ( + <div + data-slot="user-message-attachment" + data-type={file.mime.startsWith("image/") ? "image" : "file"} + data-queued={props.queued ? "" : undefined} + onClick={() => { + if (file.mime.startsWith("image/") && file.url) { + openImagePreview(file.url, file.filename) + } + }} + > + <Show + when={file.mime.startsWith("image/") && file.url} + fallback={ + <div data-slot="user-message-attachment-icon"> + <Icon name="folder" /> + </div> + } + > + <img + data-slot="user-message-attachment-image" + src={file.url} + alt={file.filename ?? i18n.t("ui.message.attachment.alt")} + /> + </Show> + </div> + )} + </For> + </div> + </Show> + <Show when={text()}> + <> + <div data-slot="user-message-body"> + <div data-slot="user-message-text" data-queued={props.queued ? "" : undefined}> + <HighlightedText text={text()} references={inlineFiles()} agents={agents()} /> </div> - </Show> - <Show when={text()}> - <> - <div data-slot="user-message-body"> - <div data-slot="user-message-text" data-queued={props.queued ? "" : undefined}> - <HighlightedText text={text()} references={inlineFiles()} agents={agents()} /> - </div> - <GrowBox animate={!!props.animate} open={!!props.queued}> - <div data-slot="user-message-queued-indicator"> - <TextShimmer text={i18n.t("ui.message.queued")} /> - </div> - </GrowBox> + <Show when={props.queued}> + <div data-slot="user-message-queued-indicator"> + <TextShimmer text={i18n.t("ui.message.queued")} /> </div> - <div data-slot="user-message-copy-wrapper" data-interrupted={props.interrupted ? "" : undefined}> - <Show when={userMeta()}> + </Show> + </div> + <div data-slot="user-message-copy-wrapper" data-interrupted={props.interrupted ? "" : undefined}> + <Show when={metaHead() || metaTail()}> + <span data-slot="user-message-meta-wrap"> + <Show when={metaHead()}> <span data-slot="user-message-meta" class="text-12-regular text-text-weak cursor-default"> - {userMeta()} + {metaHead()} </span> </Show> - <Tooltip - value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} - placement="top" - gutter={4} - > - <IconButton - icon={copied() ? "check" : "copy"} - size="normal" - variant="ghost" - onMouseDown={(e) => e.preventDefault()} - onClick={(event) => { - event.stopPropagation() - handleCopy() - }} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} - /> - </Tooltip> - </div> - </> - </Show> - </div> - </div> - </GrowBox> + <Show when={metaHead() && metaTail()}> + <span data-slot="user-message-meta-sep" class="text-12-regular text-text-weak cursor-default"> + {"\u00A0\u00B7\u00A0"} + </span> + </Show> + <Show when={metaTail()}> + <span data-slot="user-message-meta-tail" class="text-12-regular text-text-weak cursor-default"> + {metaTail()} + </span> + </Show> + </span> + </Show> + <Tooltip + value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} + placement="top" + gutter={4} + > + <IconButton + icon={copied() ? "check" : "copy"} + size="normal" + variant="ghost" + onMouseDown={(e) => e.preventDefault()} + onClick={(event) => { + event.stopPropagation() + handleCopy() + }} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} + /> + </Tooltip> + </div> + </> + </Show> + </div> ) } @@ -851,10 +1077,7 @@ export function Part(props: MessagePartProps) { hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} showAssistantCopyPartID={props.showAssistantCopyPartID} - showTurnDiffSummary={props.showTurnDiffSummary} - turnDiffSummary={props.turnDiffSummary} - animate={props.animate} - working={props.working} + turnDurationMs={props.turnDurationMs} /> </Show> ) @@ -864,16 +1087,12 @@ export interface ToolProps { input: Record<string, any> metadata: Record<string, any> tool: string - partID?: string - callID?: string output?: string status?: string hideDetails?: boolean defaultOpen?: boolean forceOpen?: boolean locked?: boolean - animate?: boolean - reveal?: boolean } export type ToolComponent = Component<ToolProps> @@ -907,7 +1126,7 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre <Accordion multiple data-scope="apply-patch" - style={{ "--sticky-accordion-offset": "37px" }} + style={{ "--sticky-accordion-offset": "40px" }} defaultValue={[value()]} > <Accordion.Item value={value()}> @@ -938,26 +1157,30 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre PART_MAPPING["tool"] = function ToolPartDisplay(props) { const i18n = useI18n() - const part = props.part as ToolPart - const hideQuestion = createMemo(() => part.tool === "question" && busy(part.state.status)) + const part = () => props.part as ToolPart + if (part().tool === "todowrite" || part().tool === "todoread") return null + + const hideQuestion = createMemo( + () => part().tool === "question" && (part().state.status === "pending" || part().state.status === "running"), + ) const emptyInput: Record<string, any> = {} const emptyMetadata: Record<string, any> = {} - const input = () => part.state?.input ?? emptyInput + const input = () => part().state?.input ?? emptyInput // @ts-expect-error - const partMetadata = () => part.state?.metadata ?? emptyMetadata + const partMetadata = () => part().state?.metadata ?? emptyMetadata - const render = createMemo(() => ToolRegistry.render(part.tool) ?? GenericTool) + const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool) return ( <Show when={!hideQuestion()}> - <div data-component="tool-part-wrapper" data-tool={part.tool}> + <div data-component="tool-part-wrapper"> <Switch> - <Match when={part.state.status === "error" && part.state.error}> + <Match when={part().state.status === "error" && (part().state as any).error}> {(error) => { const cleaned = error().replace("Error: ", "") - if (part.tool === "question" && cleaned.includes("dismissed this question")) { + if (part().tool === "question" && cleaned.includes("dismissed this question")) { return ( <div style="width: 100%; display: flex; justify-content: flex-end;"> <span class="text-13-regular text-text-weak cursor-default"> @@ -991,17 +1214,13 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { <Dynamic component={render()} input={input()} - tool={part.tool} - partID={part.id} - callID={part.callID} + tool={part().tool} metadata={partMetadata()} // @ts-expect-error - output={part.state.output} - status={part.state.status} + output={part().state.output} + status={part().state.status} hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} - animate - reveal={props.animate} /> </Match> </Switch> @@ -1026,16 +1245,74 @@ PART_MAPPING["compaction"] = function CompactionPartDisplay() { } PART_MAPPING["text"] = function TextPartDisplay(props) { + const data = useData() + const i18n = useI18n() const part = () => props.part as TextPart + const interrupted = createMemo( + () => + props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError", + ) + + const model = createMemo(() => { + if (props.message.role !== "assistant") return "" + const message = props.message as AssistantMessage + const match = data.store.provider?.all?.find((p) => p.id === message.providerID) + return match?.models?.[message.modelID]?.name ?? message.modelID + }) + + const duration = createMemo(() => { + if (props.message.role !== "assistant") return "" + const message = props.message as AssistantMessage + const completed = message.time.completed + const ms = + typeof props.turnDurationMs === "number" + ? props.turnDurationMs + : typeof completed === "number" + ? completed - message.time.created + : -1 + if (!(ms >= 0)) return "" + const total = Math.round(ms / 1000) + if (total < 60) return `${total}s` + const minutes = Math.floor(total / 60) + const seconds = total % 60 + return `${minutes}m ${seconds}s` + }) + + const meta = createMemo(() => { + if (props.message.role !== "assistant") return "" + const agent = (props.message as AssistantMessage).agent + const items = [ + agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", + model(), + duration(), + interrupted() ? i18n.t("ui.message.interrupted") : "", + ] + return items.filter((x) => !!x).join(" \u00B7 ") + }) const displayText = () => (part().text ?? "").trim() const throttledText = createThrottledValue(displayText) - const summary = createMemo(() => { - if (props.message.role !== "assistant") return - if (!props.showTurnDiffSummary) return - if (props.showAssistantCopyPartID !== part().id) return - return props.turnDiffSummary + const isLastTextPart = createMemo(() => { + const last = (data.store.part?.[props.message.id] ?? []) + .filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim()) + .at(-1) + return last?.id === part().id }) + const showCopy = createMemo(() => { + if (props.message.role !== "assistant") return isLastTextPart() + if (props.showAssistantCopyPartID === null) return false + if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part().id + return isLastTextPart() + }) + const [copied, setCopied] = createSignal(false) + + const handleCopy = async () => { + const content = displayText() + if (!content) return + await navigator.clipboard.writeText(content) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } return ( <Show when={throttledText()}> @@ -1043,12 +1320,28 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { <div data-slot="text-part-body"> <Markdown text={throttledText()} cacheKey={part().id} /> </div> - <Show when={summary()}> - {(render) => ( - <GrowBox animate={!!props.animate} fade gap={4} class="w-full min-w-0"> - <div data-slot="text-part-turn-summary">{render()()}</div> - </GrowBox> - )} + <Show when={showCopy()}> + <div data-slot="text-part-copy-wrapper" data-interrupted={interrupted() ? "" : undefined}> + <Tooltip + value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} + placement="top" + gutter={4} + > + <IconButton + icon={copied() ? "check" : "copy"} + size="normal" + variant="ghost" + onMouseDown={(e) => e.preventDefault()} + onClick={handleCopy} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} + /> + </Tooltip> + <Show when={meta()}> + <span data-slot="text-part-meta" class="text-12-regular text-text-weak cursor-default"> + {meta()} + </span> + </Show> + </div> </Show> </div> </Show> @@ -1078,33 +1371,30 @@ ToolRegistry.register({ if (props.input.offset) args.push("offset=" + props.input.offset) if (props.input.limit) args.push("limit=" + props.input.limit) const loaded = createMemo(() => { + if (props.status !== "completed") return [] const value = props.metadata.loaded if (!value || !Array.isArray(value)) return [] return value.filter((p): p is string => typeof p === "string") }) - const pending = createMemo(() => busy(props.status)) return ( <> - <ToolCall - variant="row" + <BasicTool {...props} icon="glasses" - trigger={ - <ToolTriggerRow - title={i18n.t("ui.tool.read")} - pending={pending()} - subtitle={props.input.filePath ? getFilename(props.input.filePath) : ""} - args={args} - animate={props.reveal} - /> - } + trigger={{ + title: i18n.t("ui.tool.read"), + subtitle: props.input.filePath ? getFilename(props.input.filePath) : "", + args, + }} /> <For each={loaded()}> {(filepath) => ( - <ToolLoadedFile - text={`${i18n.t("ui.tool.loaded")} ${relativizeProjectPath(filepath, data.directory)}`} - animate={props.reveal} - /> + <div data-component="tool-loaded-file"> + <Icon name="enter" size="small" /> + <span> + {i18n.t("ui.tool.loaded")} {relativizeProjectPath(filepath, data.directory)} + </span> + </div> )} </For> </> @@ -1116,29 +1406,18 @@ ToolRegistry.register({ name: "list", render(props) { const i18n = useI18n() - const pending = createMemo(() => busy(props.status)) return ( - <ToolCall - variant="panel" + <BasicTool {...props} icon="bullet-list" - trigger={ - <ToolTriggerRow - title={i18n.t("ui.tool.list")} - pending={pending()} - subtitle={getDirectory(props.input.path)} - animate={props.reveal} - /> - } + trigger={{ title: i18n.t("ui.tool.list"), subtitle: getDirectory(props.input.path || "/") }} > <Show when={props.output}> - {(output) => ( - <div data-component="tool-output" data-scrollable> - <Markdown text={output()} /> - </div> - )} + <div data-component="tool-output" data-scrollable> + <Markdown text={props.output!} /> + </div> </Show> - </ToolCall> + </BasicTool> ) }, }) @@ -1147,30 +1426,22 @@ ToolRegistry.register({ name: "glob", render(props) { const i18n = useI18n() - const pending = createMemo(() => busy(props.status)) return ( - <ToolCall - variant="panel" + <BasicTool {...props} icon="magnifying-glass-menu" - trigger={ - <ToolTriggerRow - title={i18n.t("ui.tool.glob")} - pending={pending()} - subtitle={getDirectory(props.input.path)} - args={props.input.pattern ? ["pattern=" + props.input.pattern] : []} - animate={props.reveal} - /> - } + trigger={{ + title: i18n.t("ui.tool.glob"), + subtitle: getDirectory(props.input.path || "/"), + args: props.input.pattern ? ["pattern=" + props.input.pattern] : [], + }} > <Show when={props.output}> - {(output) => ( - <div data-component="tool-output" data-scrollable> - <Markdown text={output()} /> - </div> - )} + <div data-component="tool-output" data-scrollable> + <Markdown text={props.output!} /> + </div> </Show> - </ToolCall> + </BasicTool> ) }, }) @@ -1182,214 +1453,40 @@ ToolRegistry.register({ const args: string[] = [] if (props.input.pattern) args.push("pattern=" + props.input.pattern) if (props.input.include) args.push("include=" + props.input.include) - const pending = createMemo(() => busy(props.status)) return ( - <ToolCall - variant="panel" + <BasicTool {...props} icon="magnifying-glass-menu" - trigger={ - <ToolTriggerRow - title={i18n.t("ui.tool.grep")} - pending={pending()} - subtitle={getDirectory(props.input.path)} - args={args} - animate={props.reveal} - /> - } + trigger={{ + title: i18n.t("ui.tool.grep"), + subtitle: getDirectory(props.input.path || "/"), + args, + }} > <Show when={props.output}> - {(output) => ( - <div data-component="tool-output" data-scrollable> - <Markdown text={output()} /> - </div> - )} + <div data-component="tool-output" data-scrollable> + <Markdown text={props.output!} /> + </div> </Show> - </ToolCall> + </BasicTool> ) }, }) -function useToolReveal(pending: () => boolean, animate?: () => boolean) { - const enabled = () => animate?.() ?? true - const [live, setLive] = createSignal(pending() || enabled()) - createEffect(() => { - if (pending()) setLive(true) - }) - return () => enabled() && live() -} - -function WebfetchMeta(props: { url: string; animate?: boolean }) { - let ref: HTMLSpanElement | undefined - useToolFade(() => ref, { wipe: true, animate: props.animate }) - - return ( - <span ref={ref} data-slot="webfetch-meta"> - <a - data-slot="basic-tool-tool-subtitle" - class="clickable subagent-link" - href={props.url} - target="_blank" - rel="noopener noreferrer" - onClick={(event) => event.stopPropagation()} - > - {props.url} - </a> - <div data-component="tool-action"> - <Icon name="square-arrow-top-right" size="small" /> - </div> - </span> - ) -} - -function TaskLink(props: { href: string; text: string; onClick: (e: MouseEvent) => void; animate?: boolean }) { - let ref: HTMLAnchorElement | undefined - useToolFade(() => ref, { wipe: true, animate: props.animate }) - - return ( - <a - ref={ref} - data-slot="basic-tool-tool-subtitle" - class="clickable subagent-link" - href={props.href} - onClick={props.onClick} - > - {props.text} - </a> - ) -} - -function ToolText(props: { text: string; delay?: number; animate?: boolean }) { - let ref: HTMLSpanElement | undefined - useToolFade(() => ref, { delay: props.delay, wipe: true, animate: props.animate }) - - return ( - <span ref={ref} data-slot="basic-tool-tool-subtitle"> - {props.text} - </span> - ) -} - -function ToolLoadedFile(props: { text: string; animate?: boolean }) { - let ref: HTMLDivElement | undefined - useToolFade(() => ref, { delay: 0.02, wipe: true, animate: props.animate }) - - return ( - <GrowBox animate={props.animate !== false} fade={false} class="w-full min-w-0"> - <div ref={ref} data-component="tool-loaded-file"> - <Icon name="enter" size="small" /> - <span>{props.text}</span> - </div> - </GrowBox> - ) -} - -function ToolTriggerRow(props: { - title: string - pending: boolean - subtitle?: string - args?: string[] - action?: JSX.Element - animate?: boolean - revealOnMount?: boolean -}) { - const reveal = useToolReveal( - () => props.pending, - () => props.animate !== false, - ) - const detail = createMemo(() => [props.subtitle, ...(props.args ?? [])].filter((x): x is string => !!x).join(" ")) - const detailAnimate = createMemo(() => { - if (props.animate === false) return false - if (props.revealOnMount) return true - if (!props.pending && !reveal()) return true - return reveal() - }) - - return ( - <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={props.title} active={props.pending} /> - </span> - <Show when={detail()}>{(text) => <ToolText text={text()} animate={detailAnimate()} />}</Show> - </div> - <Show when={props.action}>{props.action}</Show> - </div> - ) -} - -type DiffValue = { additions: number; deletions: number } | { additions: number; deletions: number }[] - -function ToolMetaLine(props: { - filename: string - path?: string - changes?: DiffValue - delay?: number - animate?: boolean - soft?: boolean -}) { - let ref: HTMLSpanElement | undefined - useToolFade(() => ref, { delay: props.delay ?? 0.02, wipe: true, animate: props.animate }) - - return ( - <span - ref={ref} - data-slot={props.soft ? "basic-tool-tool-subtitle" : "message-part-meta-line"} - classList={{ - "message-part-meta-line": !!props.soft, - soft: !!props.soft, - }} - > - <span data-slot="message-part-title-filename">{props.filename}</span> - <Show when={props.path}> - <span data-slot="message-part-directory-inline">{props.path}</span> - </Show> - <Show when={props.changes}>{(changes) => <DiffChanges changes={changes()} />}</Show> - </span> - ) -} - -function ToolChanges(props: { changes: DiffValue; animate?: boolean }) { - let ref: HTMLDivElement | undefined - useToolFade(() => ref, { delay: 0.04, animate: props.animate }) - - return ( - <div ref={ref}> - <DiffChanges changes={props.changes} /> - </div> - ) -} - -function ShellText(props: { text: string; animate?: boolean }) { - let ref: HTMLSpanElement | undefined - useToolFade(() => ref, { wipe: true, animate: props.animate }) - - return ( - <span data-component="shell-submessage"> - <span data-slot="basic-tool-tool-subtitle"> - <span ref={ref} data-slot="shell-submessage-value"> - {props.text} - </span> - </span> - </span> - ) -} - ToolRegistry.register({ name: "webfetch", render(props) { const i18n = useI18n() - const pending = createMemo(() => busy(props.status)) - const reveal = useToolReveal(pending, () => props.reveal !== false) + const pending = createMemo(() => props.status === "pending" || props.status === "running") const url = createMemo(() => { const value = props.input.url if (typeof value !== "string") return "" return value }) return ( - <ToolCall - variant="row" + <BasicTool {...props} + hideDetails icon="window-cursor" trigger={ <div data-slot="basic-tool-tool-info-structured"> @@ -1397,8 +1494,24 @@ ToolRegistry.register({ <span data-slot="basic-tool-tool-title"> <TextShimmer text={i18n.t("ui.tool.webfetch")} active={pending()} /> </span> - <Show when={url()}>{(value) => <WebfetchMeta url={value()} animate={reveal()} />}</Show> + <Show when={!pending() && url()}> + <a + data-slot="basic-tool-tool-subtitle" + class="clickable subagent-link" + href={url()} + target="_blank" + rel="noopener noreferrer" + onClick={(event) => event.stopPropagation()} + > + {url()} + </a> + </Show> </div> + <Show when={!pending() && url()}> + <div data-component="tool-action"> + <Icon name="square-arrow-top-right" size="small" /> + </div> + </Show> </div> } /> @@ -1417,8 +1530,7 @@ ToolRegistry.register({ }) return ( - <ToolCall - variant="panel" + <BasicTool {...props} icon="window-cursor" trigger={{ @@ -1428,7 +1540,7 @@ ToolRegistry.register({ }} > <ExaOutput output={props.output} /> - </ToolCall> + </BasicTool> ) }, }) @@ -1444,8 +1556,7 @@ ToolRegistry.register({ }) return ( - <ToolCall - variant="panel" + <BasicTool {...props} icon="code" trigger={{ @@ -1455,7 +1566,7 @@ ToolRegistry.register({ }} > <ExaOutput output={props.output} /> - </ToolCall> + </BasicTool> ) }, }) @@ -1465,6 +1576,7 @@ ToolRegistry.register({ render(props) { const data = useData() const i18n = useI18n() + const location = useLocation() const childSessionId = () => props.metadata.sessionId as string | undefined const type = createMemo(() => { const raw = props.input.subagent_type @@ -1477,8 +1589,7 @@ ToolRegistry.register({ if (typeof value === "string") return value return undefined }) - const running = createMemo(() => busy(props.status)) - const reveal = useToolReveal(running, () => props.reveal !== false) + const running = createMemo(() => props.status === "pending" || props.status === "running") const href = createMemo(() => { const sessionId = childSessionId() @@ -1487,49 +1598,34 @@ ToolRegistry.register({ const direct = data.sessionHref?.(sessionId) if (direct) return direct - if (typeof window === "undefined") return - const path = window.location.pathname + const path = location.pathname const idx = path.indexOf("/session") if (idx === -1) return return `${path.slice(0, idx)}/session/${sessionId}` }) - const handleLinkClick = (e: MouseEvent) => { - const sessionId = childSessionId() - const url = href() - if (!sessionId || !url) return - - e.stopPropagation() - - if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return - - const nav = data.navigateToSession - if (!nav || typeof window === "undefined") return - - e.preventDefault() - const before = window.location.pathname + window.location.search + window.location.hash - nav(sessionId) - setTimeout(() => { - const after = window.location.pathname + window.location.search + window.location.hash - if (after === before) window.location.assign(url) - }, 50) - } + const titleContent = () => <TextShimmer text={title()} active={running()} /> const trigger = () => ( <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={title()} active={running()} /> + <span data-slot="basic-tool-tool-title" class="capitalize agent-title"> + {titleContent()} </span> <Show when={description()}> <Switch> <Match when={href()}> - {(url) => ( - <TaskLink href={url()} text={description() ?? ""} onClick={handleLinkClick} animate={reveal()} /> - )} + <a + data-slot="basic-tool-tool-subtitle" + class="clickable subagent-link" + href={href()!} + onClick={(e) => e.stopPropagation()} + > + {description()} + </a> </Match> <Match when={true}> - <ToolText text={description() ?? ""} delay={0.02} animate={reveal()} /> + <span data-slot="basic-tool-tool-subtitle">{description()}</span> </Match> </Switch> </Show> @@ -1537,7 +1633,7 @@ ToolRegistry.register({ </div> ) - return <ToolCall variant="row" icon="task" status={props.status} trigger={trigger()} animate /> + return <BasicTool icon="task" status={props.status} trigger={trigger()} hideDetails /> }, }) @@ -1545,26 +1641,13 @@ ToolRegistry.register({ name: "bash", render(props) { const i18n = useI18n() - const pending = () => busy(props.status) - const reveal = useToolReveal(pending, () => props.reveal !== false) - const subtitle = () => props.input.description ?? props.metadata.description - const cmd = createMemo(() => { - const value = props.input.command ?? props.metadata.command - if (typeof value === "string") return value - return "" - }) - const output = createMemo(() => { - if (typeof props.output === "string") return props.output - if (typeof props.metadata.output === "string") return props.metadata.output - return "" - }) - const command = createMemo(() => `$ ${cmd()}`) - const result = createMemo(() => stripAnsi(output())) + const pending = () => props.status === "pending" || props.status === "running" + const sawPending = pending() const text = createMemo(() => { - const value = result() - return `${command()}${value ? "\n\n" + value : ""}` + const cmd = props.input.command ?? props.metadata.command ?? "" + const out = stripAnsi(props.output || props.metadata.output || "") + return `$ ${cmd}${out ? "\n\n" + out : ""}` }) - const hasOutput = createMemo(() => result().length > 0) const [copied, setCopied] = createSignal(false) const handleCopy = async () => { @@ -1576,20 +1659,18 @@ ToolRegistry.register({ } return ( - <ToolCall - variant="panel" + <BasicTool {...props} icon="console" - animate - springContent - defaultOpen={false} trigger={ <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={i18n.t("ui.tool.shell")} active={pending()} /> </span> - <Show when={subtitle()}>{(text) => <ShellText text={text()} animate={reveal()} />}</Show> + <Show when={!pending() && props.input.description}> + <ShellSubmessage text={props.input.description} animate={sawPending} /> + </Show> </div> </div> } @@ -1617,7 +1698,7 @@ ToolRegistry.register({ </pre> </div> </div> - </ToolCall> + </BasicTool> ) }, }) @@ -1630,12 +1711,10 @@ ToolRegistry.register({ const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "") const filename = () => getFilename(props.input.filePath ?? "") - const pending = () => busy(props.status) - const reveal = useToolReveal(pending, () => props.reveal !== false) + const pending = () => props.status === "pending" || props.status === "running" return ( <div data-component="edit-tool"> - <ToolCall - variant="panel" + <BasicTool {...props} icon="code-lines" defer @@ -1646,17 +1725,20 @@ ToolRegistry.register({ <span data-slot="message-part-title-text"> <TextShimmer text={i18n.t("ui.messagePart.title.edit")} active={pending()} /> </span> - <Show when={filename()}> - {(name) => ( - <ToolMetaLine - filename={name()} - path={props.input.filePath?.includes("/") ? getDirectory(props.input.filePath!) : undefined} - changes={props.metadata.filediff} - animate={reveal()} - /> - )} + <Show when={!pending()}> + <span data-slot="message-part-title-filename">{filename()}</span> </Show> </div> + <Show when={!pending() && props.input.filePath?.includes("/")}> + <div data-slot="message-part-path"> + <span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span> + </div> + </Show> + </div> + <div data-slot="message-part-actions"> + <Show when={!pending() && props.metadata.filediff}> + <DiffChanges changes={props.metadata.filediff} /> + </Show> </div> </div> } @@ -1666,7 +1748,7 @@ ToolRegistry.register({ path={path()} actions={ <Show when={!pending() && props.metadata.filediff}> - {(diff) => <ToolChanges changes={diff()} animate={reveal()} />} + <DiffChanges changes={props.metadata.filediff!} /> </Show> } > @@ -1687,7 +1769,7 @@ ToolRegistry.register({ </ToolFileAccordion> </Show> <DiagnosticsDisplay diagnostics={diagnostics()} /> - </ToolCall> + </BasicTool> </div> ) }, @@ -1701,12 +1783,10 @@ ToolRegistry.register({ const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const path = createMemo(() => props.input.filePath || "") const filename = () => getFilename(props.input.filePath ?? "") - const pending = () => busy(props.status) - const reveal = useToolReveal(pending, () => props.reveal !== false) + const pending = () => props.status === "pending" || props.status === "running" return ( <div data-component="write-tool"> - <ToolCall - variant="panel" + <BasicTool {...props} icon="code-lines" defer @@ -1717,17 +1797,17 @@ ToolRegistry.register({ <span data-slot="message-part-title-text"> <TextShimmer text={i18n.t("ui.messagePart.title.write")} active={pending()} /> </span> - <Show when={filename()}> - {(name) => ( - <ToolMetaLine - filename={name()} - path={props.input.filePath?.includes("/") ? getDirectory(props.input.filePath!) : undefined} - animate={reveal()} - /> - )} + <Show when={!pending()}> + <span data-slot="message-part-title-filename">{filename()}</span> </Show> </div> + <Show when={!pending() && props.input.filePath?.includes("/")}> + <div data-slot="message-part-path"> + <span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span> + </div> + </Show> </div> + <div data-slot="message-part-actions">{/* <DiffChanges diff={diff} /> */}</div> </div> } > @@ -1748,7 +1828,7 @@ ToolRegistry.register({ </ToolFileAccordion> </Show> <DiagnosticsDisplay diagnostics={diagnostics()} /> - </ToolCall> + </BasicTool> </div> ) }, @@ -1772,8 +1852,7 @@ ToolRegistry.register({ const i18n = useI18n() const fileComponent = useFileComponent() const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[]) - const pending = createMemo(() => busy(props.status)) - const reveal = useToolReveal(pending, () => props.reveal !== false) + const pending = createMemo(() => props.status === "pending" || props.status === "running") const single = createMemo(() => { const list = files() if (list.length !== 1) return @@ -1781,6 +1860,7 @@ ToolRegistry.register({ }) const [expanded, setExpanded] = createSignal<string[]>([]) let seeded = false + createEffect(() => { const list = files() if (list.length === 0) return @@ -1788,6 +1868,7 @@ ToolRegistry.register({ seeded = true setExpanded(list.filter((f) => f.type !== "delete").map((f) => f.filePath)) }) + const subtitle = createMemo(() => { const count = files().length if (count === 0) return "" @@ -1795,44 +1876,24 @@ ToolRegistry.register({ }) return ( - <div data-component="apply-patch-tool"> - <ToolCall - variant="panel" - {...props} - icon="code-lines" - defer - trigger={ - <div data-component={single() ? "edit-trigger" : "write-trigger"}> - <div data-slot="message-part-title-area"> - <div data-slot="message-part-title"> - <span data-slot="message-part-title-text"> - <TextShimmer text={i18n.t("ui.tool.patch")} active={pending()} /> - </span> - <Show when={single()}> - {(file) => ( - <ToolMetaLine - filename={getFilename(file().relativePath)} - path={file().relativePath.includes("/") ? getDirectory(file().relativePath) : undefined} - changes={{ additions: file().additions, deletions: file().deletions }} - animate={reveal()} - soft - /> - )} - </Show> - <Show when={!single() && subtitle()}>{(text) => <ToolText text={text()} animate={reveal()} />}</Show> - </div> - </div> - </div> - } - > - <Show - when={single()} - fallback={ + <Show + when={single()} + fallback={ + <div data-component="apply-patch-tool"> + <BasicTool + {...props} + icon="code-lines" + defer + trigger={{ + title: i18n.t("ui.tool.patch"), + subtitle: subtitle(), + }} + > <Show when={files().length > 0}> <Accordion multiple data-scope="apply-patch" - style={{ "--sticky-accordion-offset": "37px" }} + style={{ "--sticky-accordion-offset": "40px" }} value={expanded()} onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])} > @@ -1840,11 +1901,13 @@ ToolRegistry.register({ {(file) => { const active = createMemo(() => expanded().includes(file.filePath)) const [visible, setVisible] = createSignal(false) + createEffect(() => { if (!active()) { setVisible(false) return } + requestAnimationFrame(() => { if (!active()) return setVisible(true) @@ -1909,50 +1972,77 @@ ToolRegistry.register({ </For> </Accordion> </Show> + </BasicTool> + </div> + } + > + <div data-component="apply-patch-tool"> + <BasicTool + {...props} + icon="code-lines" + defer + trigger={ + <div data-component="edit-trigger"> + <div data-slot="message-part-title-area"> + <div data-slot="message-part-title"> + <span data-slot="message-part-title-text"> + <TextShimmer text={i18n.t("ui.tool.patch")} active={pending()} /> + </span> + <Show when={!pending()}> + <span data-slot="message-part-title-filename">{getFilename(single()!.relativePath)}</span> + </Show> + </div> + <Show when={!pending() && single()!.relativePath.includes("/")}> + <div data-slot="message-part-path"> + <span data-slot="message-part-directory">{getDirectory(single()!.relativePath)}</span> + </div> + </Show> + </div> + <div data-slot="message-part-actions"> + <Show when={!pending()}> + <DiffChanges changes={{ additions: single()!.additions, deletions: single()!.deletions }} /> + </Show> + </div> + </div> } > - {(file) => ( - <ToolFileAccordion - path={file().relativePath} - actions={ - <Switch> - <Match when={file().type === "add"}> - <span data-slot="apply-patch-change" data-type="added"> - {i18n.t("ui.patch.action.created")} - </span> - </Match> - <Match when={file().type === "delete"}> - <span data-slot="apply-patch-change" data-type="removed"> - {i18n.t("ui.patch.action.deleted")} - </span> - </Match> - <Match when={file().type === "move"}> - <span data-slot="apply-patch-change" data-type="modified"> - {i18n.t("ui.patch.action.moved")} - </span> - </Match> - <Match when={true}> - <ToolChanges - changes={{ additions: file().additions, deletions: file().deletions }} - animate={reveal()} - /> - </Match> - </Switch> - } - > - <div data-component="apply-patch-file-diff"> - <Dynamic - component={fileComponent} - mode="diff" - before={{ name: file().filePath, contents: file().before }} - after={{ name: file().movePath ?? file().filePath, contents: file().after }} - /> - </div> - </ToolFileAccordion> - )} - </Show> - </ToolCall> - </div> + <ToolFileAccordion + path={single()!.relativePath} + actions={ + <Switch> + <Match when={single()!.type === "add"}> + <span data-slot="apply-patch-change" data-type="added"> + {i18n.t("ui.patch.action.created")} + </span> + </Match> + <Match when={single()!.type === "delete"}> + <span data-slot="apply-patch-change" data-type="removed"> + {i18n.t("ui.patch.action.deleted")} + </span> + </Match> + <Match when={single()!.type === "move"}> + <span data-slot="apply-patch-change" data-type="modified"> + {i18n.t("ui.patch.action.moved")} + </span> + </Match> + <Match when={true}> + <DiffChanges changes={{ additions: single()!.additions, deletions: single()!.deletions }} /> + </Match> + </Switch> + } + > + <div data-component="apply-patch-file-diff"> + <Dynamic + component={fileComponent} + mode="diff" + before={{ name: single()!.filePath, contents: single()!.before }} + after={{ name: single()!.movePath ?? single()!.filePath, contents: single()!.after }} + /> + </div> + </ToolFileAccordion> + </BasicTool> + </div> + </Show> ) }, }) @@ -1970,7 +2060,6 @@ ToolRegistry.register({ return [] }) - const pending = createMemo(() => busy(props.status)) const subtitle = createMemo(() => { const list = todos() @@ -1979,19 +2068,14 @@ ToolRegistry.register({ }) return ( - <ToolCall - variant="panel" + <BasicTool {...props} defaultOpen icon="checklist" - trigger={ - <ToolTriggerRow - title={i18n.t("ui.tool.todos")} - pending={pending()} - subtitle={subtitle()} - animate={props.reveal} - /> - } + trigger={{ + title: i18n.t("ui.tool.todos"), + subtitle: subtitle(), + }} > <Show when={todos().length}> <div data-component="todos"> @@ -2009,7 +2093,7 @@ ToolRegistry.register({ </For> </div> </Show> - </ToolCall> + </BasicTool> ) }, }) @@ -2021,7 +2105,6 @@ ToolRegistry.register({ const questions = createMemo(() => (props.input.questions ?? []) as QuestionInfo[]) const answers = createMemo(() => (props.metadata.answers ?? []) as QuestionAnswer[]) const completed = createMemo(() => answers().length > 0) - const pending = createMemo(() => busy(props.status)) const subtitle = createMemo(() => { const count = questions().length @@ -2031,19 +2114,14 @@ ToolRegistry.register({ }) return ( - <ToolCall - variant="panel" + <BasicTool {...props} - defaultOpen={false} + defaultOpen={completed()} icon="bubble-5" - trigger={ - <ToolTriggerRow - title={i18n.t("ui.tool.questions")} - pending={pending()} - subtitle={subtitle()} - animate={props.reveal} - /> - } + trigger={{ + title: i18n.t("ui.tool.questions"), + subtitle: subtitle(), + }} > <Show when={completed()}> <div data-component="question-answers"> @@ -2060,7 +2138,7 @@ ToolRegistry.register({ </For> </div> </Show> - </ToolCall> + </BasicTool> ) }, }) @@ -2068,28 +2146,21 @@ ToolRegistry.register({ ToolRegistry.register({ name: "skill", render(props) { - const i18n = useI18n() - const pending = createMemo(() => busy(props.status)) - const name = createMemo(() => { - const value = props.input.name || props.metadata.name - if (typeof value === "string") return value - }) - return ( - <ToolCall - variant="row" - icon="brain" - status={props.status} - trigger={ - <ToolTriggerRow - title={i18n.t("ui.tool.skill")} - pending={pending()} - subtitle={name()} - animate={props.reveal} - revealOnMount - /> - } - animate - /> + const title = createMemo(() => props.input.name || "skill") + const running = createMemo(() => props.status === "pending" || props.status === "running") + + const titleContent = () => <TextShimmer text={title()} active={running()} /> + + const trigger = () => ( + <div data-slot="basic-tool-tool-info-structured"> + <div data-slot="basic-tool-tool-info-main"> + <span data-slot="basic-tool-tool-title" class="capitalize agent-title"> + {titleContent()} + </span> + </div> + </div> ) + + return <BasicTool icon="brain" status={props.status} trigger={trigger()} hideDetails /> }, }) diff --git a/packages/ui/src/components/motion-spring.tsx b/packages/ui/src/components/motion-spring.tsx index c7ff1fbcd..a5104a1a3 100644 --- a/packages/ui/src/components/motion-spring.tsx +++ b/packages/ui/src/components/motion-spring.tsx @@ -1,9 +1,8 @@ import { attachSpring, motionValue } from "motion" import type { SpringOptions } from "motion" import { createEffect, createSignal, onCleanup } from "solid-js" -import { useReducedMotion } from "../hooks/use-reduced-motion" -type Opt = Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity"> +type Opt = Partial<Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">> const eq = (a: Opt | undefined, b: Opt | undefined) => a?.visualDuration === b?.visualDuration && a?.bounce === b?.bounce && @@ -14,41 +13,24 @@ const eq = (a: Opt | undefined, b: Opt | undefined) => export function useSpring(target: () => number, options?: Opt | (() => Opt)) { const read = () => (typeof options === "function" ? options() : options) - const reduce = useReducedMotion() const [value, setValue] = createSignal(target()) const source = motionValue(value()) const spring = motionValue(value()) let config = read() - let reduced = reduce() - let stop = reduced ? () => {} : attachSpring(spring, source, config) - let off = spring.on("change", (next) => setValue(next)) + let stop = attachSpring(spring, source, config) + let off = spring.on("change", (next: number) => setValue(next)) createEffect(() => { - const next = target() - if (reduced) { - source.set(next) - spring.set(next) - setValue(next) - return - } - source.set(next) + source.set(target()) }) createEffect(() => { + if (!options) return const next = read() - const skip = reduce() - if (eq(config, next) && reduced === skip) return + if (eq(config, next)) return config = next - reduced = skip stop() - stop = skip ? () => {} : attachSpring(spring, source, next) - if (skip) { - const value = target() - source.set(value) - spring.set(value) - setValue(value) - return - } + stop = attachSpring(spring, source, next) setValue(spring.get()) }) diff --git a/packages/ui/src/components/motion.tsx b/packages/ui/src/components/motion.tsx deleted file mode 100644 index 6cdf01c73..000000000 --- a/packages/ui/src/components/motion.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { followValue } from "motion" -import type { MotionValue } from "motion" - -export { animate, springValue } from "motion" -export type { AnimationPlaybackControls } from "motion" - -/** - * Like `springValue` but preserves getters on the config object. - * `springValue` spreads config at creation, snapshotting getter values. - * This passes the config through to `followValue` intact, so getters - * on `visualDuration` etc. fire on every `.set()` call. - */ -export function tunableSpringValue<T extends string | number>(initial: T, config: SpringConfig): MotionValue<T> { - return followValue(initial, config as any) -} - -let _growDuration = 0.5 -let _collapsibleDuration = 0.3 - -export const GROW_SPRING = { - type: "spring" as const, - get visualDuration() { - return _growDuration - }, - bounce: 0, -} - -export const COLLAPSIBLE_SPRING = { - type: "spring" as const, - get visualDuration() { - return _collapsibleDuration - }, - bounce: 0, -} - -export const setGrowDuration = (v: number) => { - _growDuration = v -} -export const setCollapsibleDuration = (v: number) => { - _collapsibleDuration = v -} -export const getGrowDuration = () => _growDuration -export const getCollapsibleDuration = () => _collapsibleDuration - -export type SpringConfig = { type: "spring"; visualDuration: number; bounce: number } - -export const FAST_SPRING = { - type: "spring" as const, - visualDuration: 0.35, - bounce: 0, -} - -export const GLOW_SPRING = { - type: "spring" as const, - visualDuration: 0.4, - bounce: 0.15, -} - -export const WIPE_MASK = - "linear-gradient(to right, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 45%, rgba(0,0,0,0) 60%, rgba(0,0,0,0) 100%)" - -export const clearMaskStyles = (el: HTMLElement) => { - el.style.maskImage = "" - el.style.webkitMaskImage = "" - el.style.maskSize = "" - el.style.webkitMaskSize = "" - el.style.maskRepeat = "" - el.style.webkitMaskRepeat = "" - el.style.maskPosition = "" - el.style.webkitMaskPosition = "" -} - -export const clearFadeStyles = (el: HTMLElement) => { - el.style.opacity = "" - el.style.filter = "" - el.style.transform = "" -} diff --git a/packages/ui/src/components/rolling-results.css b/packages/ui/src/components/rolling-results.css deleted file mode 100644 index 200b2a97e..000000000 --- a/packages/ui/src/components/rolling-results.css +++ /dev/null @@ -1,92 +0,0 @@ -[data-component="rolling-results"] { - --rolling-results-row-height: 22px; - --rolling-results-fixed-height: var(--rolling-results-row-height); - --rolling-results-fixed-gap: 0px; - --rolling-results-row-gap: 0px; - - display: block; - width: 100%; - min-width: 0; - - [data-slot="rolling-results-viewport"] { - position: relative; - min-width: 0; - height: 0; - overflow: clip; - } - - &[data-overflowing="true"]:not([data-scrollable="true"]) [data-slot="rolling-results-window"] { - mask-image: linear-gradient( - to bottom, - transparent 0%, - black var(--rolling-results-fade), - black calc(100% - calc(var(--rolling-results-fade) * 0.5)), - transparent 100% - ); - -webkit-mask-image: linear-gradient( - to bottom, - transparent 0%, - black var(--rolling-results-fade), - black calc(100% - calc(var(--rolling-results-fade) * 0.5)), - transparent 100% - ); - } - - [data-slot="rolling-results-fixed"] { - min-width: 0; - height: var(--rolling-results-fixed-height); - min-height: var(--rolling-results-fixed-height); - display: flex; - align-items: center; - } - - [data-slot="rolling-results-window"] { - min-width: 0; - margin-top: var(--rolling-results-fixed-gap); - height: calc(100% - var(--rolling-results-fixed-height) - var(--rolling-results-fixed-gap)); - overflow: clip; - } - - &[data-scrollable="true"] [data-slot="rolling-results-window"] { - scrollbar-width: none; - -ms-overflow-style: none; - - &::-webkit-scrollbar { - display: none; - } - } - - &[data-scrollable="true"] [data-slot="rolling-results-track"] { - transform: none !important; - will-change: auto; - } - - [data-slot="rolling-results-body"] { - min-width: 0; - } - - [data-slot="rolling-results-track"] { - display: flex; - min-width: 0; - flex-direction: column; - gap: var(--rolling-results-row-gap); - will-change: transform; - } - - [data-slot="rolling-results-row"], - [data-slot="rolling-results-empty"] { - min-width: 0; - height: var(--rolling-results-row-height); - min-height: var(--rolling-results-row-height); - display: flex; - align-items: center; - } - - [data-slot="rolling-results-row"] { - color: var(--text-base); - } - - [data-slot="rolling-results-empty"] { - color: var(--text-weaker); - } -} diff --git a/packages/ui/src/components/rolling-results.tsx b/packages/ui/src/components/rolling-results.tsx deleted file mode 100644 index 77ffdb1b3..000000000 --- a/packages/ui/src/components/rolling-results.tsx +++ /dev/null @@ -1,325 +0,0 @@ -import { For, Show, batch, createEffect, createMemo, createSignal, on, onCleanup, onMount, type JSX } from "solid-js" -import { useReducedMotion } from "../hooks/use-reduced-motion" -import { animate, clearMaskStyles, GROW_SPRING, type AnimationPlaybackControls, type SpringConfig } from "./motion" - -export type RollingResultsProps<T> = { - items: T[] - render: (item: T, index: number) => JSX.Element - fixed?: JSX.Element - getKey?: (item: T, index: number) => string - rows?: number - rowHeight?: number - fixedHeight?: number - rowGap?: number - open?: boolean - scrollable?: boolean - spring?: SpringConfig - animate?: boolean - class?: string - empty?: JSX.Element - noFadeOnCollapse?: boolean -} - -export function RollingResults<T>(props: RollingResultsProps<T>) { - let view: HTMLDivElement | undefined - let track: HTMLDivElement | undefined - let windowEl: HTMLDivElement | undefined - let shift: AnimationPlaybackControls | undefined - let resize: AnimationPlaybackControls | undefined - let edgeFade: AnimationPlaybackControls | undefined - const reduce = useReducedMotion() - - const rows = createMemo(() => Math.max(1, Math.round(props.rows ?? 3))) - const rowHeight = createMemo(() => Math.max(16, Math.round(props.rowHeight ?? 22))) - const fixedHeight = createMemo(() => Math.max(0, Math.round(props.fixedHeight ?? rowHeight()))) - const rowGap = createMemo(() => Math.max(0, Math.round(props.rowGap ?? 0))) - const fixed = createMemo(() => props.fixed !== undefined) - const list = createMemo(() => props.items ?? []) - const count = createMemo(() => list().length) - - // scrollReady is the internal "transition complete" state. - // It only becomes true after props.scrollable is true AND the offset animation has settled. - const [scrollReady, setScrollReady] = createSignal(false) - - const backstop = createMemo(() => Math.max(rows() * 2, 12)) - const rendered = createMemo(() => { - const items = list() - if (scrollReady()) return items - const max = backstop() - return items.length > max ? items.slice(-max) : items - }) - const skipped = createMemo(() => { - if (scrollReady()) return 0 - return count() - rendered().length - }) - const open = createMemo(() => props.open !== false) - const active = createMemo(() => (props.animate !== false || props.spring !== undefined) && !reduce()) - const noFade = () => props.noFadeOnCollapse === true - const overflowing = createMemo(() => count() > rows()) - const shown = createMemo(() => Math.min(rows(), count())) - const step = createMemo(() => rowHeight() + rowGap()) - const offset = createMemo(() => Math.max(0, count() - shown()) * step()) - const body = createMemo(() => { - if (shown() > 0) { - return shown() * rowHeight() + Math.max(0, shown() - 1) * rowGap() - } - if (props.empty === undefined) return 0 - return rowHeight() - }) - const gap = createMemo(() => { - if (!fixed()) return 0 - if (body() <= 0) return 0 - return rowGap() - }) - const height = createMemo(() => { - if (!open()) return 0 - if (!fixed()) return body() - return fixedHeight() + gap() + body() - }) - - const key = (item: T, index: number) => { - const value = props.getKey - if (value) return value(item, index) - return String(index) - } - - const setTrack = (value: number) => { - if (!track) return - track.style.transform = `translateY(${-Math.round(value)}px)` - } - - const setView = (value: number) => { - if (!view) return - view.style.height = `${Math.max(0, Math.round(value))}px` - } - - onMount(() => { - setTrack(offset()) - }) - - // Original WAAPI offset animation — untouched rolling behavior. - createEffect( - on( - offset, - (next) => { - if (!track) return - if (scrollReady()) return - if (props.scrollable) return - if (!active()) { - shift?.stop() - shift = undefined - setTrack(next) - return - } - shift?.stop() - const anim = animate(track, { transform: `translateY(${-next}px)` }, props.spring ?? GROW_SPRING) - shift = anim - anim.finished - .catch(() => {}) - .finally(() => { - if (shift !== anim) return - setTrack(next) - shift = undefined - }) - }, - { defer: true }, - ), - ) - - // Scrollable transition: wait for the offset animation to finish, - // then batch all DOM changes in one synchronous pass. - createEffect( - on( - () => props.scrollable === true, - (isScrollable) => { - if (!isScrollable) { - setScrollReady(false) - if (windowEl) { - windowEl.style.overflowY = "" - windowEl.style.maskImage = "" - windowEl.style.webkitMaskImage = "" - } - return - } - // Wait for the current offset animation to settle (if any). - const done = shift?.finished ?? Promise.resolve() - done - .catch(() => {}) - .then(() => { - if (props.scrollable !== true) return - - // Batch the signal update — Solid updates the DOM synchronously: - // rendered() returns all items, skipped() returns 0, padding-top removed, - // data-scrollable becomes "true". - batch(() => setScrollReady(true)) - - // Now the DOM has all items. Safe to switch layout strategy. - // CSS handles `transform: none !important` on [data-scrollable="true"]. - if (windowEl) { - windowEl.style.overflowY = "auto" - windowEl.scrollTop = windowEl.scrollHeight - } - updateScrollMask() - }) - }, - ), - ) - - // Auto-scroll to bottom when new items arrive in scrollable mode - const [userScrolled, setUserScrolled] = createSignal(false) - - const updateScrollMask = () => { - if (!windowEl) return - if (!scrollReady()) { - windowEl.style.maskImage = "" - windowEl.style.webkitMaskImage = "" - return - } - const { scrollTop, scrollHeight, clientHeight } = windowEl - const atBottom = scrollHeight - scrollTop - clientHeight < 8 - // Top fade is always present in scrollable mode (matches rolling mode appearance). - // Bottom fade only when not scrolled to the end. - const mask = atBottom - ? "linear-gradient(to bottom, transparent 0, black 8px)" - : "linear-gradient(to bottom, transparent 0, black 8px, black calc(100% - 8px), transparent 100%)" - windowEl.style.maskImage = mask - windowEl.style.webkitMaskImage = mask - } - - createEffect(() => { - if (!scrollReady()) { - setUserScrolled(false) - return - } - const _n = count() - const scrolled = userScrolled() - if (scrolled) return - if (windowEl) { - windowEl.scrollTop = windowEl.scrollHeight - updateScrollMask() - } - }) - - const onWindowScroll = () => { - if (!windowEl || !scrollReady()) return - const atBottom = windowEl.scrollHeight - windowEl.scrollTop - windowEl.clientHeight < 8 - setUserScrolled(!atBottom) - updateScrollMask() - } - - const EDGE_MASK = "linear-gradient(to top, transparent 0%, black 8px)" - const applyEdge = () => { - if (!view) return - edgeFade?.stop() - edgeFade = undefined - view.style.maskImage = EDGE_MASK - view.style.webkitMaskImage = EDGE_MASK - view.style.maskSize = "100% 100%" - view.style.maskRepeat = "no-repeat" - } - const clearEdge = () => { - if (!view) return - if (!active()) { - clearMaskStyles(view) - return - } - edgeFade?.stop() - const anim = animate(view, { maskSize: "100% 200%" }, props.spring ?? GROW_SPRING) - edgeFade = anim - anim.finished - .catch(() => {}) - .then(() => { - if (edgeFade !== anim || !view) return - clearMaskStyles(view) - edgeFade = undefined - }) - } - - createEffect( - on(height, (next, prev) => { - if (!view) return - if (!active()) { - resize?.stop() - resize = undefined - setView(next) - view.style.opacity = "" - clearEdge() - return - } - const collapsing = next === 0 && prev !== undefined && prev > 0 - const expanding = prev === 0 && next > 0 - resize?.stop() - view.style.opacity = "" - applyEdge() - const spring = props.spring ?? GROW_SPRING - const anim = collapsing - ? animate(view, noFade() ? { height: `${next}px` } : { height: `${next}px`, opacity: 0 }, spring) - : expanding - ? animate(view, noFade() ? { height: `${next}px` } : { height: `${next}px`, opacity: [0, 1] }, spring) - : animate(view, { height: `${next}px` }, spring) - resize = anim - anim.finished - .catch(() => {}) - .finally(() => { - view.style.opacity = "" - if (resize !== anim) return - setView(next) - resize = undefined - clearEdge() - }) - }), - ) - - onCleanup(() => { - shift?.stop() - resize?.stop() - edgeFade?.stop() - shift = undefined - resize = undefined - edgeFade = undefined - }) - - return ( - <div - data-component="rolling-results" - class={props.class} - data-open={open() ? "true" : "false"} - data-overflowing={overflowing() ? "true" : "false"} - data-scrollable={scrollReady() ? "true" : "false"} - data-fixed={fixed() ? "true" : "false"} - style={{ - "--rolling-results-row-height": `${rowHeight()}px`, - "--rolling-results-fixed-height": `${fixed() ? fixedHeight() : 0}px`, - "--rolling-results-fixed-gap": `${gap()}px`, - "--rolling-results-row-gap": `${rowGap()}px`, - "--rolling-results-fade": `${Math.round(rowHeight() * 0.6)}px`, - }} - > - <div ref={view} data-slot="rolling-results-viewport" aria-live="polite"> - <Show when={fixed()}> - <div data-slot="rolling-results-fixed">{props.fixed}</div> - </Show> - <div ref={windowEl} data-slot="rolling-results-window" onScroll={onWindowScroll}> - <div data-slot="rolling-results-body"> - <Show when={list().length === 0 && props.empty !== undefined}> - <div data-slot="rolling-results-empty">{props.empty}</div> - </Show> - <div - ref={track} - data-slot="rolling-results-track" - style={{ "padding-top": scrollReady() ? undefined : `${skipped() * step()}px` }} - > - <For each={rendered()}> - {(item, index) => ( - <div data-slot="rolling-results-row" data-key={key(item, index())}> - {props.render(item, index())} - </div> - )} - </For> - </div> - </div> - </div> - </div> - </div> - ) -} diff --git a/packages/ui/src/components/scroll-view.css b/packages/ui/src/components/scroll-view.css index a8574cc9f..f6a49e241 100644 --- a/packages/ui/src/components/scroll-view.css +++ b/packages/ui/src/components/scroll-view.css @@ -9,13 +9,6 @@ overflow-y: auto; scrollbar-width: none; outline: none; - display: block; - overflow-anchor: none; -} - -.scroll-view__viewport[data-reverse="true"] { - display: flex; - flex-direction: column-reverse; } .scroll-view__viewport::-webkit-scrollbar { @@ -52,6 +45,18 @@ background-color: var(--border-strong-base); } +.dark .scroll-view__thumb::after, +[data-theme="dark"] .scroll-view__thumb::after { + background-color: var(--border-weak-base); +} + +.dark .scroll-view__thumb:hover::after, +[data-theme="dark"] .scroll-view__thumb:hover::after, +.dark .scroll-view__thumb[data-dragging="true"]::after, +[data-theme="dark"] .scroll-view__thumb[data-dragging="true"]::after { + background-color: var(--border-strong-base); +} + .scroll-view__thumb[data-visible="true"] { opacity: 1; } diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx index a8d3cf0f8..52ed39a46 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view.tsx @@ -1,18 +1,17 @@ -import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show } from "solid-js" -import { animate, type AnimationPlaybackControls } from "motion" +import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js" import { useI18n } from "../context/i18n" -import { FAST_SPRING } from "./motion" export interface ScrollViewProps extends ComponentProps<"div"> { viewportRef?: (el: HTMLDivElement) => void - reverse?: boolean + orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb } export function ScrollView(props: ScrollViewProps) { const i18n = useI18n() + const merged = mergeProps({ orientation: "vertical" }, props) const [local, events, rest] = splitProps( - props, - ["class", "children", "viewportRef", "style", "reverse"], + merged, + ["class", "children", "viewportRef", "orientation", "style"], [ "onScroll", "onWheel", @@ -26,9 +25,9 @@ export function ScrollView(props: ScrollViewProps) { ], ) + let rootRef!: HTMLDivElement let viewportRef!: HTMLDivElement let thumbRef!: HTMLDivElement - let anim: AnimationPlaybackControls | undefined const [isHovered, setIsHovered] = createSignal(false) const [isDragging, setIsDragging] = createSignal(false) @@ -37,8 +36,6 @@ export function ScrollView(props: ScrollViewProps) { const [thumbTop, setThumbTop] = createSignal(0) const [showThumb, setShowThumb] = createSignal(false) - const reverse = () => local.reverse === true - const updateThumb = () => { if (!viewportRef) return const { scrollTop, scrollHeight, clientHeight } = viewportRef @@ -60,13 +57,9 @@ export function ScrollView(props: ScrollViewProps) { const maxScrollTop = scrollHeight - clientHeight const maxThumbTop = trackHeight - height - const top = (() => { - if (maxScrollTop <= 0) return 0 - if (!reverse()) return (scrollTop / maxScrollTop) * maxThumbTop - return ((maxScrollTop + scrollTop) / maxScrollTop) * maxThumbTop - })() + const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0 - // Ensure thumb stays within bounds + // Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety) const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop)) setThumbHeight(height) @@ -89,7 +82,6 @@ export function ScrollView(props: ScrollViewProps) { } onCleanup(() => { - stop() observer.disconnect() }) @@ -131,31 +123,6 @@ export function ScrollView(props: ScrollViewProps) { thumbRef.addEventListener("pointerup", onPointerUp) } - const stop = () => { - if (!anim) return - anim.stop() - anim = undefined - } - - const limit = (top: number) => { - const max = viewportRef.scrollHeight - viewportRef.clientHeight - if (reverse()) return Math.max(-max, Math.min(0, top)) - return Math.max(0, Math.min(max, top)) - } - - const glide = (top: number) => { - stop() - anim = animate(viewportRef.scrollTop, limit(top), { - ...FAST_SPRING, - onUpdate: (v) => { - viewportRef.scrollTop = v - }, - onComplete: () => { - anim = undefined - }, - }) - } - // Keybinds implementation // We ensure the viewport has a tabindex so it can receive focus // We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior, @@ -180,11 +147,11 @@ export function ScrollView(props: ScrollViewProps) { break case "Home": e.preventDefault() - glide(reverse() ? -(viewportRef.scrollHeight - viewportRef.clientHeight) : 0) + viewportRef.scrollTo({ top: 0, behavior: "smooth" }) break case "End": e.preventDefault() - glide(reverse() ? 0 : viewportRef.scrollHeight - viewportRef.clientHeight) + viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" }) break case "ArrowUp": e.preventDefault() @@ -199,6 +166,7 @@ export function ScrollView(props: ScrollViewProps) { return ( <div + ref={rootRef} class={`scroll-view ${local.class || ""}`} style={local.style} onPointerEnter={() => setIsHovered(true)} @@ -209,26 +177,16 @@ export function ScrollView(props: ScrollViewProps) { <div ref={viewportRef} class="scroll-view__viewport" - data-reverse={reverse() ? "true" : undefined} onScroll={(e) => { updateThumb() if (typeof events.onScroll === "function") events.onScroll(e as any) }} - onWheel={(e) => { - if (e.deltaY) stop() - if (typeof events.onWheel === "function") events.onWheel(e as any) - }} - onTouchStart={(e) => { - stop() - if (typeof events.onTouchStart === "function") events.onTouchStart(e as any) - }} + onWheel={events.onWheel as any} + onTouchStart={events.onTouchStart as any} onTouchMove={events.onTouchMove as any} onTouchEnd={events.onTouchEnd as any} onTouchCancel={events.onTouchCancel as any} - onPointerDown={(e) => { - stop() - if (typeof events.onPointerDown === "function") events.onPointerDown(e as any) - }} + onPointerDown={events.onPointerDown as any} onClick={events.onClick as any} tabIndex={0} role="region" diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 56e060633..eea9a13e4 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -1,4 +1,5 @@ [data-component="session-turn"] { + --sticky-header-height: calc(var(--session-title-height, 0px) + 24px); height: 100%; min-height: 0; min-width: 0; @@ -25,7 +26,7 @@ align-items: flex-start; align-self: stretch; min-width: 0; - gap: 0px; + gap: 18px; overflow-anchor: none; } @@ -42,127 +43,30 @@ align-self: stretch; } - [data-slot="session-turn-assistant-lane"] { - width: 100%; - min-width: 0; - display: flex; - flex-direction: column; - align-self: stretch; - } - [data-slot="session-turn-thinking"] { display: flex; - flex-wrap: nowrap; align-items: center; gap: 8px; width: 100%; min-width: 0; - white-space: nowrap; color: var(--text-weak); font-family: var(--font-family-sans); font-size: var(--font-size-base); font-weight: var(--font-weight-medium); - line-height: var(--line-height-large); - height: 36px; + line-height: 20px; + min-height: 20px; [data-component="spinner"] { width: 16px; height: 16px; } - - > [data-component="text-shimmer"] { - flex: 0 0 auto; - white-space: nowrap; - } - } - - [data-slot="session-turn-handoff-wrap"] { - width: 100%; - min-width: 0; - overflow: visible; - } - - [data-slot="session-turn-handoff"] { - width: 100%; - min-width: 0; - min-height: 37px; - position: relative; - } - - [data-slot="session-turn-thinking"] { - position: absolute; - inset: 0; - will-change: opacity, filter; - transition: - opacity 180ms ease-out, - filter 180ms ease-out, - transform 180ms ease-out; - } - - [data-slot="session-turn-thinking"][data-visible="false"] { - opacity: 0; - filter: blur(2px); - transform: translateY(1px); - pointer-events: none; - } - - [data-slot="session-turn-thinking"][data-visible="true"] { - opacity: 1; - filter: blur(0px); - transform: translateY(0px); - } - - [data-slot="session-turn-meta"] { - position: absolute; - inset: 0; - min-height: 37px; - display: flex; - align-items: center; - justify-content: flex-start; - gap: 10px; - opacity: 0; - pointer-events: none; - transition: opacity 0.15s ease; - } - - [data-slot="session-turn-meta"][data-interrupted] { - gap: 12px; - } - - [data-slot="session-turn-meta"] [data-component="tooltip-trigger"] { - display: inline-flex; - width: fit-content; - } - - [data-slot="session-turn-message-container"]:hover [data-slot="session-turn-meta"][data-visible="true"], - [data-slot="session-turn-message-container"]:focus-within [data-slot="session-turn-meta"][data-visible="true"] { - opacity: 1; - pointer-events: auto; - } - - [data-slot="session-turn-meta-label"] { - user-select: none; - min-width: 0; - overflow: clip; - white-space: nowrap; - text-overflow: ellipsis; } [data-component="text-reveal"].session-turn-thinking-heading { flex: 1 1 auto; min-width: 0; - overflow: clip; - white-space: nowrap; - line-height: inherit; color: var(--text-weaker); font-weight: var(--font-weight-regular); - - [data-slot="text-reveal-track"], - [data-slot="text-reveal-entering"], - [data-slot="text-reveal-leaving"] { - min-height: 0; - line-height: inherit; - } } .error-card { @@ -180,7 +84,7 @@ display: flex; flex-direction: column; align-self: stretch; - gap: 0px; + gap: 12px; > :first-child > [data-component="markdown"]:first-child { margin-top: 0; @@ -205,7 +109,6 @@ [data-component="session-turn-diffs-trigger"] { width: 100%; - height: 36px; display: flex; align-items: center; justify-content: flex-start; @@ -215,7 +118,7 @@ [data-slot="session-turn-diffs-title"] { display: inline-flex; - align-items: center; + align-items: baseline; gap: 8px; } @@ -233,7 +136,7 @@ font-variant-numeric: tabular-nums; font-size: var(--font-size-base); font-weight: var(--font-weight-regular); - line-height: var(--line-height-large); + line-height: var(--line-height-x-large); } [data-slot="session-turn-diffs-meta"] { @@ -269,10 +172,8 @@ [data-slot="session-turn-diff-path"] { display: flex; + flex-grow: 1; min-width: 0; - align-items: baseline; - overflow: clip; - white-space: nowrap; font-family: var(--font-family-sans); font-size: var(--font-size-small); @@ -280,22 +181,16 @@ } [data-slot="session-turn-diff-directory"] { - flex: 1 1 auto; - color: var(--text-weak); - min-width: 0; - overflow: clip; + color: var(--text-base); + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; direction: rtl; - unicode-bidi: plaintext; text-align: left; } [data-slot="session-turn-diff-filename"] { flex-shrink: 0; - max-width: 100%; - min-width: 0; - overflow: clip; - white-space: nowrap; color: var(--text-strong); font-weight: var(--font-weight-medium); } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index f1aee802e..3323a9fc6 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -3,27 +3,23 @@ import type { SessionStatus } from "@opencode-ai/sdk/v2" import { useData } from "../context" import { useFileComponent } from "../context/file" -import { same } from "@opencode-ai/util/array" import { Binary } from "@opencode-ai/util/binary" import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { createEffect, createMemo, createSignal, For, on, onCleanup, ParentProps, Show } from "solid-js" +import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js" import { Dynamic } from "solid-js/web" -import { GrowBox } from "./grow-box" -import { AssistantParts, UserMessageDisplay, Part, PART_MAPPING } from "./message-part" +import { AssistantParts, Message, Part, PART_MAPPING } from "./message-part" import { Card } from "./card" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { Collapsible } from "./collapsible" import { DiffChanges } from "./diff-changes" import { Icon } from "./icon" -import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" -import { TextReveal } from "./text-reveal" -import { list } from "./text-utils" import { SessionRetry } from "./session-retry" -import { Tooltip } from "./tooltip" +import { TextReveal } from "./text-reveal" import { createAutoScroll } from "../hooks" import { useI18n } from "../context/i18n" + function record(value: unknown): value is Record<string, unknown> { return !!value && typeof value === "object" && !Array.isArray(value) } @@ -77,12 +73,18 @@ function unwrap(message: string) { return message } +function same<T>(a: readonly T[], b: readonly T[]) { + if (a === b) return true + if (a.length !== b.length) return false + return a.every((x, i) => x === b[i]) +} + +function list<T>(value: T[] | undefined | null, fallback: T[]) { + if (Array.isArray(value)) return value + return fallback +} + const hidden = new Set(["todowrite", "todoread"]) -const emptyMessages: MessageType[] = [] -const emptyAssistant: AssistantMessage[] = [] -const emptyDiffs: FileDiff[] = [] -const idle: SessionStatus = { type: "idle" as const } -const handoffHoldMs = 120 function partState(part: PartType, showReasoningSummaries: boolean) { if (part.type === "tool") { @@ -139,7 +141,6 @@ export function SessionTurn( props: ParentProps<{ sessionID: string messageID: string - animate?: boolean showReasoningSummaries?: boolean shellToolDefaultOpen?: boolean editToolDefaultOpen?: boolean @@ -158,7 +159,11 @@ export function SessionTurn( const i18n = useI18n() const fileComponent = useFileComponent() + const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] + const emptyAssistant: AssistantMessage[] = [] + const emptyDiffs: FileDiff[] = [] + const idle = { type: "idle" as const } const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages)) @@ -186,8 +191,42 @@ export function SessionTurn( return msg }) - const active = createMemo(() => props.active ?? false) - const queued = createMemo(() => props.queued ?? false) + const pending = createMemo(() => { + if (typeof props.active === "boolean" && typeof props.queued === "boolean") return + const messages = allMessages() ?? emptyMessages + return messages.findLast( + (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number", + ) + }) + + const pendingUser = createMemo(() => { + const item = pending() + if (!item?.parentID) return + const messages = allMessages() ?? emptyMessages + const result = Binary.search(messages, item.parentID, (m) => m.id) + const msg = result.found ? messages[result.index] : messages.find((m) => m.id === item.parentID) + if (!msg || msg.role !== "user") return + return msg + }) + + const active = createMemo(() => { + if (typeof props.active === "boolean") return props.active + const msg = message() + const parent = pendingUser() + if (!msg || !parent) return false + return parent.id === msg.id + }) + + const queued = createMemo(() => { + if (typeof props.queued === "boolean") return props.queued + const id = message()?.id + if (!id) return false + if (!pendingUser()) return false + const item = pending() + if (!item) return false + return id > item.id + }) + const parts = createMemo(() => { const msg = message() if (!msg) return emptyParts @@ -250,7 +289,7 @@ export function SessionTurn( const error = createMemo( () => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error, ) - const assistantCopyPart = createMemo(() => { + const showAssistantCopyPartID = createMemo(() => { const messages = assistantMessages() for (let i = messages.length - 1; i >= 0; i--) { @@ -260,18 +299,13 @@ export function SessionTurn( const parts = list(data.store.part?.[message.id], emptyParts) for (let j = parts.length - 1; j >= 0; j--) { const part = parts[j] - if (!part || part.type !== "text") continue - const text = part.text?.trim() - if (!text) continue - return { - id: part.id, - text, - message, - } + if (!part || part.type !== "text" || !part.text?.trim()) continue + return part.id } } + + return undefined }) - const assistantCopyPartID = createMemo(() => assistantCopyPart()?.id ?? null) const errorText = createMemo(() => { const msg = error()?.data?.message if (typeof msg === "string") return unwrap(msg) @@ -279,14 +313,18 @@ export function SessionTurn( return unwrap(String(msg)) }) - const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle) - const working = createMemo(() => { - if (status().type === "idle") return false - if (!message()) return false - return active() + const status = createMemo(() => { + if (props.status !== undefined) return props.status + if (typeof props.active === "boolean" && !props.active) return idle + return data.store.session_status[props.sessionID] ?? idle }) + const working = createMemo(() => status().type !== "idle" && active()) const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true) - const showDiffSummary = createMemo(() => edited() > 0 && !working()) + + const assistantCopyPartID = createMemo(() => { + if (working()) return null + return showAssistantCopyPartID() ?? null + }) const turnDurationMs = createMemo(() => { const start = message()?.time.created if (typeof start !== "number") return undefined @@ -326,109 +364,13 @@ export function SessionTurn( .filter((text): text is string => !!text) .at(-1), ) - const thinking = createMemo(() => { + const showThinking = createMemo(() => { if (!working() || !!error()) return false if (queued()) return false if (status().type === "retry") return false if (showReasoningSummaries()) return assistantVisible() === 0 return true }) - const hasAssistant = createMemo(() => assistantMessages().length > 0) - const animateEnabled = createMemo(() => props.animate !== false) - const [live, setLive] = createSignal(false) - const thinkingOpen = createMemo(() => thinking() && (live() || !animateEnabled())) - const metaOpen = createMemo(() => !working() && !!assistantCopyPart()) - const duration = createMemo(() => { - const ms = turnDurationMs() - if (typeof ms !== "number" || ms < 0) return "" - - const total = Math.round(ms / 1000) - if (total < 60) return `${total}s` - - const minutes = Math.floor(total / 60) - const seconds = total % 60 - return `${minutes}m ${seconds}s` - }) - const meta = createMemo(() => { - const item = assistantCopyPart() - if (!item) return "" - - const agent = item.message.agent ? item.message.agent[0]?.toUpperCase() + item.message.agent.slice(1) : "" - const model = item.message.modelID - ? (data.store.provider?.all?.find((provider) => provider.id === item.message.providerID)?.models?.[ - item.message.modelID - ]?.name ?? item.message.modelID) - : "" - return [agent, model, duration()].filter((value) => !!value).join("\u00A0\u00B7\u00A0") - }) - const [copied, setCopied] = createSignal(false) - const [handoffHold, setHandoffHold] = createSignal(false) - const thinkingVisible = createMemo(() => thinkingOpen() || handoffHold()) - const handoffOpen = createMemo(() => thinkingVisible() || metaOpen()) - const lane = createMemo(() => hasAssistant() || handoffOpen()) - - let liveFrame: number | undefined - let copiedTimer: ReturnType<typeof setTimeout> | undefined - let handoffTimer: ReturnType<typeof setTimeout> | undefined - - const copyAssistant = async () => { - const text = assistantCopyPart()?.text - if (!text) return - - await navigator.clipboard.writeText(text) - setCopied(true) - if (copiedTimer !== undefined) clearTimeout(copiedTimer) - copiedTimer = setTimeout(() => { - copiedTimer = undefined - setCopied(false) - }, 2000) - } - - createEffect( - on( - () => [animateEnabled(), working()] as const, - ([enabled, isWorking]) => { - if (liveFrame !== undefined) { - cancelAnimationFrame(liveFrame) - liveFrame = undefined - } - if (!enabled || !isWorking || live()) return - liveFrame = requestAnimationFrame(() => { - liveFrame = undefined - setLive(true) - }) - }, - ), - ) - - createEffect( - on( - () => [thinkingOpen(), metaOpen()] as const, - ([thinkingNow, metaNow]) => { - if (handoffTimer !== undefined) { - clearTimeout(handoffTimer) - handoffTimer = undefined - } - - if (thinkingNow) { - setHandoffHold(true) - return - } - - if (metaNow) { - setHandoffHold(false) - return - } - - if (!handoffHold()) return - handoffTimer = setTimeout(() => { - handoffTimer = undefined - setHandoffHold(false) - }, handoffHoldMs) - }, - { defer: true }, - ), - ) const autoScroll = createAutoScroll({ working, @@ -436,119 +378,6 @@ export function SessionTurn( overflowAnchor: "dynamic", }) - onCleanup(() => { - if (liveFrame !== undefined) cancelAnimationFrame(liveFrame) - if (copiedTimer !== undefined) clearTimeout(copiedTimer) - if (handoffTimer !== undefined) clearTimeout(handoffTimer) - }) - - const turnDiffSummary = () => ( - <div data-slot="session-turn-diffs"> - <Collapsible open={open()} onOpenChange={setOpen} variant="ghost"> - <Collapsible.Trigger> - <div data-component="session-turn-diffs-trigger"> - <div data-slot="session-turn-diffs-title"> - <span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span> - <span data-slot="session-turn-diffs-count"> - {edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")} - </span> - <div data-slot="session-turn-diffs-meta"> - <DiffChanges changes={diffs()} variant="bars" /> - <Collapsible.Arrow /> - </div> - </div> - </div> - </Collapsible.Trigger> - <Collapsible.Content> - <Show when={open()}> - <div data-component="session-turn-diffs-content"> - <Accordion - multiple - style={{ "--sticky-accordion-offset": "37px" }} - value={expanded()} - onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])} - > - <For each={diffs()}> - {(diff) => { - const active = createMemo(() => expanded().includes(diff.file)) - const [visible, setVisible] = createSignal(false) - - createEffect( - on( - active, - (value) => { - if (!value) { - setVisible(false) - return - } - - requestAnimationFrame(() => { - if (!active()) return - setVisible(true) - }) - }, - { defer: true }, - ), - ) - - return ( - <Accordion.Item value={diff.file}> - <StickyAccordionHeader> - <Accordion.Trigger> - <div data-slot="session-turn-diff-trigger"> - <span data-slot="session-turn-diff-path"> - <Show when={diff.file.includes("/")}> - <span data-slot="session-turn-diff-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span> - </Show> - <span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span> - </span> - <div data-slot="session-turn-diff-meta"> - <span data-slot="session-turn-diff-changes"> - <DiffChanges changes={diff} /> - </span> - <span data-slot="session-turn-diff-chevron"> - <Icon name="chevron-down" size="small" /> - </span> - </div> - </div> - </Accordion.Trigger> - </StickyAccordionHeader> - <Accordion.Content> - <Show when={visible()}> - <div data-slot="session-turn-diff-view" data-scrollable> - <Dynamic - component={fileComponent} - mode="diff" - before={{ name: diff.file, contents: diff.before }} - after={{ name: diff.file, contents: diff.after }} - /> - </div> - </Show> - </Accordion.Content> - </Accordion.Item> - ) - }} - </For> - </Accordion> - </div> - </Show> - </Collapsible.Content> - </Collapsible> - </div> - ) - - const divider = (label: string) => ( - <div data-component="compaction-part"> - <div data-slot="compaction-part-divider"> - <span data-slot="compaction-part-line" /> - <span data-slot="compaction-part-label" class="text-12-regular text-text-weak"> - {label} - </span> - <span data-slot="compaction-part-line" /> - </div> - </div> - ) - return ( <div data-component="session-turn" class={props.classes?.root}> <div @@ -559,120 +388,149 @@ export function SessionTurn( > <div onClick={autoScroll.handleInteraction}> <Show when={message()}> - {(msg) => ( - <div - ref={autoScroll.contentRef} - data-message={msg().id} - data-slot="session-turn-message-container" - class={props.classes?.container} - > - <div data-slot="session-turn-message-content" aria-live="off"> - <UserMessageDisplay - message={msg()} - parts={parts()} - interrupted={interrupted()} - animate={props.animate} - queued={queued()} + <div + ref={autoScroll.contentRef} + data-message={message()!.id} + data-slot="session-turn-message-container" + class={props.classes?.container} + > + <div data-slot="session-turn-message-content" aria-live="off"> + <Message message={message()!} parts={parts()} interrupted={interrupted()} queued={queued()} /> + </div> + <Show when={compaction()}> + <div data-slot="session-turn-compaction"> + <Part part={compaction()!} message={message()!} hideDetails /> + </div> + </Show> + <Show when={assistantMessages().length > 0}> + <div data-slot="session-turn-assistant-content" aria-hidden={working()}> + <AssistantParts + messages={assistantMessages()} + showAssistantCopyPartID={assistantCopyPartID()} + turnDurationMs={turnDurationMs()} + working={working()} + showReasoningSummaries={showReasoningSummaries()} + shellToolDefaultOpen={props.shellToolDefaultOpen} + editToolDefaultOpen={props.editToolDefaultOpen} /> </div> - <Show when={compaction()}> - {(part) => ( - <GrowBox animate={props.animate !== false} fade gap={8} class="w-full min-w-0"> - <div data-slot="session-turn-compaction"> - <Part part={part()} message={msg()} hideDetails /> - </div> - </GrowBox> - )} - </Show> - <div data-slot="session-turn-assistant-lane" aria-hidden={!lane()}> - <Show when={hasAssistant()}> - <div - data-slot="session-turn-assistant-content" - aria-hidden={working()} - style={{ contain: "layout paint" }} - > - <AssistantParts - messages={assistantMessages()} - showAssistantCopyPartID={assistantCopyPartID()} - showTurnDiffSummary={showDiffSummary()} - turnDiffSummary={turnDiffSummary} - working={working()} - animate={live()} - showReasoningSummaries={showReasoningSummaries()} - shellToolDefaultOpen={props.shellToolDefaultOpen} - editToolDefaultOpen={props.editToolDefaultOpen} - /> - </div> + </Show> + <Show when={showThinking()}> + <div data-slot="session-turn-thinking"> + <TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} /> + <Show when={!showReasoningSummaries()}> + <TextReveal + text={reasoningHeading()} + class="session-turn-thinking-heading" + travel={25} + duration={700} + /> </Show> - <GrowBox - animate={live()} - animateToggle={live()} - open={handoffOpen()} - fade - slot="session-turn-handoff-wrap" - > - <div data-slot="session-turn-handoff"> - <div data-slot="session-turn-thinking" data-visible={thinkingVisible() ? "true" : "false"}> - <TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} /> - <TextReveal - text={!showReasoningSummaries() ? (reasoningHeading() ?? "") : ""} - class="session-turn-thinking-heading" - travel={25} - duration={900} - /> + </div> + </Show> + <SessionRetry status={status()} show={active()} /> + <Show when={edited() > 0 && !working()}> + <div data-slot="session-turn-diffs"> + <Collapsible open={open()} onOpenChange={setOpen} variant="ghost"> + <Collapsible.Trigger> + <div data-component="session-turn-diffs-trigger"> + <div data-slot="session-turn-diffs-title"> + <span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span> + <span data-slot="session-turn-diffs-count"> + {edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")} + </span> + <div data-slot="session-turn-diffs-meta"> + <DiffChanges changes={diffs()} variant="bars" /> + <Collapsible.Arrow /> + </div> + </div> </div> - <Show when={metaOpen()}> - <div - data-slot="session-turn-meta" - data-visible={thinkingVisible() ? "false" : "true"} - data-interrupted={interrupted() ? "" : undefined} - > - <Tooltip - value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} - placement="top" - gutter={4} + </Collapsible.Trigger> + <Collapsible.Content> + <Show when={open()}> + <div data-component="session-turn-diffs-content"> + <Accordion + multiple + style={{ "--sticky-accordion-offset": "40px" }} + value={expanded()} + onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])} > - <IconButton - icon={copied() ? "check" : "copy"} - size="normal" - variant="ghost" - onMouseDown={(event) => event.preventDefault()} - onClick={() => void copyAssistant()} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} - /> - </Tooltip> - <Show when={meta()}> - <span - data-slot="session-turn-meta-label" - class="text-12-regular text-text-weak cursor-default" - > - {meta()} - </span> - </Show> + <For each={diffs()}> + {(diff) => { + const active = createMemo(() => expanded().includes(diff.file)) + const [visible, setVisible] = createSignal(false) + + createEffect( + on( + active, + (value) => { + if (!value) { + setVisible(false) + return + } + + requestAnimationFrame(() => { + if (!active()) return + setVisible(true) + }) + }, + { defer: true }, + ), + ) + + return ( + <Accordion.Item value={diff.file}> + <StickyAccordionHeader> + <Accordion.Trigger> + <div data-slot="session-turn-diff-trigger"> + <span data-slot="session-turn-diff-path"> + <Show when={diff.file.includes("/")}> + <span data-slot="session-turn-diff-directory"> + {`\u202A${getDirectory(diff.file)}\u202C`} + </span> + </Show> + <span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span> + </span> + <div data-slot="session-turn-diff-meta"> + <span data-slot="session-turn-diff-changes"> + <DiffChanges changes={diff} /> + </span> + <span data-slot="session-turn-diff-chevron"> + <Icon name="chevron-down" size="small" /> + </span> + </div> + </div> + </Accordion.Trigger> + </StickyAccordionHeader> + <Accordion.Content> + <Show when={visible()}> + <div data-slot="session-turn-diff-view" data-scrollable> + <Dynamic + component={fileComponent} + mode="diff" + before={{ name: diff.file, contents: diff.before }} + after={{ name: diff.file, contents: diff.after }} + /> + </div> + </Show> + </Accordion.Content> + </Accordion.Item> + ) + }} + </For> + </Accordion> </div> </Show> - </div> - </GrowBox> + </Collapsible.Content> + </Collapsible> </div> - <GrowBox animate={props.animate !== false} fade gap={0} open={interrupted()} class="w-full min-w-0"> - {divider(i18n.t("ui.message.interrupted"))} - </GrowBox> - <SessionRetry status={status()} show={active()} /> - <GrowBox - animate={props.animate !== false} - fade - gap={0} - open={showDiffSummary() && !assistantCopyPartID()} - > - {turnDiffSummary()} - </GrowBox> - <Show when={error()}> - <Card variant="error" class="error-card"> - {errorText()} - </Card> - </Show> - </div> - )} + </Show> + <Show when={error()}> + <Card variant="error" class="error-card"> + {errorText()} + </Card> + </Show> + </div> </Show> {props.children} </div> diff --git a/packages/ui/src/components/shell-rolling-results.tsx b/packages/ui/src/components/shell-rolling-results.tsx deleted file mode 100644 index 0210e46e0..000000000 --- a/packages/ui/src/components/shell-rolling-results.tsx +++ /dev/null @@ -1,291 +0,0 @@ -import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" -import stripAnsi from "strip-ansi" -import type { ToolPart } from "@opencode-ai/sdk/v2" -import { useReducedMotion } from "../hooks/use-reduced-motion" -import { useI18n } from "../context/i18n" -import { RollingResults } from "./rolling-results" -import { Icon } from "./icon" -import { IconButton } from "./icon-button" -import { TextShimmer } from "./text-shimmer" -import { Tooltip } from "./tooltip" -import { GROW_SPRING } from "./motion" -import { useSpring } from "./motion-spring" -import { busy, createThrottledValue, updateScrollMask, useCollapsible, useRowWipe, useToolFade } from "./tool-utils" - -function ShellRollingSubtitle(props: { text: string; animate?: boolean }) { - let ref: HTMLSpanElement | undefined - useToolFade(() => ref, { wipe: true, animate: props.animate }) - - return ( - <span data-slot="shell-rolling-subtitle"> - <span ref={ref}>{props.text}</span> - </span> - ) -} - -function firstLine(text: string) { - return text - .split(/\r\n|\n|\r/g) - .map((item) => item.trim()) - .find((item) => item.length > 0) -} - -function shellRows(output: string) { - const rows: { id: string; text: string }[] = [] - const lines = output - .split(/\r\n|\n|\r/g) - .map((item) => item.trimEnd()) - .filter((item) => item.length > 0) - const start = Math.max(0, lines.length - 80) - for (let i = start; i < lines.length; i++) { - rows.push({ id: `line:${i}`, text: lines[i]! }) - } - - return rows -} - -function ShellRollingCommand(props: { text: string; animate?: boolean }) { - let ref: HTMLSpanElement | undefined - useToolFade(() => ref, { wipe: true, animate: props.animate }) - - return ( - <div data-component="shell-rolling-command"> - <span ref={ref} data-slot="shell-rolling-text"> - <span data-slot="shell-rolling-prompt">$</span> {props.text} - </span> - </div> - ) -} - -function ShellExpanded(props: { cmd: string; out: string; open: boolean }) { - const i18n = useI18n() - const rows = 10 - const rowHeight = 22 - const max = rows * rowHeight - - let contentRef: HTMLDivElement | undefined - let bodyRef: HTMLDivElement | undefined - let scrollRef: HTMLDivElement | undefined - let topRef: HTMLDivElement | undefined - const [copied, setCopied] = createSignal(false) - const [cap, setCap] = createSignal(max) - - const updateMask = () => { - if (scrollRef) updateScrollMask(scrollRef) - } - - const resize = () => { - const top = Math.ceil(topRef?.getBoundingClientRect().height ?? 0) - setCap(Math.max(rowHeight * 2, max - top - (props.out ? 1 : 0))) - } - - const measure = () => { - resize() - return Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0) - } - - onMount(() => { - resize() - if (!topRef) return - const obs = new ResizeObserver(resize) - obs.observe(topRef) - onCleanup(() => obs.disconnect()) - }) - - createEffect(() => { - props.cmd - props.out - queueMicrotask(() => { - resize() - updateMask() - }) - }) - - useCollapsible({ - content: () => contentRef, - body: () => bodyRef, - open: () => props.open, - measure, - onOpen: updateMask, - }) - - const handleCopy = async (e: MouseEvent) => { - e.stopPropagation() - const cmd = props.cmd ? `$ ${props.cmd}` : "" - const text = `${cmd}${props.out ? `${cmd ? "\n\n" : ""}${props.out}` : ""}` - if (!text) return - await navigator.clipboard.writeText(text) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } - - return ( - <div ref={contentRef} style={{ overflow: "clip", height: "0px", display: "none" }}> - <div ref={bodyRef} data-component="shell-expanded-shell"> - <div data-slot="shell-expanded-body"> - <div ref={topRef} data-slot="shell-expanded-top"> - <div data-slot="shell-expanded-command"> - <span data-slot="shell-expanded-prompt">$</span> - <span data-slot="shell-expanded-input">{props.cmd}</span> - </div> - <div data-slot="shell-expanded-actions"> - <Tooltip - value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} - placement="top" - gutter={4} - > - <IconButton - icon={copied() ? "check" : "copy"} - size="small" - variant="ghost" - class="shell-expanded-copy" - onMouseDown={(e: MouseEvent) => e.preventDefault()} - onClick={handleCopy} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} - /> - </Tooltip> - </div> - </div> - <Show when={props.out}> - <> - <div data-slot="shell-expanded-divider" /> - <div - ref={scrollRef} - data-component="shell-expanded-output" - data-scrollable - onScroll={updateMask} - style={{ "max-height": `${cap()}px` }} - > - <pre data-slot="shell-expanded-pre"> - <code>{props.out}</code> - </pre> - </div> - </> - </Show> - </div> - </div> - </div> - ) -} - -export function ShellRollingResults(props: { part: ToolPart; animate?: boolean; defaultOpen?: boolean }) { - const i18n = useI18n() - const reduce = useReducedMotion() - const wiped = new Set<string>() - const [mounted, setMounted] = createSignal(false) - const [open, setOpen] = createSignal(props.defaultOpen ?? true) - onMount(() => setMounted(true)) - const state = createMemo(() => props.part.state as Record<string, any>) - const pending = createMemo(() => busy(props.part.state.status)) - const expanded = createMemo(() => open() && !pending()) - const previewOpen = createMemo(() => open() && pending()) - const command = createMemo(() => { - const value = state().input?.command ?? state().metadata?.command - if (typeof value === "string") return value - return "" - }) - const subtitle = createMemo(() => { - const value = state().input?.description ?? state().metadata?.description - if (typeof value === "string" && value.trim().length > 0) return value - return firstLine(command()) ?? "" - }) - const output = createMemo(() => { - const value = state().output ?? state().metadata?.output - if (typeof value === "string") return value - return "" - }) - const skip = () => reduce() || props.animate === false - const opacity = useSpring(() => (mounted() ? 1 : 0), GROW_SPRING) - const blur = useSpring(() => (mounted() ? 0 : 2), GROW_SPRING) - const previewOpacity = useSpring(() => (previewOpen() ? 1 : 0), GROW_SPRING) - const previewBlur = useSpring(() => (previewOpen() ? 0 : 2), GROW_SPRING) - const headerHeight = useSpring(() => (mounted() ? 37 : 0), GROW_SPRING) - let headerClipRef: HTMLDivElement | undefined - const handleHeaderClick = () => { - const el = headerClipRef - const viewport = el?.closest(".scroll-view__viewport") as HTMLElement | null - const beforeY = el?.getBoundingClientRect().top ?? 0 - setOpen((prev) => !prev) - if (viewport && el) { - requestAnimationFrame(() => { - const afterY = el.getBoundingClientRect().top - const delta = afterY - beforeY - if (delta !== 0) viewport.scrollTop += delta - }) - } - } - const line = createMemo(() => firstLine(command())) - const fixed = createMemo(() => { - const value = line() - if (!value) return - return <ShellRollingCommand text={value} animate={props.animate} /> - }) - const text = createThrottledValue(() => stripAnsi(output())) - const rows = createMemo(() => shellRows(text())) - - return ( - <div - data-component="shell-rolling-results" - style={{ opacity: skip() ? (mounted() ? 1 : 0) : opacity(), filter: `blur(${skip() ? 0 : blur()}px)` }} - > - <div - ref={headerClipRef} - data-slot="shell-rolling-header-clip" - data-scroll-preserve - data-clickable="true" - onClick={handleHeaderClick} - style={{ height: `${skip() ? (mounted() ? 37 : 0) : headerHeight()}px`, overflow: "clip" }} - > - <div data-slot="shell-rolling-header"> - <span data-slot="shell-rolling-title"> - <TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} /> - </span> - <Show when={subtitle()}>{(text) => <ShellRollingSubtitle text={text()} animate={props.animate} />}</Show> - <span data-slot="shell-rolling-actions"> - <span data-slot="shell-rolling-arrow" data-open={open() ? "true" : "false"}> - <Icon name="chevron-down" size="small" /> - </span> - </span> - </div> - </div> - <div - data-slot="shell-rolling-preview" - style={{ - opacity: skip() ? (previewOpen() ? 1 : 0) : previewOpacity(), - filter: `blur(${skip() ? 0 : previewBlur()}px)`, - }} - > - <RollingResults - class="shell-rolling-output" - noFadeOnCollapse - items={rows()} - fixed={fixed()} - fixedHeight={22} - rows={5} - rowHeight={22} - rowGap={0} - open={previewOpen()} - animate={props.animate !== false} - getKey={(row) => row.id} - render={(row) => { - const [textRef, setTextRef] = createSignal<HTMLSpanElement>() - useRowWipe({ - id: () => row.id, - text: () => row.text, - ref: textRef, - seen: wiped, - }) - return ( - <div data-component="shell-rolling-row"> - <span ref={setTextRef} data-slot="shell-rolling-text"> - {row.text} - </span> - </div> - ) - }} - /> - </div> - <ShellExpanded cmd={command()} out={text()} open={expanded()} /> - </div> - ) -} diff --git a/packages/ui/src/components/shell-submessage.css b/packages/ui/src/components/shell-submessage.css index 9f19c2d15..f72ba3fc7 100644 --- a/packages/ui/src/components/shell-submessage.css +++ b/packages/ui/src/components/shell-submessage.css @@ -1,13 +1,23 @@ [data-component="shell-submessage"] { min-width: 0; max-width: 100%; - display: inline-block; + display: inline-flex; + align-items: baseline; vertical-align: baseline; } +[data-component="shell-submessage"] [data-slot="shell-submessage-width"] { + min-width: 0; + max-width: 100%; + display: inline-flex; + align-items: baseline; + overflow: hidden; +} + [data-component="shell-submessage"] [data-slot="shell-submessage-value"] { display: inline-block; vertical-align: baseline; min-width: 0; + line-height: inherit; white-space: nowrap; } diff --git a/packages/ui/src/components/text-reveal.css b/packages/ui/src/components/text-reveal.css index 7939322e6..f799962f0 100644 --- a/packages/ui/src/components/text-reveal.css +++ b/packages/ui/src/components/text-reveal.css @@ -4,14 +4,14 @@ * Instead of sliding text through a fixed mask (odometer style), * the mask itself sweeps across each span to reveal/hide text. * - * Direction: bottom-to-top. New text rises in from below, old text exits upward. + * Direction: top-to-bottom. New text drops in from above, old text exits downward. * - * Entering: gradient reveals bottom-to-top (bottom of text appears first). + * Entering: gradient reveals top-to-bottom (top of text appears first). * gradient(to bottom, white 33%, transparent 33%+edge) * pos 0 100% = transparent covers element = hidden * pos 0 0% = white covers element = visible * - * Leaving: gradient hides bottom-to-top (bottom of text disappears first). + * Leaving: gradient hides top-to-bottom (top of text disappears first). * gradient(to top, white 33%, transparent 33%+edge) * pos 0 100% = white covers element = visible * pos 0 0% = transparent covers element = hidden @@ -56,17 +56,17 @@ transition-timing-function: var(--_spring); } - /* ── entering: reveal bottom-to-top ── - * Gradient(to bottom): white at top, transparent at bottom of mask. - * Settled pos 0 0% = white covers element = visible - * Swap pos 0 100% = transparent covers = hidden - * Rises from below: translateY(travel) → translateY(0) + /* ── entering: reveal top-to-bottom ── + * Gradient(to top): white at bottom, transparent at top of mask. + * Settled pos 0 100% = white covers element = visible + * Swap pos 0 0% = transparent covers = hidden + * Slides from above: translateY(-travel) → translateY(0) */ [data-slot="text-reveal-entering"] { - mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge))); - -webkit-mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge))); - mask-position: 0 0%; - -webkit-mask-position: 0 0%; + mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge))); + -webkit-mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge))); + mask-position: 0 100%; + -webkit-mask-position: 0 100%; transition-property: mask-position, -webkit-mask-position, @@ -74,37 +74,37 @@ transform: translateY(0); } - /* ── leaving: hide bottom-to-top + slide upward ── - * Gradient(to top): white at bottom, transparent at top of mask. - * Swap pos 0 100% = white covers element = visible - * Settled pos 0 0% = transparent covers = hidden - * Slides up: translateY(0) → translateY(-travel) + /* ── leaving: hide top-to-bottom + slide downward ── + * Gradient(to bottom): white at top, transparent at bottom of mask. + * Swap pos 0 0% = white covers element = visible + * Settled pos 0 100% = transparent covers = hidden + * Slides down: translateY(0) → translateY(travel) */ [data-slot="text-reveal-leaving"] { - mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge))); - -webkit-mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge))); - mask-position: 0 0%; - -webkit-mask-position: 0 0%; + mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge))); + -webkit-mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge))); + mask-position: 0 100%; + -webkit-mask-position: 0 100%; transition-property: mask-position, -webkit-mask-position, transform; - transform: translateY(calc(var(--_travel) * -1)); + transform: translateY(var(--_travel)); } /* ── swapping: instant reset ── - * Snap entering to hidden (below), leaving to visible (center). + * Snap entering to hidden (above), leaving to visible (center). */ &[data-swapping="true"] [data-slot="text-reveal-entering"] { - mask-position: 0 100%; - -webkit-mask-position: 0 100%; - transform: translateY(var(--_travel)); + mask-position: 0 0%; + -webkit-mask-position: 0 0%; + transform: translateY(calc(var(--_travel) * -1)); transition-duration: 0ms !important; } &[data-swapping="true"] [data-slot="text-reveal-leaving"] { - mask-position: 0 100%; - -webkit-mask-position: 0 100%; + mask-position: 0 0%; + -webkit-mask-position: 0 0%; transform: translateY(0); transition-duration: 0ms !important; } @@ -126,14 +126,15 @@ &[data-truncate="true"] [data-slot="text-reveal-track"] { width: 100%; min-width: 0; - overflow: clip; + overflow: hidden; } &[data-truncate="true"] [data-slot="text-reveal-entering"], &[data-truncate="true"] [data-slot="text-reveal-leaving"] { min-width: 0; width: 100%; - overflow: clip; + overflow: hidden; + text-overflow: ellipsis; } } diff --git a/packages/ui/src/components/text-reveal.tsx b/packages/ui/src/components/text-reveal.tsx index edf5dbf83..c4fe1302f 100644 --- a/packages/ui/src/components/text-reveal.tsx +++ b/packages/ui/src/components/text-reveal.tsx @@ -1,13 +1,4 @@ import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js" -import { useReducedMotion } from "../hooks/use-reduced-motion" -import { - animate, - type AnimationPlaybackControls, - clearFadeStyles, - clearMaskStyles, - GROW_SPRING, - WIPE_MASK, -} from "./motion" const px = (value: number | string | undefined, fallback: number) => { if (typeof value === "number") return `${value}px` @@ -26,11 +17,6 @@ const pct = (value: number | undefined, fallback: number) => { return `${v}%` } -const clearWipe = (el: HTMLElement) => { - clearFadeStyles(el) - clearMaskStyles(el) -} - export function TextReveal(props: { text?: string class?: string @@ -53,8 +39,10 @@ export function TextReveal(props: { let outRef: HTMLSpanElement | undefined let rootRef: HTMLSpanElement | undefined let frame: number | undefined + const win = () => inRef?.scrollWidth ?? 0 const wout = () => outRef?.scrollWidth ?? 0 + const widen = (next: number) => { if (next <= 0) return if (props.growOnly ?? true) { @@ -63,14 +51,21 @@ export function TextReveal(props: { } setWidth(`${next}px`) } + createEffect( on( () => props.text, (next, prev) => { if (next === prev) return + if (typeof next === "string" && typeof prev === "string" && next.startsWith(prev)) { + setCur(next) + widen(win()) + return + } setSwapping(true) setOld(prev) setCur(next) + if (typeof requestAnimationFrame !== "function") { widen(Math.max(win(), wout())) rootRef?.offsetHeight @@ -138,95 +133,3 @@ export function TextReveal(props: { </span> ) } - -export function TextWipe(props: { text?: string; class?: string; delay?: number; animate?: boolean }) { - let ref: HTMLSpanElement | undefined - let frame: number | undefined - let anim: AnimationPlaybackControls | undefined - const reduce = useReducedMotion() - - const run = () => { - if (props.animate === false) return - const el = ref - if (!el || !props.text || typeof window === "undefined") return - if (reduce()) return - - const mask = - typeof CSS !== "undefined" && - (CSS.supports("mask-image", "linear-gradient(to right, black, transparent)") || - CSS.supports("-webkit-mask-image", "linear-gradient(to right, black, transparent)")) - - anim?.stop() - if (frame !== undefined && typeof cancelAnimationFrame === "function") { - cancelAnimationFrame(frame) - frame = undefined - } - - el.style.opacity = "0" - el.style.filter = "blur(3px)" - el.style.transform = "translateX(-0.06em)" - - if (mask) { - el.style.maskImage = WIPE_MASK - el.style.webkitMaskImage = WIPE_MASK - el.style.maskSize = "240% 100%" - el.style.webkitMaskSize = "240% 100%" - el.style.maskRepeat = "no-repeat" - el.style.webkitMaskRepeat = "no-repeat" - el.style.maskPosition = "100% 0%" - el.style.webkitMaskPosition = "100% 0%" - } - - if (typeof requestAnimationFrame !== "function") { - clearWipe(el) - return - } - - frame = requestAnimationFrame(() => { - frame = undefined - const node = ref - if (!node) return - anim = mask - ? animate( - node, - { opacity: 1, filter: "blur(0px)", transform: "translateX(0)", maskPosition: "0% 0%" }, - { ...GROW_SPRING, delay: props.delay ?? 0 }, - ) - : animate( - node, - { opacity: 1, filter: "blur(0px)", transform: "translateX(0)" }, - { ...GROW_SPRING, delay: props.delay ?? 0 }, - ) - - anim?.finished.then(() => { - const value = ref - if (!value) return - clearWipe(value) - }) - }) - } - - createEffect( - on( - () => [props.text, props.animate] as const, - ([text, enabled]) => { - if (!text || enabled === false) { - if (ref) clearWipe(ref) - return - } - run() - }, - ), - ) - - onCleanup(() => { - if (frame !== undefined && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame) - anim?.stop() - }) - - return ( - <span ref={ref} class={props.class} aria-label={props.text ?? ""}> - {props.text ?? "\u00A0"} - </span> - ) -} diff --git a/packages/ui/src/components/text-shimmer.css b/packages/ui/src/components/text-shimmer.css index bd1437c27..f042dd2d8 100644 --- a/packages/ui/src/components/text-shimmer.css +++ b/packages/ui/src/components/text-shimmer.css @@ -1,11 +1,11 @@ [data-component="text-shimmer"] { --text-shimmer-step: 45ms; - --text-shimmer-duration: 2000ms; + --text-shimmer-duration: 1200ms; --text-shimmer-swap: 220ms; --text-shimmer-index: 0; --text-shimmer-angle: 90deg; --text-shimmer-spread: 5.2ch; - --text-shimmer-size: 600%; + --text-shimmer-size: 360%; --text-shimmer-base-color: var(--text-weak); --text-shimmer-peak-color: var(--text-strong); --text-shimmer-sweep: linear-gradient( @@ -16,17 +16,15 @@ ); --text-shimmer-base: linear-gradient(var(--text-shimmer-base-color), var(--text-shimmer-base-color)); - display: inline-block; - vertical-align: baseline; + display: inline-flex; + align-items: baseline; font: inherit; letter-spacing: inherit; line-height: inherit; } [data-component="text-shimmer"] [data-slot="text-shimmer-char"] { - display: inline-block; - position: relative; - vertical-align: baseline; + display: inline-grid; white-space: pre; font: inherit; letter-spacing: inherit; @@ -35,7 +33,7 @@ [data-component="text-shimmer"] [data-slot="text-shimmer-char-base"], [data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] { - display: inline-block; + grid-area: 1 / 1; white-space: pre; transition: opacity var(--text-shimmer-swap) ease-out; font: inherit; @@ -44,14 +42,11 @@ } [data-component="text-shimmer"] [data-slot="text-shimmer-char-base"] { - position: relative; color: inherit; opacity: 1; } [data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] { - position: absolute; - inset: 0; color: var(--text-weaker); opacity: 0; } diff --git a/packages/ui/src/components/text-shimmer.tsx b/packages/ui/src/components/text-shimmer.tsx index 0d797e5c1..3ab077d92 100644 --- a/packages/ui/src/components/text-shimmer.tsx +++ b/packages/ui/src/components/text-shimmer.tsx @@ -37,16 +37,6 @@ export const TextShimmer = <T extends ValidComponent = "span">(props: { clearTimeout(timer) }) - const len = createMemo(() => Math.max(text().length, 1)) - const shimmerSize = createMemo(() => Math.max(300, Math.round(200 + 1400 / len()))) - - // duration = len × (size - 1) / velocity → uniform perceived sweep speed - const VELOCITY = 0.01375 // ch per ms, ~10% faster than original 0.0125 baseline - const shimmerDuration = createMemo(() => { - const s = shimmerSize() / 100 - return Math.max(1000, Math.min(2500, Math.round((len() * (s - 1)) / VELOCITY))) - }) - return ( <Dynamic component={props.as ?? "span"} @@ -57,8 +47,6 @@ export const TextShimmer = <T extends ValidComponent = "span">(props: { style={{ "--text-shimmer-swap": `${swap}ms`, "--text-shimmer-index": `${offset()}`, - "--text-shimmer-size": `${shimmerSize()}%`, - "--text-shimmer-duration": `${shimmerDuration()}ms`, }} > <span data-slot="text-shimmer-char"> diff --git a/packages/ui/src/components/text-utils.ts b/packages/ui/src/components/text-utils.ts deleted file mode 100644 index c094b5e65..000000000 --- a/packages/ui/src/components/text-utils.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** Find the longest common character prefix between two strings. */ -export function commonPrefix(a: string, b: string) { - const ac = Array.from(a) - const bc = Array.from(b) - let i = 0 - while (i < ac.length && i < bc.length && ac[i] === bc[i]) i++ - return { - prefix: ac.slice(0, i).join(""), - aSuffix: ac.slice(i).join(""), - bSuffix: bc.slice(i).join(""), - } -} - -export function list<T>(value: T[] | undefined | null, fallback: T[]): T[] { - if (Array.isArray(value)) return value - return fallback -} diff --git a/packages/ui/src/components/tool-count-label.css b/packages/ui/src/components/tool-count-label.css index 4ed46e50b..11a33ff5d 100644 --- a/packages/ui/src/components/tool-count-label.css +++ b/packages/ui/src/components/tool-count-label.css @@ -27,10 +27,10 @@ grid-template-columns: 0fr; opacity: 0; filter: blur(calc(var(--tool-motion-blur, 2px) * 0.42)); - overflow: clip; + overflow: hidden; transform: translateX(-0.04em); transition-property: grid-template-columns, opacity, filter, transform; - transition-duration: 800ms, 400ms, 400ms, 800ms; + transition-duration: 250ms, 250ms, 250ms, 250ms; transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out, var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); @@ -45,7 +45,7 @@ [data-slot="tool-count-label-suffix-inner"] { min-width: 0; - overflow: clip; + overflow: hidden; white-space: pre; } } diff --git a/packages/ui/src/components/tool-count-label.tsx b/packages/ui/src/components/tool-count-label.tsx index c374d2d37..67e861cdc 100644 --- a/packages/ui/src/components/tool-count-label.tsx +++ b/packages/ui/src/components/tool-count-label.tsx @@ -1,6 +1,5 @@ import { createMemo } from "solid-js" import { AnimatedNumber } from "./animated-number" -import { commonPrefix } from "./text-utils" function split(text: string) { const match = /{{\s*count\s*}}/.exec(text) @@ -12,23 +11,35 @@ function split(text: string) { } } +function common(one: string, other: string) { + const a = Array.from(one) + const b = Array.from(other) + let i = 0 + while (i < a.length && i < b.length && a[i] === b[i]) i++ + return { + stem: a.slice(0, i).join(""), + one: a.slice(i).join(""), + other: b.slice(i).join(""), + } +} + export function AnimatedCountLabel(props: { count: number; one: string; other: string; class?: string }) { const one = createMemo(() => split(props.one)) const other = createMemo(() => split(props.other)) const singular = createMemo(() => Math.round(props.count) === 1) const active = createMemo(() => (singular() ? one() : other())) - const suffix = createMemo(() => commonPrefix(one().after, other().after)) + const suffix = createMemo(() => common(one().after, other().after)) const splitSuffix = createMemo( () => one().before === other().before && (one().after.startsWith(other().after) || other().after.startsWith(one().after)), ) const before = createMemo(() => (splitSuffix() ? one().before : active().before)) - const stem = createMemo(() => (splitSuffix() ? suffix().prefix : active().after)) + const stem = createMemo(() => (splitSuffix() ? suffix().stem : active().after)) const tail = createMemo(() => { if (!splitSuffix()) return "" - if (singular()) return suffix().aSuffix - return suffix().bSuffix + if (singular()) return suffix().one + return suffix().other }) const showTail = createMemo(() => splitSuffix() && tail().length > 0) diff --git a/packages/ui/src/components/tool-count-summary.css b/packages/ui/src/components/tool-count-summary.css index 435ed95fe..da8455267 100644 --- a/packages/ui/src/components/tool-count-summary.css +++ b/packages/ui/src/components/tool-count-summary.css @@ -10,12 +10,12 @@ opacity: 1; filter: blur(0); transform: translateY(0) scale(1); - overflow: clip; + overflow: hidden; transform-origin: left center; transition-property: grid-template-columns, opacity, filter, transform; transition-duration: - var(--tool-motion-spring-ms, 800ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms), - var(--tool-motion-spring-ms, 800ms); + var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 240ms), var(--tool-motion-fade-ms, 280ms), + var(--tool-motion-spring-ms, 480ms); transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out, var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); @@ -35,12 +35,12 @@ opacity: 0; filter: blur(var(--tool-motion-blur, 2px)); transform: translateY(0.06em) scale(0.985); - overflow: clip; + overflow: hidden; transform-origin: left center; transition-property: grid-template-columns, opacity, filter, transform; transition-duration: - var(--tool-motion-spring-ms, 800ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms), - var(--tool-motion-spring-ms, 800ms); + var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 280ms), var(--tool-motion-fade-ms, 320ms), + var(--tool-motion-spring-ms, 480ms); transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out, var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); @@ -55,7 +55,7 @@ [data-slot="tool-count-summary-empty-inner"] { min-width: 0; - overflow: clip; + overflow: hidden; white-space: nowrap; } @@ -63,7 +63,7 @@ display: inline-flex; align-items: baseline; min-width: 0; - overflow: clip; + overflow: hidden; white-space: nowrap; } @@ -75,11 +75,12 @@ margin-right: 0; opacity: 0; filter: blur(calc(var(--tool-motion-blur, 2px) * 0.55)); - overflow: clip; + overflow: hidden; transform: translateX(-0.08em); transition-property: opacity, filter, transform; transition-duration: - var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms); + calc(var(--tool-motion-fade-ms, 200ms) * 0.75), calc(var(--tool-motion-fade-ms, 220ms) * 0.75), + calc(var(--tool-motion-fade-ms, 220ms) * 0.6); transition-timing-function: ease-out, ease-out, ease-out; } diff --git a/packages/ui/src/components/tool-status-title.css b/packages/ui/src/components/tool-status-title.css index 050f5e390..d4415bd2d 100644 --- a/packages/ui/src/components/tool-status-title.css +++ b/packages/ui/src/components/tool-status-title.css @@ -18,8 +18,9 @@ [data-slot="tool-status-swap"], [data-slot="tool-status-tail"] { display: inline-grid; - overflow: clip; + overflow: hidden; justify-items: start; + transition: width var(--tool-motion-spring-ms, 480ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); } [data-slot="tool-status-active"], @@ -30,8 +31,8 @@ text-align: start; transition-property: opacity, filter, transform; transition-duration: - var(--tool-motion-fade-ms, 400ms), calc(var(--tool-motion-fade-ms, 400ms) * 0.8), - calc(var(--tool-motion-fade-ms, 400ms) * 0.8); + var(--tool-motion-fade-ms, 240ms), calc(var(--tool-motion-fade-ms, 240ms) * 0.8), + calc(var(--tool-motion-fade-ms, 240ms) * 0.8); transition-timing-function: ease-out, ease-out, ease-out; } diff --git a/packages/ui/src/components/tool-status-title.tsx b/packages/ui/src/components/tool-status-title.tsx index 444955af9..68440b6c6 100644 --- a/packages/ui/src/components/tool-status-title.tsx +++ b/packages/ui/src/components/tool-status-title.tsx @@ -1,8 +1,17 @@ import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" -import { useReducedMotion } from "../hooks/use-reduced-motion" -import { animate, type AnimationPlaybackControls, GROW_SPRING } from "./motion" import { TextShimmer } from "./text-shimmer" -import { commonPrefix } from "./text-utils" + +function common(active: string, done: string) { + const a = Array.from(active) + const b = Array.from(done) + let i = 0 + while (i < a.length && i < b.length && a[i] === b[i]) i++ + return { + prefix: a.slice(0, i).join(""), + active: a.slice(i).join(""), + done: b.slice(i).join(""), + } +} function contentWidth(el: HTMLSpanElement | undefined) { if (!el) return 0 @@ -18,58 +27,25 @@ export function ToolStatusTitle(props: { class?: string split?: boolean }) { - const reduce = useReducedMotion() - const split = createMemo(() => commonPrefix(props.activeText, props.doneText)) + const split = createMemo(() => common(props.activeText, props.doneText)) const suffix = createMemo( - () => - (props.split ?? true) && split().prefix.length >= 2 && split().aSuffix.length > 0 && split().bSuffix.length > 0, + () => (props.split ?? true) && split().prefix.length >= 2 && split().active.length > 0 && split().done.length > 0, ) const prefixLen = createMemo(() => Array.from(split().prefix).length) - const activeTail = createMemo(() => (suffix() ? split().aSuffix : props.activeText)) - const doneTail = createMemo(() => (suffix() ? split().bSuffix : props.doneText)) + const activeTail = createMemo(() => (suffix() ? split().active : props.activeText)) + const doneTail = createMemo(() => (suffix() ? split().done : props.doneText)) + const [width, setWidth] = createSignal("auto") const [ready, setReady] = createSignal(false) let activeRef: HTMLSpanElement | undefined let doneRef: HTMLSpanElement | undefined - let swapRef: HTMLSpanElement | undefined - let tailRef: HTMLSpanElement | undefined let frame: number | undefined let readyFrame: number | undefined - let widthAnim: AnimationPlaybackControls | undefined - - const node = () => (suffix() ? tailRef : swapRef) - - const setNodeWidth = (width: string) => { - if (swapRef) swapRef.style.width = width - if (tailRef) tailRef.style.width = width - } const measure = () => { const target = props.active ? activeRef : doneRef - const next = contentWidth(target) - if (next <= 0) return - - const ref = node() - if (!ref || !ready() || reduce()) { - widthAnim?.stop() - setNodeWidth(`${next}px`) - return - } - - const prev = Math.max(0, Math.ceil(ref.getBoundingClientRect().width)) - if (Math.abs(next - prev) < 1) { - ref.style.width = `${next}px` - return - } - - ref.style.width = `${prev}px` - widthAnim?.stop() - widthAnim = animate(ref, { width: `${next}px` }, GROW_SPRING) - widthAnim.finished.then(() => { - const el = node() - if (!el) return - el.style.width = `${next}px` - }) + const px = contentWidth(target) + if (px > 0) setWidth(`${px}px`) } const schedule = () => { @@ -114,7 +90,6 @@ export function ToolStatusTitle(props: { onCleanup(() => { if (frame !== undefined) cancelAnimationFrame(frame) if (readyFrame !== undefined) cancelAnimationFrame(readyFrame) - widthAnim?.stop() }) return ( @@ -129,7 +104,7 @@ export function ToolStatusTitle(props: { <Show when={suffix()} fallback={ - <span data-slot="tool-status-swap" ref={swapRef}> + <span data-slot="tool-status-swap" style={{ width: width() }}> <span data-slot="tool-status-active" ref={activeRef}> <TextShimmer text={activeTail()} active={props.active} offset={0} /> </span> @@ -143,7 +118,7 @@ export function ToolStatusTitle(props: { <span data-slot="tool-status-prefix"> <TextShimmer text={split().prefix} active={props.active} offset={0} /> </span> - <span data-slot="tool-status-tail" ref={tailRef}> + <span data-slot="tool-status-tail" style={{ width: width() }}> <span data-slot="tool-status-active" ref={activeRef}> <TextShimmer text={activeTail()} active={props.active} offset={prefixLen()} /> </span> diff --git a/packages/ui/src/components/tool-utils.ts b/packages/ui/src/components/tool-utils.ts deleted file mode 100644 index 4d57c626e..000000000 --- a/packages/ui/src/components/tool-utils.ts +++ /dev/null @@ -1,336 +0,0 @@ -import type { ToolPart } from "@opencode-ai/sdk/v2" -import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" -import { useReducedMotion } from "../hooks/use-reduced-motion" -import { - animate, - type AnimationPlaybackControls, - clearFadeStyles, - clearMaskStyles, - COLLAPSIBLE_SPRING, - GROW_SPRING, - WIPE_MASK, -} from "./motion" - -export const TEXT_RENDER_THROTTLE_MS = 100 - -export function createThrottledValue(getValue: () => string) { - const [value, setValue] = createSignal(getValue()) - let timeout: ReturnType<typeof setTimeout> | undefined - let last = 0 - - createEffect(() => { - const next = getValue() - const now = Date.now() - - const remaining = TEXT_RENDER_THROTTLE_MS - (now - last) - if (remaining <= 0) { - if (timeout) { - clearTimeout(timeout) - timeout = undefined - } - last = now - setValue(next) - return - } - if (timeout) clearTimeout(timeout) - timeout = setTimeout(() => { - last = Date.now() - setValue(next) - timeout = undefined - }, remaining) - }) - - onCleanup(() => { - if (timeout) clearTimeout(timeout) - }) - - return value -} - -export function busy(status: string | undefined) { - return status === "pending" || status === "running" -} - -export function hold(state: () => boolean, wait = 2000) { - const [live, setLive] = createSignal(state()) - let timer: ReturnType<typeof setTimeout> | undefined - - createEffect(() => { - if (state()) { - if (timer) clearTimeout(timer) - timer = undefined - setLive(true) - return - } - - if (timer) clearTimeout(timer) - timer = setTimeout(() => { - timer = undefined - setLive(false) - }, wait) - }) - - onCleanup(() => { - if (timer) clearTimeout(timer) - }) - - return live -} - -export function updateScrollMask(el: HTMLElement, fade = 12) { - const { scrollTop, scrollHeight, clientHeight } = el - const overflow = scrollHeight - clientHeight - if (overflow <= 1) { - el.style.maskImage = "" - el.style.webkitMaskImage = "" - return - } - const top = scrollTop > 1 - const bottom = scrollTop < overflow - 1 - const mask = - top && bottom - ? `linear-gradient(to bottom, transparent 0, black ${fade}px, black calc(100% - ${fade}px), transparent 100%)` - : top - ? `linear-gradient(to bottom, transparent 0, black ${fade}px)` - : bottom - ? `linear-gradient(to bottom, black calc(100% - ${fade}px), transparent 100%)` - : "" - el.style.maskImage = mask - el.style.webkitMaskImage = mask -} - -export function useCollapsible(options: { - content: () => HTMLElement | undefined - body: () => HTMLElement | undefined - open: () => boolean - measure?: () => number - onOpen?: () => void -}) { - const reduce = useReducedMotion() - let heightAnim: AnimationPlaybackControls | undefined - let fadeAnim: AnimationPlaybackControls | undefined - let gen = 0 - - createEffect( - on(options.open, (isOpen) => { - const content = options.content() - const body = options.body() - if (!content || !body) return - heightAnim?.stop() - fadeAnim?.stop() - if (reduce()) { - body.style.opacity = "" - body.style.filter = "" - if (isOpen) { - content.style.display = "" - content.style.height = "auto" - options.onOpen?.() - return - } - content.style.height = "0px" - content.style.display = "none" - return - } - const id = ++gen - if (isOpen) { - content.style.display = "" - content.style.height = "0px" - body.style.opacity = "0" - body.style.filter = "blur(2px)" - fadeAnim = animate(body, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING) - queueMicrotask(() => { - if (gen !== id) return - const c = options.content() - if (!c) return - const h = options.measure?.() ?? Math.ceil(body.getBoundingClientRect().height) - heightAnim = animate(c, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING) - heightAnim.finished.then( - () => { - if (gen !== id) return - c.style.height = "auto" - options.onOpen?.() - }, - () => {}, - ) - }) - return - } - - const h = content.getBoundingClientRect().height - heightAnim = animate(content, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING) - fadeAnim = animate(body, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING) - heightAnim.finished.then( - () => { - if (gen !== id) return - content.style.display = "none" - }, - () => {}, - ) - }), - ) - - onCleanup(() => { - ++gen - heightAnim?.stop() - fadeAnim?.stop() - }) -} - -export function useContextToolPending(parts: () => ToolPart[], working?: () => boolean) { - const anyRunning = createMemo(() => parts().some((part) => busy(part.state.status))) - const [settled, setSettled] = createSignal(false) - createEffect(() => { - if (!anyRunning() && !working?.()) setSettled(true) - }) - return createMemo(() => !settled() && (!!working?.() || anyRunning())) -} - -export function useRowWipe(opts: { - id: () => string - text: () => string | undefined - ref: () => HTMLElement | undefined - seen: Set<string> -}) { - const reduce = useReducedMotion() - - createEffect(() => { - const id = opts.id() - const txt = opts.text() - const el = opts.ref() - if (!el) return - if (!txt) { - clearFadeStyles(el) - clearMaskStyles(el) - return - } - if (reduce() || typeof window === "undefined") { - clearFadeStyles(el) - clearMaskStyles(el) - return - } - if (opts.seen.has(id)) { - clearFadeStyles(el) - clearMaskStyles(el) - return - } - opts.seen.add(id) - - el.style.maskImage = WIPE_MASK - el.style.webkitMaskImage = WIPE_MASK - el.style.maskSize = "240% 100%" - el.style.webkitMaskSize = "240% 100%" - el.style.maskRepeat = "no-repeat" - el.style.webkitMaskRepeat = "no-repeat" - el.style.maskPosition = "100% 0%" - el.style.webkitMaskPosition = "100% 0%" - el.style.opacity = "0" - el.style.filter = "blur(2px)" - el.style.transform = "translateX(-0.06em)" - - let done = false - const clear = () => { - if (done) return - done = true - clearFadeStyles(el) - clearMaskStyles(el) - } - if (typeof requestAnimationFrame !== "function") { - clear() - return - } - let anim: AnimationPlaybackControls | undefined - let frame: number | undefined = requestAnimationFrame(() => { - frame = undefined - const node = opts.ref() - if (!node) return - anim = animate( - node, - { - opacity: [0, 1], - filter: ["blur(2px)", "blur(0px)"], - transform: ["translateX(-0.06em)", "translateX(0)"], - maskPosition: "0% 0%", - }, - GROW_SPRING, - ) - - anim.finished.catch(() => {}).finally(clear) - }) - - onCleanup(() => { - if (frame !== undefined) { - cancelAnimationFrame(frame) - clear() - } - }) - }) -} - -export function useToolFade( - ref: () => HTMLElement | undefined, - options?: { delay?: number; wipe?: boolean; animate?: boolean }, -) { - let anim: AnimationPlaybackControls | undefined - let frame: number | undefined - const delay = options?.delay ?? 0 - const wipe = options?.wipe ?? false - const active = options?.animate !== false - const reduce = useReducedMotion() - - onMount(() => { - if (!active) return - - const el = ref() - if (!el || typeof window === "undefined") return - if (reduce()) return - - const mask = - wipe && - typeof CSS !== "undefined" && - (CSS.supports("mask-image", "linear-gradient(to right, black, transparent)") || - CSS.supports("-webkit-mask-image", "linear-gradient(to right, black, transparent)")) - - el.style.opacity = "0" - el.style.filter = wipe ? "blur(3px)" : "blur(2px)" - el.style.transform = wipe ? "translateX(-0.06em)" : "translateY(0.04em)" - - if (mask) { - el.style.maskImage = WIPE_MASK - el.style.webkitMaskImage = WIPE_MASK - el.style.maskSize = "240% 100%" - el.style.webkitMaskSize = "240% 100%" - el.style.maskRepeat = "no-repeat" - el.style.webkitMaskRepeat = "no-repeat" - el.style.maskPosition = "100% 0%" - el.style.webkitMaskPosition = "100% 0%" - } - - frame = requestAnimationFrame(() => { - frame = undefined - const node = ref() - if (!node) return - - anim = wipe - ? mask - ? animate( - node, - { opacity: 1, filter: "blur(0px)", transform: "translateX(0)", maskPosition: "0% 0%" }, - { ...GROW_SPRING, delay }, - ) - : animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateX(0)" }, { ...GROW_SPRING, delay }) - : animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateY(0)" }, { ...GROW_SPRING, delay }) - - anim?.finished.then(() => { - const value = ref() - if (!value) return - clearFadeStyles(value) - if (mask) clearMaskStyles(value) - }) - }) - }) - - onCleanup(() => { - if (frame !== undefined) cancelAnimationFrame(frame) - anim?.stop() - }) -} |
