refactor(ui): extract shared collapsible, scroll mask, and hold utilities

- Replace manual autoOpen signal+effect with hold(pending, 2000)
- Extract updateScrollMask() shared by ShellExpanded and ContextToolExpandedList
- Extract useCollapsible() hook for the shared expand/collapse animation pattern

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kit Langton 2026-03-06 16:20:19 -05:00 committed by Adam
commit 0ac8f06521
No known key found for this signature in database
GPG key ID: 9CB48779AF150E75
3 changed files with 292 additions and 171 deletions

View file

@ -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 (

View file

@ -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 (
<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 }) {
const i18n = useI18n()
const wiped = new Set<string>()
@ -66,36 +184,24 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }
onMount(() => setMounted(true))
const state = createMemo(() => props.part.state as Record<string, any>)
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 }
<Show when={subtitle()}>{(text) => <ShellRollingSubtitle text={text()} animate={props.animate} />}</Show>
<Show when={!pending()}>
<span data-slot="shell-rolling-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-rolling-copy"
onMouseDown={(e: MouseEvent) => e.preventDefault()}
onClick={handleCopy}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
/>
</Tooltip>
<span data-slot="shell-rolling-arrow" data-open={effectiveOpen() ? "true" : "false"}>
<Icon name="chevron-down" size="small" />
</span>
@ -184,59 +266,74 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }
</Show>
</div>
</div>
<RollingResults
class="shell-rolling-output"
items={rows()}
fixed={fixed()}
fixedHeight={22}
rows={userToggled() && userOpen() ? 10 : 5}
rowHeight={22}
rowGap={0}
open={effectiveOpen()}
scrollable={!pending() && !autoOpen() && userToggled() && userOpen()}
spring={userToggled() ? FAST_SPRING : undefined}
animate={props.animate !== false}
getKey={(row) => row.id}
render={(row) => {
const [textRef, setTextRef] = createSignal<HTMLSpanElement>()
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 (
<div data-component="shell-rolling-row">
<span ref={setTextRef} data-slot="shell-rolling-text">
{row.text}
</span>
</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"
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>()
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 (
<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>
)
}

View file

@ -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)