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>
This commit is contained in:
Kit Langton 2026-03-06 16:38:49 -05:00 committed by Adam
commit b47ab35ddf
No known key found for this signature in database
GPG key ID: 9CB48779AF150E75
7 changed files with 850 additions and 379 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 type { ToolPart } from "@opencode-ai/sdk/v2"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { useI18n } from "../context/i18n" import { useI18n } from "../context/i18n"
@ -7,15 +7,9 @@ import { ToolCall } from "./basic-tool"
import { ToolStatusTitle } from "./tool-status-title" import { ToolStatusTitle } from "./tool-status-title"
import { AnimatedCountList } from "./tool-count-summary" import { AnimatedCountList } from "./tool-count-summary"
import { RollingResults } from "./rolling-results" import { RollingResults } from "./rolling-results"
import { import { GROW_SPRING } from "./motion"
animate,
clearFadeStyles,
clearMaskStyles,
GROW_SPRING,
WIPE_MASK,
} from "./motion"
import { useSpring } from "./motion-spring" 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 } { function contextToolLabel(part: ToolPart): { action: string; detail: string } {
const state = part.state 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> <span data-slot="context-tool-rolling-action">{label().action}</span>
{(() => { {(() => {
const [detailRef, setDetailRef] = createSignal<HTMLSpanElement>() const [detailRef, setDetailRef] = createSignal<HTMLSpanElement>()
createEffect(() => { useRowWipe({
const el = detailRef() id: () => k,
const d = label().detail text: () => label().detail,
if (!el || !d) return ref: detailRef,
if (wiped.has(k)) return seen: wiped,
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)
})
}) })
return ( return (
<span <span

View file

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

View file

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

View file

@ -17,6 +17,7 @@ export type RollingResultsProps<T> = {
animate?: boolean animate?: boolean
class?: string class?: string
empty?: JSX.Element empty?: JSX.Element
noFadeOnCollapse?: boolean
} }
export function RollingResults<T>(props: RollingResultsProps<T>) { 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 open = createMemo(() => props.open !== false)
const active = createMemo(() => (props.animate !== false || props.spring !== undefined) && !reducedMotion()) const active = createMemo(() => (props.animate !== false || props.spring !== undefined) && !reducedMotion())
const noFade = () => props.noFadeOnCollapse === true
const overflowing = createMemo(() => count() > rows()) const overflowing = createMemo(() => count() > rows())
const shown = createMemo(() => Math.min(rows(), count())) const shown = createMemo(() => Math.min(rows(), count()))
const step = createMemo(() => rowHeight() + rowGap()) 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). // Wait for the current offset animation to settle (if any).
const done = shift?.finished ?? Promise.resolve() const done = shift?.finished ?? Promise.resolve()
done.catch(() => {}).then(() => { done
if (props.scrollable !== true) return .catch(() => {})
.then(() => {
if (props.scrollable !== true) return
// Batch the signal update — Solid updates the DOM synchronously: // Batch the signal update — Solid updates the DOM synchronously:
// rendered() returns all items, skipped() returns 0, padding-top removed, // rendered() returns all items, skipped() returns 0, padding-top removed,
// data-scrollable becomes "true". // data-scrollable becomes "true".
batch(() => setScrollReady(true)) batch(() => setScrollReady(true))
// Now the DOM has all items. Safe to switch layout strategy. // Now the DOM has all items. Safe to switch layout strategy.
// CSS handles `transform: none !important` on [data-scrollable="true"]. // CSS handles `transform: none !important` on [data-scrollable="true"].
if (windowEl) { if (windowEl) {
windowEl.style.overflowY = "auto" windowEl.style.overflowY = "auto"
windowEl.scrollTop = windowEl.scrollHeight windowEl.scrollTop = windowEl.scrollHeight
} }
updateScrollMask() updateScrollMask()
}) })
}, },
), ),
) )
@ -239,26 +243,28 @@ export function RollingResults<T>(props: RollingResultsProps<T>) {
resize?.stop() resize?.stop()
resize = undefined resize = undefined
setView(next) setView(next)
view.style.opacity = ""
clearEdge() clearEdge()
return return
} }
const collapsing = next === 0 && prev !== undefined && prev > 0 const collapsing = next === 0 && prev !== undefined && prev > 0
const expanding = prev === 0 && next > 0 const expanding = prev === 0 && next > 0
resize?.stop() resize?.stop()
view.style.opacity = ""
applyEdge() applyEdge()
const spring = props.spring ?? GROW_SPRING const spring = props.spring ?? GROW_SPRING
const anim = collapsing const anim = collapsing
? animate(view, { height: `${next}px`, opacity: 0 }, spring) ? animate(view, noFade() ? { height: `${next}px` } : { height: `${next}px`, opacity: 0 }, spring)
: expanding : 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) : animate(view, { height: `${next}px` }, spring)
resize = anim resize = anim
anim.finished anim.finished
.catch(() => {}) .catch(() => {})
.finally(() => { .finally(() => {
view.style.opacity = ""
if (resize !== anim) return if (resize !== anim) return
setView(next) setView(next)
if (collapsing || expanding) view!.style.opacity = ""
resize = undefined resize = undefined
clearEdge() clearEdge()
}) })
@ -299,7 +305,11 @@ export function RollingResults<T>(props: RollingResultsProps<T>) {
<Show when={list().length === 0 && props.empty !== undefined}> <Show when={list().length === 0 && props.empty !== undefined}>
<div data-slot="rolling-results-empty">{props.empty}</div> <div data-slot="rolling-results-empty">{props.empty}</div>
</Show> </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()}> <For each={rendered()}>
{(item, index) => ( {(item, index) => (
<div data-slot="rolling-results-row" data-key={key(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[] = [ const events: TimelineEvent[] = [
{ type: "part", part: readPart }, { type: "part", part: readPart },
{ type: "delay", ms: 60 }, { 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: "delay", ms: 60 },
{ type: "part-update", messageID: turn.asstMsgID, partID: readPart.id, patch: toolRunning(readPart, fileName, t) }, { type: "part-update", messageID: turn.asstMsgID, partID: readPart.id, patch: toolRunning(readPart, fileName, t) },
] ]
return [events, { return [
part: readPart, turn, title: fileName, startTime: t, events,
completeOutput: `// contents of ${fileName}`, {
completePatch: toolCompleted(readPart, fileName, `// contents of ${fileName}`, t, t + 300), 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 // 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[] = [ const events: TimelineEvent[] = [
{ type: "part", part: shellPart }, { type: "part", part: shellPart },
{ type: "delay", ms: 120 }, { 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( function buildBashChunkEvents(
@ -681,7 +704,13 @@ function buildBashChunkEvents(
messageID: turn.asstMsgID, messageID: turn.asstMsgID,
partID: part.id, partID: part.id,
patch: { 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[] = [ const events: TimelineEvent[] = [
{ type: "part", part: grepPart }, { type: "part", part: grepPart },
{ type: "delay", ms: 60 }, { 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: "delay", ms: 60 },
{ type: "part-update", messageID: turn.asstMsgID, partID: grepPart.id, patch: toolRunning(grepPart, title, t) }, { type: "part-update", messageID: turn.asstMsgID, partID: grepPart.id, patch: toolRunning(grepPart, title, t) },
] ]
return [events, { return [
part: grepPart, turn, title, startTime: t, events,
completeOutput: "14 matches found", {
completePatch: toolCompleted(grepPart, title, "14 matches found", t, t + 400), 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"] const globPatterns = ["**/*.ts", "**/*.tsx", "src/**/*.css", "packages/*/package.json", "**/*.test.ts"]
@ -745,15 +785,26 @@ function buildGlobEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
const events: TimelineEvent[] = [ const events: TimelineEvent[] = [
{ type: "part", part: globPart }, { type: "part", part: globPart },
{ type: "delay", ms: 60 }, { 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: "delay", ms: 60 },
{ type: "part-update", messageID: turn.asstMsgID, partID: globPart.id, patch: toolRunning(globPart, pattern, t) }, { type: "part-update", messageID: turn.asstMsgID, partID: globPart.id, patch: toolRunning(globPart, pattern, t) },
] ]
return [events, { return [
part: globPart, turn, title: pattern, startTime: t, events,
completeOutput: "23 files matched", {
completePatch: toolCompleted(globPart, pattern, "23 files matched", t, t + 200), part: globPart,
}] turn,
title: pattern,
startTime: t,
completeOutput: "23 files matched",
completePatch: toolCompleted(globPart, pattern, "23 files matched", t, t + 200),
},
]
} }
const listPaths = [ const listPaths = [
@ -773,15 +824,26 @@ function buildListEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
const events: TimelineEvent[] = [ const events: TimelineEvent[] = [
{ type: "part", part: listPart }, { type: "part", part: listPart },
{ type: "delay", ms: 60 }, { 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: "delay", ms: 60 },
{ type: "part-update", messageID: turn.asstMsgID, partID: listPart.id, patch: toolRunning(listPart, dirName, t) }, { type: "part-update", messageID: turn.asstMsgID, partID: listPart.id, patch: toolRunning(listPart, dirName, t) },
] ]
return [events, { return [
part: listPart, turn, title: dirName, startTime: t, events,
completeOutput: "12 entries", {
completePatch: toolCompleted(listPart, dirName, "12 entries", t, t + 150), part: listPart,
}] turn,
title: dirName,
startTime: t,
completeOutput: "12 entries",
completePatch: toolCompleted(listPart, dirName, "12 entries", t, t + 150),
},
]
} }
const fetchUrls = [ const fetchUrls = [
@ -801,15 +863,26 @@ function buildWebFetchEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
const events: TimelineEvent[] = [ const events: TimelineEvent[] = [
{ type: "part", part: fetchPart }, { type: "part", part: fetchPart },
{ type: "delay", ms: 60 }, { 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: "delay", ms: 80 },
{ type: "part-update", messageID: turn.asstMsgID, partID: fetchPart.id, patch: toolRunning(fetchPart, url, t) }, { type: "part-update", messageID: turn.asstMsgID, partID: fetchPart.id, patch: toolRunning(fetchPart, url, t) },
] ]
return [events, { return [
part: fetchPart, turn, title: url, startTime: t, events,
completeOutput: "Fetched 24.3 KB", {
completePatch: toolCompleted(fetchPart, url, "Fetched 24.3 KB", t, t + 1200), 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] { function buildEditEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
@ -831,8 +904,17 @@ function buildEditEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
{ type: "part", part: editPart }, { type: "part", part: editPart },
{ type: "delay", ms: 100 }, { type: "delay", ms: 100 },
{ {
type: "part-update", messageID: turn.asstMsgID, partID: editPart.id, patch: { type: "part-update",
state: { status: "running", input: editPart.state.input, title: "bash.ts", metadata: { filediff, diagnostics: {} }, time: { start: t } }, 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 }, time: { start: t, end: t + 300 },
}, },
} }
return [events, { return [
part: editPart, turn, title: "bash.ts", startTime: t, events,
completeOutput: "", {
completePatch, 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[] { function buildErrorEvents(turn: TurnState): TimelineEvent[] {
@ -859,7 +1064,12 @@ function buildErrorEvents(turn: TurnState): TimelineEvent[] {
return [ return [
{ type: "part", part: errPart }, { type: "part", part: errPart },
{ type: "delay", ms: 100 }, { 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: "delay", ms: 200 },
{ {
type: "part-update", type: "part-update",
@ -941,7 +1151,8 @@ function SessionTimelineSimulator() {
const [runningTool, setRunningTool] = createSignal<RunningTool | null>(null) const [runningTool, setRunningTool] = createSignal<RunningTool | null>(null)
// Bash streaming state — tracks the current bash tool being streamed into // 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() { function startNewTurn() {
turnCounter++ turnCounter++
@ -1009,6 +1220,71 @@ function SessionTimelineSimulator() {
triggerTool(builder) 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() { function completeTurn() {
const turn = currentTurn() const turn = currentTurn()
if (!turn) return if (!turn) return
@ -1042,15 +1318,22 @@ function SessionTimelineSimulator() {
// --- Flat action list --- // --- Flat action list ---
const actions: Action[] = [ const actions: Action[] = [
{ key: "p", label: "Pattern", handler: () => pattern() },
{ key: "e", label: "Explore", handler: () => triggerExplore() }, { key: "e", label: "Explore", handler: () => triggerExplore() },
{ {
key: "b", label: "Bash", handler: () => { key: "b",
label: "Bash",
handler: () => {
if (bashState) { if (bashState) {
// Already streaming — append next chunk // Already streaming — append next chunk
const chunks = bashOutputChunks[bashState.cmdIdx] const chunks = bashOutputChunks[bashState.cmdIdx]
if (bashState.chunkIdx < chunks.length) { if (bashState.chunkIdx < chunks.length) {
const [chunkEvents, newOutput] = buildBashChunkEvents( 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.chunkIdx++
bashState.currentOutput = newOutput bashState.currentOutput = newOutput
@ -1075,23 +1358,40 @@ function SessionTimelineSimulator() {
}, },
}, },
{ {
key: "t", label: "Text", handler: () => { key: "t",
label: "Text",
handler: () => {
const drain = drainRunning() const drain = drainRunning()
const turn = ensureTurn() const turn = ensureTurn()
pb.appendAndPlay([...drain, ...buildTextEvents(turn)]) 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: "w", label: "WebFetch", handler: () => triggerTool(buildWebFetchEvents) },
{ {
key: "x", label: "Error", handler: () => { key: "x",
label: "Error",
handler: () => {
const drain = drainRunning() const drain = drainRunning()
const turn = ensureTurn() const turn = ensureTurn()
pb.appendAndPlay([...drain, ...buildErrorEvents(turn)]) pb.appendAndPlay([...drain, ...buildErrorEvents(turn)])
}, },
}, },
{ {
key: "u", label: "User", handler: () => { key: "u",
label: "User",
handler: () => {
const prev = currentTurn() const prev = currentTurn()
const drain = drainRunning() const drain = drainRunning()
// Complete previous turn if needed // Complete previous turn if needed
@ -1306,7 +1606,9 @@ function SessionTimelineSimulator() {
{/* Speed */} {/* Speed */}
<div style={{ display: "flex", "align-items": "center", gap: "4px", "flex-shrink": "0" }}> <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]}> <For each={[0.25, 0.5, 1, 2, 4]}>
{(s) => ( {(s) => (
<button <button
@ -1316,8 +1618,7 @@ function SessionTimelineSimulator() {
"font-size": "var(--font-size-small)", "font-size": "var(--font-size-small)",
"font-family": "var(--font-family-mono)", "font-family": "var(--font-family-mono)",
"border-radius": "4px", "border-radius": "4px",
border: border: "1px solid " + (pb.speed() === s ? "var(--color-blue, #3b82f6)" : "var(--border-base)"),
"1px solid " + (pb.speed() === s ? "var(--color-blue, #3b82f6)" : "var(--border-base)"),
background: pb.speed() === s ? "var(--color-blue, #3b82f6)" : "transparent", background: pb.speed() === s ? "var(--color-blue, #3b82f6)" : "transparent",
color: pb.speed() === s ? "white" : "var(--text-base)", color: pb.speed() === s ? "white" : "var(--text-base)",
cursor: "pointer", cursor: "pointer",
@ -1333,9 +1634,7 @@ function SessionTimelineSimulator() {
{/* Trigger buttons */} {/* Trigger buttons */}
<div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap" }}> <div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap" }}>
<For each={actions}> <For each={actions}>
{(action) => ( {(action) => <TriggerBtn key={action.key} label={action.label} onClick={action.handler} />}
<TriggerBtn key={action.key} label={action.label} onClick={action.handler} />
)}
</For> </For>
</div> </div>
@ -1371,10 +1670,11 @@ Flat control panel — each action auto-completes the previous running tool.
| Key | Action | | Key | Action |
|-----|--------| |-----|--------|
| p | Full pattern (user + every tool + text + completion) |
| e | Explore (random read/grep/glob/list, stays running) | | e | Explore (random read/grep/glob/list, stays running) |
| b | Bash tool (keep pressing to stream output, other key completes) | | b | Bash tool (keep pressing to stream output, other key completes) |
| t | Stream text | | t | Stream text |
| d | Edit tool (stays running) | | d | Edit/Write/Patch (cycles, stays running) |
| x | Error tool | | x | Error tool |
| u | New user turn | | u | New user turn |
| c | Complete assistant turn | | c | Complete assistant turn |

View file

@ -8,15 +8,17 @@ import { Icon } from "./icon"
import { IconButton } from "./icon-button" import { IconButton } from "./icon-button"
import { TextShimmer } from "./text-shimmer" import { TextShimmer } from "./text-shimmer"
import { Tooltip } from "./tooltip" import { Tooltip } from "./tooltip"
import { import { GROW_SPRING } from "./motion"
animate,
clearFadeStyles,
clearMaskStyles,
GROW_SPRING,
WIPE_MASK,
} from "./motion"
import { useSpring } from "./motion-spring" 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 }) { function ShellRollingSubtitle(props: { text: string; animate?: boolean }) {
let ref: HTMLSpanElement | undefined let ref: HTMLSpanElement | undefined
@ -65,7 +67,6 @@ function ShellRollingCommand(props: { text: string; animate?: boolean }) {
function ShellExpanded(props: { cmd: string; out: string; open: boolean }) { function ShellExpanded(props: { cmd: string; out: string; open: boolean }) {
const i18n = useI18n() const i18n = useI18n()
const fade = 12
const rows = 10 const rows = 10
const rowHeight = 22 const rowHeight = 22
const max = rows * rowHeight const max = rows * rowHeight
@ -78,7 +79,7 @@ function ShellExpanded(props: { cmd: string; out: string; open: boolean }) {
const [cap, setCap] = createSignal(max) const [cap, setCap] = createSignal(max)
const updateMask = () => { const updateMask = () => {
if (scrollRef) updateScrollMask(scrollRef, fade) if (scrollRef) updateScrollMask(scrollRef)
} }
const resize = () => { const resize = () => {
@ -286,42 +287,11 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }
getKey={(row) => row.id} getKey={(row) => row.id}
render={(row) => { render={(row) => {
const [textRef, setTextRef] = createSignal<HTMLSpanElement>() const [textRef, setTextRef] = createSignal<HTMLSpanElement>()
createEffect(() => { useRowWipe({
const el = textRef() id: () => row.id,
if (!el || !row.text) return text: () => row.text,
if (wiped.has(row.id)) return ref: textRef,
wiped.add(row.id) seen: wiped,
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 ( return (
<div data-component="shell-rolling-row"> <div data-component="shell-rolling-row">

View file

@ -108,54 +108,59 @@ export function useCollapsible(options: {
}) { }) {
let heightAnim: AnimationPlaybackControls | undefined let heightAnim: AnimationPlaybackControls | undefined
let fadeAnim: AnimationPlaybackControls | undefined let fadeAnim: AnimationPlaybackControls | undefined
let gen = 0
createEffect( createEffect(
on(options.open, (isOpen) => { on(
const content = options.content() options.open,
const body = options.body() (isOpen) => {
if (!content || !body) return const content = options.content()
heightAnim?.stop() const body = options.body()
fadeAnim?.stop() if (!content || !body) return
if (isOpen) { heightAnim?.stop()
content.style.display = "" fadeAnim?.stop()
content.style.height = "0px" const id = ++gen
body.style.opacity = "0" if (isOpen) {
body.style.filter = "blur(2px)" content.style.display = ""
fadeAnim = animate(body, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING) content.style.height = "0px"
queueMicrotask(() => { body.style.opacity = "0"
if (!options.open()) return body.style.filter = "blur(2px)"
const c = options.content() fadeAnim = animate(body, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING)
if (!c) return queueMicrotask(() => {
const h = options.measure?.() ?? Math.ceil(body.getBoundingClientRect().height) if (gen !== id) return
heightAnim = animate(c, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING) const c = options.content()
heightAnim.finished if (!c) return
.catch(() => {}) const h = options.measure?.() ?? Math.ceil(body.getBoundingClientRect().height)
.then(() => { heightAnim = animate(c, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING)
if (!options.open()) return heightAnim.finished.then(
const el = options.content() () => {
if (!el) return if (gen !== id) return
el.style.height = "auto" c.style.height = "auto"
options.onOpen?.() options.onOpen?.()
}) },
}) () => {},
return )
} })
return
}
const h = content.getBoundingClientRect().height const h = content.getBoundingClientRect().height
heightAnim = animate(content, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING) heightAnim = animate(content, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING)
fadeAnim = animate(body, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING) fadeAnim = animate(body, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING)
heightAnim.finished heightAnim.finished.then(
.catch(() => {}) () => {
.then(() => { if (gen !== id) return
if (options.open()) return content.style.display = "none"
const el = options.content() },
if (!el) return () => {},
el.style.display = "none" )
}) },
}, { defer: true }), { defer: true },
),
) )
onCleanup(() => { onCleanup(() => {
++gen
heightAnim?.stop() heightAnim?.stop()
fadeAnim?.stop() fadeAnim?.stop()
}) })
@ -170,6 +175,84 @@ export function useContextToolPending(parts: () => ToolPart[], working?: () => b
return createMemo(() => !settled() && (!!working?.() || anyRunning())) 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( export function useToolFade(
ref: () => HTMLElement | undefined, ref: () => HTMLElement | undefined,
options?: { delay?: number; wipe?: boolean; animate?: boolean }, options?: { delay?: number; wipe?: boolean; animate?: boolean },