From b47ab35ddf12aaa8b317acf5e95060ffecc1b40d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 6 Mar 2026 16:38:49 -0500 Subject: [PATCH] fix(ui): fix useRowWipe stuck blur and useCollapsible race conditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/components/context-tool-results.tsx | 46 +- packages/ui/src/components/message-part.css | 135 +++++- packages/ui/src/components/message-part.tsx | 353 ++++++++------- .../ui/src/components/rolling-results.tsx | 46 +- .../session-timeline-simulator.stories.tsx | 418 +++++++++++++++--- .../src/components/shell-rolling-results.tsx | 62 +-- packages/ui/src/components/tool-utils.ts | 167 +++++-- 7 files changed, 849 insertions(+), 378 deletions(-) diff --git a/packages/ui/src/components/context-tool-results.tsx b/packages/ui/src/components/context-tool-results.tsx index 4a563c5dc3..25d120e05e 100644 --- a/packages/ui/src/components/context-tool-results.tsx +++ b/packages/ui/src/components/context-tool-results.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, For, 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 {label().action} {(() => { const [detailRef, setDetailRef] = createSignal() - 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 ( grouped().keys.at(-1)) return ( - - {(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 - }) +
+ + {(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() - 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() + 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 ( - <> - - - {(entry) => ( - groupState.write(entry().groupKey, value)} - /> - )} - - - {(entry) => ( -
- -
- )} -
-
- - + 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 ( + +
+ + {(entry) => ( + <> + + groupState.write(entry().groupKey, value)} + /> + + + + + )} + + {(value) => } + + {(entry) => ( + + +
+ +
+
+
+ )} +
+
- - {(value) => } - - ) - }} -
+ ) + }} + +
) } @@ -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={
@@ -1709,7 +1718,7 @@ ToolRegistry.register({ variant="panel" {...props} icon="code-lines" - springContent + defer trigger={
diff --git a/packages/ui/src/components/rolling-results.tsx b/packages/ui/src/components/rolling-results.tsx index 05c71b8aef..d2f30105e5 100644 --- a/packages/ui/src/components/rolling-results.tsx +++ b/packages/ui/src/components/rolling-results.tsx @@ -17,6 +17,7 @@ export type RollingResultsProps = { animate?: boolean class?: string empty?: JSX.Element + noFadeOnCollapse?: boolean } export function RollingResults(props: RollingResultsProps) { @@ -54,6 +55,7 @@ export function RollingResults(props: RollingResultsProps) { }) 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(props: RollingResultsProps) { } // 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(props: RollingResultsProps) { 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(props: RollingResultsProps) {
{props.empty}
-
+
{(item, index) => (
diff --git a/packages/ui/src/components/session-timeline-simulator.stories.tsx b/packages/ui/src/components/session-timeline-simulator.stories.tsx index d00661fbb4..08a72dbb06 100644 --- a/packages/ui/src/components/session-timeline-simulator.stories.tsx +++ b/packages/ui/src/components/session-timeline-simulator.stories.tsx @@ -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(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 */}
- Speed + + Speed + {(s) => (
@@ -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 | diff --git a/packages/ui/src/components/shell-rolling-results.tsx b/packages/ui/src/components/shell-rolling-results.tsx index a7ae89f63e..744d043592 100644 --- a/packages/ui/src/components/shell-rolling-results.tsx +++ b/packages/ui/src/components/shell-rolling-results.tsx @@ -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() - 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 (
diff --git a/packages/ui/src/components/tool-utils.ts b/packages/ui/src/components/tool-utils.ts index 973af49c30..3dde423f85 100644 --- a/packages/ui/src/components/tool-utils.ts +++ b/packages/ui/src/components/tool-utils.ts @@ -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 +}) { + 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 },