diff --git a/packages/ui/src/components/context-tool-results.tsx b/packages/ui/src/components/context-tool-results.tsx index 8b8a3f68c0..4a563c5dc3 100644 --- a/packages/ui/src/components/context-tool-results.tsx +++ b/packages/ui/src/components/context-tool-results.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, For, on, onCleanup, onMount } from "solid-js" +import { createEffect, createMemo, createSignal, For, onMount } from "solid-js" import type { ToolPart } from "@opencode-ai/sdk/v2" import { getFilename } from "@opencode-ai/util/path" import { useI18n } from "../context/i18n" @@ -9,15 +9,13 @@ import { AnimatedCountList } from "./tool-count-summary" import { RollingResults } from "./rolling-results" import { animate, - type AnimationPlaybackControls, clearFadeStyles, clearMaskStyles, - COLLAPSIBLE_SPRING, GROW_SPRING, WIPE_MASK, } from "./motion" import { useSpring } from "./motion-spring" -import { busy } from "./tool-utils" +import { busy, updateScrollMask, useCollapsible } from "./tool-utils" function contextToolLabel(part: ToolPart): { action: string; detail: string } { const state = part.state @@ -124,74 +122,15 @@ export function ContextToolExpandedList(props: { parts: ToolPart[]; expanded: bo let contentRef: HTMLDivElement | undefined let bodyRef: HTMLDivElement | undefined let scrollRef: HTMLDivElement | undefined - let heightAnim: AnimationPlaybackControls | undefined - let fadeAnim: AnimationPlaybackControls | undefined - - const FADE = 12 - const updateMask = () => { - if (!scrollRef) return - const { scrollTop, scrollHeight, clientHeight } = scrollRef - const overflow = scrollHeight - clientHeight - if (overflow <= 1) { - scrollRef.style.maskImage = "" - scrollRef.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%)` - : "" - scrollRef.style.maskImage = mask - scrollRef.style.webkitMaskImage = mask + if (scrollRef) updateScrollMask(scrollRef) } - createEffect( - on( - () => props.expanded, - (isOpen) => { - if (!contentRef || !bodyRef) return - heightAnim?.stop() - fadeAnim?.stop() - if (isOpen) { - contentRef.style.display = "" - bodyRef.style.opacity = "0" - bodyRef.style.filter = "blur(2px)" - const h = bodyRef.getBoundingClientRect().height - heightAnim = animate(contentRef, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING) - fadeAnim = animate(bodyRef, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING) - heightAnim.finished - .catch(() => {}) - .then(() => { - if (!contentRef || !props.expanded) return - contentRef.style.height = "auto" - updateMask() - }) - } else { - const h = contentRef.getBoundingClientRect().height - heightAnim = animate(contentRef, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING) - fadeAnim = animate(bodyRef, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING) - heightAnim.finished - .catch(() => {}) - .then(() => { - if (!contentRef || props.expanded) return - contentRef.style.display = "none" - }) - } - }, - { defer: true }, - ), - ) - - onCleanup(() => { - heightAnim?.stop() - fadeAnim?.stop() + useCollapsible({ + content: () => contentRef, + body: () => bodyRef, + open: () => props.expanded, + onOpen: updateMask, }) return ( diff --git a/packages/ui/src/components/shell-rolling-results.tsx b/packages/ui/src/components/shell-rolling-results.tsx index 19f98c6282..a7ae89f63e 100644 --- a/packages/ui/src/components/shell-rolling-results.tsx +++ b/packages/ui/src/components/shell-rolling-results.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, on, onCleanup, onMount, Show } from "solid-js" +import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" import stripAnsi from "strip-ansi" import type { ToolPart } from "@opencode-ai/sdk/v2" import { prefersReducedMotion } from "../hooks/use-reduced-motion" @@ -8,9 +8,15 @@ import { Icon } from "./icon" import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" import { Tooltip } from "./tooltip" -import { animate, clearFadeStyles, clearMaskStyles, FAST_SPRING, GROW_SPRING, WIPE_MASK } from "./motion" +import { + animate, + clearFadeStyles, + clearMaskStyles, + GROW_SPRING, + WIPE_MASK, +} from "./motion" import { useSpring } from "./motion-spring" -import { busy, createThrottledValue, useToolFade } from "./tool-utils" +import { busy, createThrottledValue, hold, updateScrollMask, useCollapsible, useToolFade } from "./tool-utils" function ShellRollingSubtitle(props: { text: string; animate?: boolean }) { let ref: HTMLSpanElement | undefined @@ -57,6 +63,118 @@ function ShellRollingCommand(props: { text: string; animate?: boolean }) { ) } +function ShellExpanded(props: { cmd: string; out: string; open: boolean }) { + const i18n = useI18n() + const fade = 12 + 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, fade) + } + + 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 ( +
+
+
+
+
+ $ + {props.cmd} +
+
+ + e.preventDefault()} + onClick={handleCopy} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} + /> + +
+
+ + <> +
+
+
+                  {props.out}
+                
+
+ + +
+
+
+ ) +} + export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }) { const i18n = useI18n() const wiped = new Set() @@ -66,36 +184,24 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean } onMount(() => setMounted(true)) const state = createMemo(() => props.part.state as Record) const pending = createMemo(() => busy(props.part.state.status)) - // autoOpen starts true if pending, false if already complete (e.g. scrolling back in history). - // When pending transitions false, autoOpen is still true — no intermediate state — then - // a 2s timer sets it false to trigger the collapse. This avoids the flash caused by - // holdOpen being set in a late-running effect. - const [autoOpen, setAutoOpen] = createSignal(pending()) - createEffect( - on(pending, (isPending, wasPending) => { - if (isPending) { - setAutoOpen(true) - } else if (wasPending && !userToggled()) { - const timer = setTimeout(() => setAutoOpen(false), 2000) - onCleanup(() => clearTimeout(timer)) - } - }), - ) + const autoOpen = hold(pending, 2000) const effectiveOpen = createMemo(() => { if (pending()) return true if (userToggled()) return userOpen() return autoOpen() }) - const subtitle = createMemo(() => { - const value = state().input?.description ?? state().metadata?.description - if (typeof value === "string") return value - return "" - }) + const expanded = createMemo(() => !pending() && !autoOpen() && userToggled() && userOpen()) + const previewOpen = createMemo(() => effectiveOpen() && !expanded()) 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 @@ -105,9 +211,10 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean } 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 [copied, setCopied] = createSignal(false) const handleHeaderClick = () => { if (pending()) return const el = headerClipRef @@ -123,16 +230,6 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean } }) } } - const handleCopy = async (e: MouseEvent) => { - e.stopPropagation() - const cmd = command() - const out = stripAnsi(output()) - const content = `$ ${cmd}${out ? "\n" + out : ""}` - if (!content) return - await navigator.clipboard.writeText(content) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } const line = createMemo(() => firstLine(command())) const fixed = createMemo(() => { const value = line() @@ -162,21 +259,6 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean } {(text) => } - - e.preventDefault()} - onClick={handleCopy} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} - /> - @@ -184,59 +266,74 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }
- row.id} - render={(row) => { - const [textRef, setTextRef] = createSignal() - createEffect(() => { - const el = textRef() - if (!el || !row.text) return - if (wiped.has(row.id)) return - wiped.add(row.id) - if (reduce()) return - 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%" - animate( - el, - { - opacity: [0, 1], - filter: ["blur(2px)", "blur(0px)"], - transform: ["translateX(-0.06em)", "translateX(0)"], - maskPosition: "0% 0%", - }, - GROW_SPRING, - ).finished.then(() => { - if (!el) return - clearFadeStyles(el) - clearMaskStyles(el) - }) - }) - return ( -
- - {row.text} - -
- ) +
+ > + row.id} + render={(row) => { + const [textRef, setTextRef] = createSignal() + createEffect(() => { + const el = textRef() + if (!el || !row.text) return + if (wiped.has(row.id)) return + wiped.add(row.id) + if (reduce()) return + 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%" + let done = false + const clear = () => { + if (done) return + done = true + clearFadeStyles(el) + clearMaskStyles(el) + } + const anim = animate( + el, + { + 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(() => { + anim.stop() + clear() + }) + }) + return ( +
+ + {row.text} + +
+ ) + }} + /> +
+ ) } diff --git a/packages/ui/src/components/tool-utils.ts b/packages/ui/src/components/tool-utils.ts index 060fffd516..973af49c30 100644 --- a/packages/ui/src/components/tool-utils.ts +++ b/packages/ui/src/components/tool-utils.ts @@ -1,9 +1,10 @@ -import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" +import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" import { animate, type AnimationPlaybackControls, clearFadeStyles, clearMaskStyles, + COLLAPSIBLE_SPRING, GROW_SPRING, WIPE_MASK, } from "./motion" @@ -76,6 +77,90 @@ export function hold(state: () => boolean, wait = 2000) { 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 +}) { + let heightAnim: AnimationPlaybackControls | undefined + let fadeAnim: AnimationPlaybackControls | undefined + + createEffect( + on(options.open, (isOpen) => { + const content = options.content() + const body = options.body() + if (!content || !body) return + heightAnim?.stop() + fadeAnim?.stop() + 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 (!options.open()) 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 + .catch(() => {}) + .then(() => { + if (!options.open()) return + const el = options.content() + if (!el) return + el.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 + .catch(() => {}) + .then(() => { + if (options.open()) return + const el = options.content() + if (!el) return + el.style.display = "none" + }) + }, { defer: true }), + ) + + onCleanup(() => { + 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)