fix(ui): fix useRowWipe stuck blur and useCollapsible race conditions

- Remove anim.stop() from useRowWipe cleanup — stopping mid-animation
  leaves WAAPI fill-forward that overrides cleared inline styles. Let
  animations run to completion; cancelAnimationFrame prevents starts.
- Add generation counter to useCollapsible to guard against stale
  microtask and promise callbacks on rapid open/close toggling.
- Use .then(ok, err) instead of .catch().then() to prevent callbacks
  firing after animation cancellation.
- Remove redundant fade constant in ShellExpanded.
- Clean up unused imports in context-tool-results.tsx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
pull/16405/head
Kit Langton 2026-03-06 16:38:49 -05:00 committed by Adam
parent 0ac8f06521
commit b47ab35ddf
No known key found for this signature in database
GPG Key ID: 9CB48779AF150E75
7 changed files with 849 additions and 378 deletions

View File

@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, For, onMount } from "solid-js"
import { 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"
@ -7,15 +7,9 @@ import { ToolCall } from "./basic-tool"
import { ToolStatusTitle } from "./tool-status-title"
import { AnimatedCountList } from "./tool-count-summary"
import { RollingResults } from "./rolling-results"
import {
animate,
clearFadeStyles,
clearMaskStyles,
GROW_SPRING,
WIPE_MASK,
} from "./motion"
import { GROW_SPRING } from "./motion"
import { useSpring } from "./motion-spring"
import { busy, updateScrollMask, useCollapsible } from "./tool-utils"
import { busy, updateScrollMask, useCollapsible, useRowWipe } from "./tool-utils"
function contextToolLabel(part: ToolPart): { action: string; detail: string } {
const state = part.state
@ -180,35 +174,11 @@ export function ContextToolRollingResults(props: { parts: ToolPart[]; pending: b
<span data-slot="context-tool-rolling-action">{label().action}</span>
{(() => {
const [detailRef, setDetailRef] = createSignal<HTMLSpanElement>()
createEffect(() => {
const el = detailRef()
const d = label().detail
if (!el || !d) return
if (wiped.has(k)) return
wiped.add(k)
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)
})
useRowWipe({
id: () => k,
text: () => label().detail,
ref: detailRef,
seen: wiped,
})
return (
<span

View File

@ -1,10 +1,20 @@
[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: 12px;
gap: 0;
}
[data-component="assistant-part-item"] {
width: 100%;
min-width: 0;
}
[data-component="user-message"] {
@ -200,7 +210,7 @@
[data-component="text-part"] {
width: 100%;
margin-top: 0;
padding-bottom: 8px;
padding-block: 4px;
position: relative;
[data-slot="text-part-body"] {
@ -218,6 +228,10 @@
}
}
[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;
@ -795,7 +809,6 @@
transition: opacity 0.15s ease;
}
.shell-rolling-copy {
border: none !important;
outline: none !important;
@ -836,6 +849,122 @@
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;

View File

@ -1,15 +1,4 @@
import {
Component,
createEffect,
createMemo,
createSignal,
For,
Match,
on,
Show,
Switch,
type JSX,
} from "solid-js"
import { Component, createEffect, createMemo, createSignal, For, Match, on, Show, Switch, type JSX } from "solid-js"
import stripAnsi from "strip-ansi"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
@ -48,9 +37,7 @@ 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 { COLLAPSIBLE_SPRING } from "./motion"
import { busy, hold, createThrottledValue, useToolFade, useContextToolPending } from "./tool-utils"
import { ContextToolGroupHeader, ContextToolExpandedList, ContextToolRollingResults } from "./context-tool-results"
import { ShellRollingResults } from "./shell-rolling-results"
@ -453,163 +440,186 @@ export function AssistantParts(props: {
const last = createMemo(() => grouped().keys.at(-1))
return (
<For each={grouped().keys}>
{(key, idx) => {
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 collapse = (
afterTool?: boolean,
groupTail?: boolean,
group?: { part: ToolPart; message: AssistantMessage }[],
) =>
shouldCollapseGroup(group?.map((item) => item.part.state.status) ?? [], {
afterTool,
groupTail,
working: props.working,
})
const value = ctx()
if (value) return groupState.read(value.groupKey, collapse(value.afterTool, value.tail, value.parts))
const entry = part()
return groupState.read(entry?.groupKey, collapse(entry?.afterTool, entry?.groupTail, entry?.groupParts))
})
const visible = createMemo(() => {
if (!context()) return true
// The context group header is always visible (it has its own expand arrow).
if (ctx()) return true
// Individual context parts are rendered inside the header's collapsible content,
// so they're always hidden at this level.
return false
})
<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 collapse = (
afterTool?: boolean,
groupTail?: boolean,
group?: { part: ToolPart; message: AssistantMessage }[],
) =>
shouldCollapseGroup(group?.map((item) => item.part.state.status) ?? [], {
afterTool,
groupTail,
working: props.working,
})
const value = ctx()
if (value) return groupState.read(value.groupKey, collapse(value.afterTool, value.tail, value.parts))
const entry = part()
return groupState.read(entry?.groupKey, collapse(entry?.afterTool, entry?.groupTail, entry?.groupParts))
})
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 ?? []
// Guard against transient empty flash during store recomputation
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)
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 ctxPendingRaw = useContextToolPending(ctxParts, () => !!(props.working && ctx()?.tail))
const ctxPending = ctxPendingRaw
const ctxHoldOpen = hold(ctxPendingRaw)
const shell = createMemo(() => {
const value = part()
if (!value) return
if (value.part.type !== "tool") return
if (value.part.tool !== "bash") return
return value.part
})
return (
<>
<PartGrow
animate={props.animate}
gap={idx() === 0 || fade() ? 0 : 8}
fade={fade()}
edge={edge()}
edgeHeight={20}
edgeOpacity={0.95}
edgeIdle={100}
edgeFade={0.6}
edgeRise={0.1}
grow
watch={watch()}
animateToggle
open={visible()}
toggleSpring={contextSpring()}
>
<Show when={ctx()}>
{(entry) => (
<ContextToolGroupHeader
parts={ctxParts()}
pending={ctxPending()}
open={contextOpen()}
onOpenChange={(value: boolean) => groupState.write(entry().groupKey, value)}
/>
)}
</Show>
<Show when={!shell() ? part() : undefined}>
{(entry) => (
<div data-component={entry().context ? "context-tool-step" : undefined}>
<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={entry().context}
animate={props.animate}
working={props.working}
/>
</div>
)}
</Show>
</PartGrow>
<Show when={ctx()}>
<ContextToolExpandedList parts={ctxParts()} expanded={!ctxPending() && contextOpen()} />
ctxPartsPrev = result
return result
})
const ctxPendingRaw = useContextToolPending(ctxParts, () => !!(props.working && ctx()?.tail))
const ctxPending = ctxPendingRaw
const ctxHoldOpen = hold(ctxPendingRaw)
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={!ctxPending() && contextOpen()} />
<ContextToolRollingResults parts={ctxParts()} pending={ctxHoldOpen()} />
</>
)}
</Show>
<Show when={shell()}>{(value) => <ShellRollingResults part={value()} animate={props.animate} />}</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>
</Show>
)}
</Show>
</div>
</Show>
<ContextToolRollingResults parts={ctxParts()} pending={ctxHoldOpen()} />
<Show when={shell()}>{(value) => <ShellRollingResults part={value()} animate={props.animate} />}</Show>
</>
)
}}
</For>
)
}}
</For>
</div>
)
}
@ -647,7 +657,6 @@ export function registerPartComponent(type: string, component: PartComponent) {
PART_MAPPING[type] = component
}
export function UserMessageDisplay(props: {
message: UserMessage
parts: PartType[]
@ -1638,7 +1647,7 @@ ToolRegistry.register({
variant="panel"
{...props}
icon="code-lines"
springContent
defer
trigger={
<div data-component="edit-trigger">
<div data-slot="message-part-title-area">
@ -1709,7 +1718,7 @@ ToolRegistry.register({
variant="panel"
{...props}
icon="code-lines"
springContent
defer
trigger={
<div data-component="write-trigger">
<div data-slot="message-part-title-area">

View File

@ -17,6 +17,7 @@ export type RollingResultsProps<T> = {
animate?: boolean
class?: string
empty?: JSX.Element
noFadeOnCollapse?: boolean
}
export function RollingResults<T>(props: RollingResultsProps<T>) {
@ -54,6 +55,7 @@ export function RollingResults<T>(props: RollingResultsProps<T>) {
})
const open = createMemo(() => props.open !== false)
const active = createMemo(() => (props.animate !== false || props.spring !== undefined) && !reducedMotion())
const noFade = () => props.noFadeOnCollapse === true
const overflowing = createMemo(() => count() > rows())
const shown = createMemo(() => Math.min(rows(), count()))
const step = createMemo(() => rowHeight() + rowGap())
@ -142,22 +144,24 @@ export function RollingResults<T>(props: RollingResultsProps<T>) {
}
// Wait for the current offset animation to settle (if any).
const done = shift?.finished ?? Promise.resolve()
done.catch(() => {}).then(() => {
if (props.scrollable !== true) return
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))
// 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()
})
// 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()
})
},
),
)
@ -239,26 +243,28 @@ export function RollingResults<T>(props: RollingResultsProps<T>) {
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, { height: `${next}px`, opacity: 0 }, spring)
? animate(view, noFade() ? { height: `${next}px` } : { height: `${next}px`, opacity: 0 }, spring)
: expanding
? animate(view, { height: `${next}px`, opacity: [0, 1] }, spring)
? 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)
if (collapsing || expanding) view!.style.opacity = ""
resize = undefined
clearEdge()
})
@ -299,7 +305,11 @@ export function RollingResults<T>(props: RollingResultsProps<T>) {
<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` }}>
<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())}>

View File

@ -549,15 +549,26 @@ function buildReadEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
const events: TimelineEvent[] = [
{ type: "part", part: readPart },
{ type: "delay", ms: 60 },
{ type: "part-update", messageID: turn.asstMsgID, partID: readPart.id, patch: { state: { status: "pending", input: { filePath }, raw: JSON.stringify({ filePath }) } } },
{
type: "part-update",
messageID: turn.asstMsgID,
partID: readPart.id,
patch: { state: { status: "pending", input: { filePath }, raw: JSON.stringify({ filePath }) } },
},
{ type: "delay", ms: 60 },
{ type: "part-update", messageID: turn.asstMsgID, partID: readPart.id, patch: toolRunning(readPart, fileName, t) },
]
return [events, {
part: readPart, turn, title: fileName, startTime: t,
completeOutput: `// contents of ${fileName}`,
completePatch: toolCompleted(readPart, fileName, `// contents of ${fileName}`, t, t + 300),
}]
return [
events,
{
part: readPart,
turn,
title: fileName,
startTime: t,
completeOutput: `// contents of ${fileName}`,
completePatch: toolCompleted(readPart, fileName, `// contents of ${fileName}`, t, t + 300),
},
]
}
// Bash output chunks — each press of `b` appends the next chunk to the running tool
@ -656,13 +667,25 @@ function buildBashStartEvents(turn: TurnState): [TimelineEvent[], RunningTool, n
const events: TimelineEvent[] = [
{ type: "part", part: shellPart },
{ type: "delay", ms: 120 },
{ type: "part-update", messageID: turn.asstMsgID, partID: shellPart.id, patch: toolRunning(shellPart, input.command, t) },
{
type: "part-update",
messageID: turn.asstMsgID,
partID: shellPart.id,
patch: toolRunning(shellPart, input.command, t),
},
]
return [
events,
{
part: shellPart,
turn,
title: input.command,
startTime: t,
completeOutput: fullOutput,
completePatch: toolCompleted(shellPart, input.command, fullOutput, t, t + 2000),
},
cmdIdx,
]
return [events, {
part: shellPart, turn, title: input.command, startTime: t,
completeOutput: fullOutput,
completePatch: toolCompleted(shellPart, input.command, fullOutput, t, t + 2000),
}, cmdIdx]
}
function buildBashChunkEvents(
@ -681,7 +704,13 @@ function buildBashChunkEvents(
messageID: turn.asstMsgID,
partID: part.id,
patch: {
state: { status: "running", input: part.state.input, title: part.state.input?.command, output: newOutput, time: { start: Date.now() } },
state: {
status: "running",
input: part.state.input,
title: part.state.input?.command,
output: newOutput,
time: { start: Date.now() },
},
},
},
]
@ -723,15 +752,26 @@ function buildGrepEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
const events: TimelineEvent[] = [
{ type: "part", part: grepPart },
{ type: "delay", ms: 60 },
{ type: "part-update", messageID: turn.asstMsgID, partID: grepPart.id, patch: { state: { status: "pending", input, raw: JSON.stringify(input) } } },
{
type: "part-update",
messageID: turn.asstMsgID,
partID: grepPart.id,
patch: { state: { status: "pending", input, raw: JSON.stringify(input) } },
},
{ type: "delay", ms: 60 },
{ type: "part-update", messageID: turn.asstMsgID, partID: grepPart.id, patch: toolRunning(grepPart, title, t) },
]
return [events, {
part: grepPart, turn, title, startTime: t,
completeOutput: "14 matches found",
completePatch: toolCompleted(grepPart, title, "14 matches found", t, t + 400),
}]
return [
events,
{
part: grepPart,
turn,
title,
startTime: t,
completeOutput: "14 matches found",
completePatch: toolCompleted(grepPart, title, "14 matches found", t, t + 400),
},
]
}
const globPatterns = ["**/*.ts", "**/*.tsx", "src/**/*.css", "packages/*/package.json", "**/*.test.ts"]
@ -745,15 +785,26 @@ function buildGlobEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
const events: TimelineEvent[] = [
{ type: "part", part: globPart },
{ type: "delay", ms: 60 },
{ type: "part-update", messageID: turn.asstMsgID, partID: globPart.id, patch: { state: { status: "pending", input, raw: JSON.stringify(input) } } },
{
type: "part-update",
messageID: turn.asstMsgID,
partID: globPart.id,
patch: { state: { status: "pending", input, raw: JSON.stringify(input) } },
},
{ type: "delay", ms: 60 },
{ type: "part-update", messageID: turn.asstMsgID, partID: globPart.id, patch: toolRunning(globPart, pattern, t) },
]
return [events, {
part: globPart, turn, title: pattern, startTime: t,
completeOutput: "23 files matched",
completePatch: toolCompleted(globPart, pattern, "23 files matched", t, t + 200),
}]
return [
events,
{
part: globPart,
turn,
title: pattern,
startTime: t,
completeOutput: "23 files matched",
completePatch: toolCompleted(globPart, pattern, "23 files matched", t, t + 200),
},
]
}
const listPaths = [
@ -773,15 +824,26 @@ function buildListEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
const events: TimelineEvent[] = [
{ type: "part", part: listPart },
{ type: "delay", ms: 60 },
{ type: "part-update", messageID: turn.asstMsgID, partID: listPart.id, patch: { state: { status: "pending", input, raw: JSON.stringify(input) } } },
{
type: "part-update",
messageID: turn.asstMsgID,
partID: listPart.id,
patch: { state: { status: "pending", input, raw: JSON.stringify(input) } },
},
{ type: "delay", ms: 60 },
{ type: "part-update", messageID: turn.asstMsgID, partID: listPart.id, patch: toolRunning(listPart, dirName, t) },
]
return [events, {
part: listPart, turn, title: dirName, startTime: t,
completeOutput: "12 entries",
completePatch: toolCompleted(listPart, dirName, "12 entries", t, t + 150),
}]
return [
events,
{
part: listPart,
turn,
title: dirName,
startTime: t,
completeOutput: "12 entries",
completePatch: toolCompleted(listPart, dirName, "12 entries", t, t + 150),
},
]
}
const fetchUrls = [
@ -801,15 +863,26 @@ function buildWebFetchEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
const events: TimelineEvent[] = [
{ type: "part", part: fetchPart },
{ type: "delay", ms: 60 },
{ type: "part-update", messageID: turn.asstMsgID, partID: fetchPart.id, patch: { state: { status: "pending", input, raw: JSON.stringify(input) } } },
{
type: "part-update",
messageID: turn.asstMsgID,
partID: fetchPart.id,
patch: { state: { status: "pending", input, raw: JSON.stringify(input) } },
},
{ type: "delay", ms: 80 },
{ type: "part-update", messageID: turn.asstMsgID, partID: fetchPart.id, patch: toolRunning(fetchPart, url, t) },
]
return [events, {
part: fetchPart, turn, title: url, startTime: t,
completeOutput: "Fetched 24.3 KB",
completePatch: toolCompleted(fetchPart, url, "Fetched 24.3 KB", t, t + 1200),
}]
return [
events,
{
part: fetchPart,
turn,
title: url,
startTime: t,
completeOutput: "Fetched 24.3 KB",
completePatch: toolCompleted(fetchPart, url, "Fetched 24.3 KB", t, t + 1200),
},
]
}
function buildEditEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
@ -831,8 +904,17 @@ function buildEditEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
{ type: "part", part: editPart },
{ type: "delay", ms: 100 },
{
type: "part-update", messageID: turn.asstMsgID, partID: editPart.id, patch: {
state: { status: "running", input: editPart.state.input, title: "bash.ts", metadata: { filediff, diagnostics: {} }, time: { start: t } },
type: "part-update",
messageID: turn.asstMsgID,
partID: editPart.id,
patch: {
state: {
status: "running",
input: editPart.state.input,
title: "bash.ts",
metadata: { filediff, diagnostics: {} },
time: { start: t },
},
},
},
]
@ -845,11 +927,134 @@ function buildEditEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
time: { start: t, end: t + 300 },
},
}
return [events, {
part: editPart, turn, title: "bash.ts", startTime: t,
completeOutput: "",
completePatch,
}]
return [
events,
{
part: editPart,
turn,
title: "bash.ts",
startTime: t,
completeOutput: "",
completePatch,
},
]
}
function buildWriteEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
const t = Date.now()
const writeInput = {
filePath: "/Users/kit/project/packages/opencode/src/util/helpers.ts",
content: `export function sanitize(cmd: string): string {\n return cmd.replace(/[;&|]/g, "")\n}\n`,
}
const writePart = mkTool(turn.asstMsgID, "write", writeInput)
const events: TimelineEvent[] = [
{ type: "part", part: writePart },
{ type: "delay", ms: 100 },
{
type: "part-update",
messageID: turn.asstMsgID,
partID: writePart.id,
patch: {
state: {
status: "running",
input: writePart.state.input,
title: "helpers.ts",
metadata: {},
time: { start: t },
},
},
},
]
const completePatch = {
state: {
status: "completed",
input: writeInput,
title: "Created helpers.ts",
metadata: {},
time: { start: t, end: t + 300 },
},
}
return [
events,
{
part: writePart,
turn,
title: "helpers.ts",
startTime: t,
completeOutput: "",
completePatch,
},
]
}
function buildApplyPatchEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
const t = Date.now()
const patchInput = {
patch: `--- a/packages/opencode/src/tool/bash.ts\n+++ b/packages/opencode/src/tool/bash.ts\n@@ -1,3 +1,4 @@\n+import { sanitize } from "../util/helpers"\n const cmd = input.command\n const result = await run(cmd)\n return result\n--- a/packages/opencode/src/util/helpers.ts\n+++ b/packages/opencode/src/util/helpers.ts\n@@ -1,3 +1,5 @@\n export function sanitize(cmd: string): string {\n- return cmd.replace(/[;&|]/g, "")\n+ return cmd\n+ .replace(/[;&|]/g, "")\n+ .trim()\n }\n`,
}
const patchPart = mkTool(turn.asstMsgID, "apply_patch", patchInput)
const files = [
{
filePath: "/Users/kit/project/packages/opencode/src/tool/bash.ts",
relativePath: "packages/opencode/src/tool/bash.ts",
type: "update",
diff: "",
before: "const cmd = input.command\nconst result = await run(cmd)\nreturn result",
after:
'import { sanitize } from "../util/helpers"\nconst cmd = input.command\nconst result = await run(cmd)\nreturn result',
additions: 1,
deletions: 0,
},
{
filePath: "/Users/kit/project/packages/opencode/src/util/helpers.ts",
relativePath: "packages/opencode/src/util/helpers.ts",
type: "update",
diff: "",
before: 'export function sanitize(cmd: string): string {\n return cmd.replace(/[;&|]/g, "")\n}',
after:
'export function sanitize(cmd: string): string {\n return cmd\n .replace(/[;&|]/g, "")\n .trim()\n}',
additions: 3,
deletions: 1,
},
]
const events: TimelineEvent[] = [
{ type: "part", part: patchPart },
{ type: "delay", ms: 100 },
{
type: "part-update",
messageID: turn.asstMsgID,
partID: patchPart.id,
patch: {
state: {
status: "running",
input: patchPart.state.input,
title: "2 files",
metadata: { files },
time: { start: t },
},
},
},
]
const completePatch = {
state: {
status: "completed",
input: patchInput,
title: "Applied patch to 2 files",
metadata: { files },
time: { start: t, end: t + 500 },
},
}
return [
events,
{
part: patchPart,
turn,
title: "2 files",
startTime: t,
completeOutput: "",
completePatch,
},
]
}
function buildErrorEvents(turn: TurnState): TimelineEvent[] {
@ -859,7 +1064,12 @@ function buildErrorEvents(turn: TurnState): TimelineEvent[] {
return [
{ type: "part", part: errPart },
{ type: "delay", ms: 100 },
{ type: "part-update", messageID: turn.asstMsgID, partID: errPart.id, patch: toolRunning(errPart, input.command, t) },
{
type: "part-update",
messageID: turn.asstMsgID,
partID: errPart.id,
patch: toolRunning(errPart, input.command, t),
},
{ type: "delay", ms: 200 },
{
type: "part-update",
@ -941,7 +1151,8 @@ function SessionTimelineSimulator() {
const [runningTool, setRunningTool] = createSignal<RunningTool | null>(null)
// Bash streaming state — tracks the current bash tool being streamed into
let bashState: { cmdIdx: number; chunkIdx: number; currentOutput: string; part: ToolPart; turn: TurnState } | null = null
let bashState: { cmdIdx: number; chunkIdx: number; currentOutput: string; part: ToolPart; turn: TurnState } | null =
null
function startNewTurn() {
turnCounter++
@ -1009,6 +1220,71 @@ function SessionTimelineSimulator() {
triggerTool(builder)
}
function flow(turn: TurnState, build: (turn: TurnState) => [TimelineEvent[], RunningTool]) {
const [evts, run] = build(turn)
return [
...evts,
{ type: "delay", ms: 120 },
{ type: "part-update", messageID: turn.asstMsgID, partID: run.part.id, patch: run.completePatch },
{ type: "delay", ms: 80 },
]
}
function shell(turn: TurnState) {
const [evts, run, idx] = buildBashStartEvents(turn)
const [a, out] = buildBashChunkEvents(turn, run.part, idx, 0, "")
const [b] = buildBashChunkEvents(turn, run.part, idx, 1, out)
return [
...evts,
{ type: "delay", ms: 120 },
...a,
{ type: "delay", ms: 80 },
...b,
{ type: "delay", ms: 80 },
{ type: "part-update", messageID: turn.asstMsgID, partID: run.part.id, patch: run.completePatch },
{ type: "delay", ms: 100 },
]
}
function pattern() {
const prev = currentTurn()
const turn = startNewTurn()
const prompt = "Can you run one pass with every tool so I can preview the full timeline UI?"
const evts: TimelineEvent[] = [...drainRunning()]
if (prev) {
evts.push(
{ type: "message", message: mkAssistant(prev.asstMsgID, prev.userMsgID, Date.now()) },
{ type: "status", status: { type: "idle" } },
{ type: "delay", ms: 80 },
)
}
evts.push(
{ type: "status", status: { type: "busy" } },
{ type: "message", message: mkUser(turn.userMsgID) },
{ type: "part", part: mkText(turn.userMsgID, prompt) },
{ type: "delay", ms: 120 },
{ type: "message", message: mkAssistant(turn.asstMsgID, turn.userMsgID) },
{ type: "delay", ms: 100 },
...flow(turn, buildReadEvents),
...flow(turn, buildGrepEvents),
...flow(turn, buildGlobEvents),
...flow(turn, buildListEvents),
...shell(turn),
...flow(turn, buildWebFetchEvents),
...flow(turn, buildEditEvents),
...flow(turn, buildWriteEvents),
...flow(turn, buildApplyPatchEvents),
...buildTextEvents(turn),
{ type: "delay", ms: 120 },
{ type: "message", message: mkAssistant(turn.asstMsgID, turn.userMsgID, Date.now()) },
{ type: "status", status: { type: "idle" } },
)
setCurrentTurn(null)
setRunningTool(null)
bashState = null
pb.appendAndPlay(evts)
}
function completeTurn() {
const turn = currentTurn()
if (!turn) return
@ -1042,15 +1318,22 @@ function SessionTimelineSimulator() {
// --- Flat action list ---
const actions: Action[] = [
{ key: "p", label: "Pattern", handler: () => pattern() },
{ key: "e", label: "Explore", handler: () => triggerExplore() },
{
key: "b", label: "Bash", handler: () => {
key: "b",
label: "Bash",
handler: () => {
if (bashState) {
// Already streaming — append next chunk
const chunks = bashOutputChunks[bashState.cmdIdx]
if (bashState.chunkIdx < chunks.length) {
const [chunkEvents, newOutput] = buildBashChunkEvents(
bashState.turn, bashState.part, bashState.cmdIdx, bashState.chunkIdx, bashState.currentOutput,
bashState.turn,
bashState.part,
bashState.cmdIdx,
bashState.chunkIdx,
bashState.currentOutput,
)
bashState.chunkIdx++
bashState.currentOutput = newOutput
@ -1075,23 +1358,40 @@ function SessionTimelineSimulator() {
},
},
{
key: "t", label: "Text", handler: () => {
key: "t",
label: "Text",
handler: () => {
const drain = drainRunning()
const turn = ensureTurn()
pb.appendAndPlay([...drain, ...buildTextEvents(turn)])
},
},
{ key: "d", label: "Edit", handler: () => triggerTool(buildEditEvents) },
{
key: "d",
label: "Edit/Write/Patch",
handler: (() => {
const builders = [buildEditEvents, buildWriteEvents, buildApplyPatchEvents]
let idx = 0
return () => {
triggerTool(builders[idx % builders.length]!)
idx++
}
})(),
},
{ key: "w", label: "WebFetch", handler: () => triggerTool(buildWebFetchEvents) },
{
key: "x", label: "Error", handler: () => {
key: "x",
label: "Error",
handler: () => {
const drain = drainRunning()
const turn = ensureTurn()
pb.appendAndPlay([...drain, ...buildErrorEvents(turn)])
},
},
{
key: "u", label: "User", handler: () => {
key: "u",
label: "User",
handler: () => {
const prev = currentTurn()
const drain = drainRunning()
// Complete previous turn if needed
@ -1306,7 +1606,9 @@ function SessionTimelineSimulator() {
{/* Speed */}
<div style={{ display: "flex", "align-items": "center", gap: "4px", "flex-shrink": "0" }}>
<span style={{ "font-size": "var(--font-size-small)", color: "var(--text-weak)", "margin-right": "2px" }}>Speed</span>
<span style={{ "font-size": "var(--font-size-small)", color: "var(--text-weak)", "margin-right": "2px" }}>
Speed
</span>
<For each={[0.25, 0.5, 1, 2, 4]}>
{(s) => (
<button
@ -1316,8 +1618,7 @@ function SessionTimelineSimulator() {
"font-size": "var(--font-size-small)",
"font-family": "var(--font-family-mono)",
"border-radius": "4px",
border:
"1px solid " + (pb.speed() === s ? "var(--color-blue, #3b82f6)" : "var(--border-base)"),
border: "1px solid " + (pb.speed() === s ? "var(--color-blue, #3b82f6)" : "var(--border-base)"),
background: pb.speed() === s ? "var(--color-blue, #3b82f6)" : "transparent",
color: pb.speed() === s ? "white" : "var(--text-base)",
cursor: "pointer",
@ -1333,9 +1634,7 @@ function SessionTimelineSimulator() {
{/* Trigger buttons */}
<div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap" }}>
<For each={actions}>
{(action) => (
<TriggerBtn key={action.key} label={action.label} onClick={action.handler} />
)}
{(action) => <TriggerBtn key={action.key} label={action.label} onClick={action.handler} />}
</For>
</div>
@ -1371,10 +1670,11 @@ Flat control panel — each action auto-completes the previous running tool.
| Key | Action |
|-----|--------|
| p | Full pattern (user + every tool + text + completion) |
| e | Explore (random read/grep/glob/list, stays running) |
| b | Bash tool (keep pressing to stream output, other key completes) |
| t | Stream text |
| d | Edit tool (stays running) |
| d | Edit/Write/Patch (cycles, stays running) |
| x | Error tool |
| u | New user turn |
| c | Complete assistant turn |

View File

@ -8,15 +8,17 @@ import { Icon } from "./icon"
import { IconButton } from "./icon-button"
import { TextShimmer } from "./text-shimmer"
import { Tooltip } from "./tooltip"
import {
animate,
clearFadeStyles,
clearMaskStyles,
GROW_SPRING,
WIPE_MASK,
} from "./motion"
import { GROW_SPRING } from "./motion"
import { useSpring } from "./motion-spring"
import { busy, createThrottledValue, hold, updateScrollMask, useCollapsible, useToolFade } from "./tool-utils"
import {
busy,
createThrottledValue,
hold,
updateScrollMask,
useCollapsible,
useRowWipe,
useToolFade,
} from "./tool-utils"
function ShellRollingSubtitle(props: { text: string; animate?: boolean }) {
let ref: HTMLSpanElement | undefined
@ -65,7 +67,6 @@ 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
@ -78,7 +79,7 @@ function ShellExpanded(props: { cmd: string; out: string; open: boolean }) {
const [cap, setCap] = createSignal(max)
const updateMask = () => {
if (scrollRef) updateScrollMask(scrollRef, fade)
if (scrollRef) updateScrollMask(scrollRef)
}
const resize = () => {
@ -286,42 +287,11 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }
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()
})
useRowWipe({
id: () => row.id,
text: () => row.text,
ref: textRef,
seen: wiped,
})
return (
<div data-component="shell-rolling-row">

View File

@ -108,54 +108,59 @@ export function useCollapsible(options: {
}) {
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 (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
}
on(
options.open,
(isOpen) => {
const content = options.content()
const body = options.body()
if (!content || !body) return
heightAnim?.stop()
fadeAnim?.stop()
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
.catch(() => {})
.then(() => {
if (options.open()) return
const el = options.content()
if (!el) return
el.style.display = "none"
})
}, { defer: true }),
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"
},
() => {},
)
},
{ defer: true },
),
)
onCleanup(() => {
++gen
heightAnim?.stop()
fadeAnim?.stop()
})
@ -170,6 +175,84 @@ export function useContextToolPending(parts: () => ToolPart[], working?: () => b
return createMemo(() => !settled() && (!!working?.() || anyRunning()))
}
export function useRowWipe(opts: {
id: () => string
text: () => string | undefined
ref: () => HTMLElement | undefined
seen: Set<string>
}) {
const reduce = prefersReducedMotion
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)
})
})
}
export function useToolFade(
ref: () => HTMLElement | undefined,
options?: { delay?: number; wipe?: boolean; animate?: boolean },