Explorar o código

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 <[email protected]>
Kit Langton hai 1 mes
pai
achega
b47ab35ddf

+ 8 - 38
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
               <span data-slot="context-tool-rolling-action">{label().action}</span>
               {(() => {
                 const [detailRef, setDetailRef] = createSignal<HTMLSpanElement>()
-                createEffect(() => {
-                  const el = detailRef()
-                  const d = label().detail
-                  if (!el || !d) return
-                  if (wiped.has(k)) return
-                  wiped.add(k)
-                  if (reduce()) return
-                  el.style.maskImage = WIPE_MASK
-                  el.style.webkitMaskImage = WIPE_MASK
-                  el.style.maskSize = "240% 100%"
-                  el.style.webkitMaskSize = "240% 100%"
-                  el.style.maskRepeat = "no-repeat"
-                  el.style.webkitMaskRepeat = "no-repeat"
-                  el.style.maskPosition = "100% 0%"
-                  el.style.webkitMaskPosition = "100% 0%"
-                  animate(
-                    el,
-                    {
-                      opacity: [0, 1],
-                      filter: ["blur(2px)", "blur(0px)"],
-                      transform: ["translateX(-0.06em)", "translateX(0)"],
-                      maskPosition: "0% 0%",
-                    },
-                    GROW_SPRING,
-                  ).finished.then(() => {
-                    if (!el) return
-                    clearFadeStyles(el)
-                    clearMaskStyles(el)
-                  })
+                useRowWipe({
+                  id: () => k,
+                  text: () => label().detail,
+                  ref: detailRef,
+                  seen: wiped,
                 })
                 return (
                   <span

+ 132 - 3
packages/ui/src/components/message-part.css

@@ -1,10 +1,20 @@
 [data-component="assistant-message"] {
   content-visibility: auto;
   width: 100%;
+}
+
+[data-component="assistant-parts"] {
+  width: 100%;
+  min-width: 0;
   display: flex;
   flex-direction: column;
   align-items: flex-start;
-  gap: 12px;
+  gap: 0;
+}
+
+[data-component="assistant-part-item"] {
+  width: 100%;
+  min-width: 0;
 }
 
 [data-component="user-message"] {
@@ -200,7 +210,7 @@
 [data-component="text-part"] {
   width: 100%;
   margin-top: 0;
-  padding-bottom: 8px;
+  padding-block: 4px;
   position: relative;
 
   [data-slot="text-part-body"] {
@@ -218,6 +228,10 @@
   }
 }
 
+[data-component="assistant-part-item"][data-kind="text"][data-last="true"] [data-component="text-part"] {
+  padding-bottom: 0;
+}
+
 [data-component="compaction-part"] {
   width: 100%;
   display: flex;
@@ -795,7 +809,6 @@
     transition: opacity 0.15s ease;
   }
 
-
   .shell-rolling-copy {
     border: none !important;
     outline: none !important;
@@ -836,6 +849,122 @@
   min-width: 0;
 }
 
+[data-slot="shell-rolling-preview"] {
+  width: 100%;
+  min-width: 0;
+}
+
+[data-component="shell-expanded-output"] {
+  width: 100%;
+  max-width: 100%;
+  overflow-y: auto;
+  overflow-x: hidden;
+  scrollbar-width: none;
+  -ms-overflow-style: none;
+
+  &::-webkit-scrollbar {
+    display: none;
+  }
+}
+
+[data-component="shell-expanded-shell"] {
+  position: relative;
+  width: 100%;
+  min-width: 0;
+  border: 1px solid var(--border-weak-base);
+  border-radius: 6px;
+  background: transparent;
+  overflow: hidden;
+}
+
+[data-slot="shell-expanded-body"] {
+  position: relative;
+  width: 100%;
+  min-width: 0;
+}
+
+[data-slot="shell-expanded-top"] {
+  position: relative;
+  width: 100%;
+  min-width: 0;
+  padding: 9px 44px 9px 16px;
+  box-sizing: border-box;
+}
+
+[data-slot="shell-expanded-command"] {
+  display: flex;
+  align-items: flex-start;
+  gap: 8px;
+  width: 100%;
+  min-width: 0;
+  font-family: var(--font-family-mono);
+  font-feature-settings: var(--font-family-mono--font-feature-settings);
+  font-size: 13px;
+  line-height: 1.45;
+}
+
+[data-slot="shell-expanded-prompt"] {
+  flex-shrink: 0;
+  color: var(--text-weaker);
+}
+
+[data-slot="shell-expanded-input"] {
+  min-width: 0;
+  color: var(--text-strong);
+  white-space: pre-wrap;
+  overflow-wrap: anywhere;
+}
+
+[data-slot="shell-expanded-actions"] {
+  position: absolute;
+  top: 50%;
+  right: 8px;
+  z-index: 1;
+  transform: translateY(-50%);
+}
+
+.shell-expanded-copy {
+  border: none !important;
+  outline: none !important;
+  box-shadow: none !important;
+  background: transparent !important;
+
+  [data-slot="icon-svg"] {
+    color: var(--icon-weaker);
+  }
+
+  &:hover:not(:disabled) {
+    background: color-mix(in srgb, var(--text-base) 8%, transparent) !important;
+    box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important;
+    border-radius: var(--radius-sm);
+
+    [data-slot="icon-svg"] {
+      color: var(--icon-base);
+    }
+  }
+}
+
+[data-slot="shell-expanded-divider"] {
+  width: 100%;
+  height: 1px;
+  background: var(--border-weak-base);
+}
+
+[data-slot="shell-expanded-pre"] {
+  margin: 0;
+  padding: 12px 16px;
+  white-space: pre-wrap;
+  overflow-wrap: anywhere;
+
+  code {
+    font-family: var(--font-family-mono);
+    font-feature-settings: var(--font-family-mono--font-feature-settings);
+    font-size: 13px;
+    line-height: 1.45;
+    color: var(--text-base);
+  }
+}
+
 [data-component="shell-rolling-command"],
 [data-component="shell-rolling-row"] {
   display: inline-flex;

+ 182 - 173
packages/ui/src/components/message-part.tsx

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

+ 28 - 18
packages/ui/src/components/rolling-results.tsx

@@ -17,6 +17,7 @@ export type RollingResultsProps<T> = {
   animate?: boolean
   class?: string
   empty?: JSX.Element
+  noFadeOnCollapse?: boolean
 }
 
 export function RollingResults<T>(props: RollingResultsProps<T>) {
@@ -54,6 +55,7 @@ export function RollingResults<T>(props: RollingResultsProps<T>) {
   })
   const open = createMemo(() => props.open !== false)
   const active = createMemo(() => (props.animate !== false || props.spring !== undefined) && !reducedMotion())
+  const noFade = () => props.noFadeOnCollapse === true
   const overflowing = createMemo(() => count() > rows())
   const shown = createMemo(() => Math.min(rows(), count()))
   const step = createMemo(() => rowHeight() + rowGap())
@@ -142,22 +144,24 @@ export function RollingResults<T>(props: RollingResultsProps<T>) {
         }
         // Wait for the current offset animation to settle (if any).
         const done = shift?.finished ?? Promise.resolve()
-        done.catch(() => {}).then(() => {
-          if (props.scrollable !== true) return
+        done
+          .catch(() => {})
+          .then(() => {
+            if (props.scrollable !== true) return
 
-          // Batch the signal update — Solid updates the DOM synchronously:
-          // rendered() returns all items, skipped() returns 0, padding-top removed,
-          // data-scrollable becomes "true".
-          batch(() => setScrollReady(true))
+            // Batch the signal update — Solid updates the DOM synchronously:
+            // rendered() returns all items, skipped() returns 0, padding-top removed,
+            // data-scrollable becomes "true".
+            batch(() => setScrollReady(true))
 
-          // Now the DOM has all items. Safe to switch layout strategy.
-          // CSS handles `transform: none !important` on [data-scrollable="true"].
-          if (windowEl) {
-            windowEl.style.overflowY = "auto"
-            windowEl.scrollTop = windowEl.scrollHeight
-          }
-          updateScrollMask()
-        })
+            // Now the DOM has all items. Safe to switch layout strategy.
+            // CSS handles `transform: none !important` on [data-scrollable="true"].
+            if (windowEl) {
+              windowEl.style.overflowY = "auto"
+              windowEl.scrollTop = windowEl.scrollHeight
+            }
+            updateScrollMask()
+          })
       },
     ),
   )
@@ -239,26 +243,28 @@ export function RollingResults<T>(props: RollingResultsProps<T>) {
         resize?.stop()
         resize = undefined
         setView(next)
+        view.style.opacity = ""
         clearEdge()
         return
       }
       const collapsing = next === 0 && prev !== undefined && prev > 0
       const expanding = prev === 0 && next > 0
       resize?.stop()
+      view.style.opacity = ""
       applyEdge()
       const spring = props.spring ?? GROW_SPRING
       const anim = collapsing
-        ? animate(view, { height: `${next}px`, opacity: 0 }, spring)
+        ? animate(view, noFade() ? { height: `${next}px` } : { height: `${next}px`, opacity: 0 }, spring)
         : expanding
-          ? animate(view, { height: `${next}px`, opacity: [0, 1] }, spring)
+          ? animate(view, noFade() ? { height: `${next}px` } : { height: `${next}px`, opacity: [0, 1] }, spring)
           : animate(view, { height: `${next}px` }, spring)
       resize = anim
       anim.finished
         .catch(() => {})
         .finally(() => {
+          view.style.opacity = ""
           if (resize !== anim) return
           setView(next)
-          if (collapsing || expanding) view!.style.opacity = ""
           resize = undefined
           clearEdge()
         })
@@ -299,7 +305,11 @@ export function RollingResults<T>(props: RollingResultsProps<T>) {
             <Show when={list().length === 0 && props.empty !== undefined}>
               <div data-slot="rolling-results-empty">{props.empty}</div>
             </Show>
-            <div ref={track} data-slot="rolling-results-track" style={{ "padding-top": scrollReady() ? undefined : `${skipped() * step()}px` }}>
+            <div
+              ref={track}
+              data-slot="rolling-results-track"
+              style={{ "padding-top": scrollReady() ? undefined : `${skipped() * step()}px` }}
+            >
               <For each={rendered()}>
                 {(item, index) => (
                   <div data-slot="rolling-results-row" data-key={key(item, index())}>

+ 359 - 59
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<RunningTool | null>(null)
 
   // Bash streaming state — tracks the current bash tool being streamed into
-  let bashState: { cmdIdx: number; chunkIdx: number; currentOutput: string; part: ToolPart; turn: TurnState } | null = null
+  let bashState: { cmdIdx: number; chunkIdx: number; currentOutput: string; part: ToolPart; turn: TurnState } | null =
+    null
 
   function startNewTurn() {
     turnCounter++
@@ -1009,6 +1220,71 @@ function SessionTimelineSimulator() {
     triggerTool(builder)
   }
 
+  function flow(turn: TurnState, build: (turn: TurnState) => [TimelineEvent[], RunningTool]) {
+    const [evts, run] = build(turn)
+    return [
+      ...evts,
+      { type: "delay", ms: 120 },
+      { type: "part-update", messageID: turn.asstMsgID, partID: run.part.id, patch: run.completePatch },
+      { type: "delay", ms: 80 },
+    ]
+  }
+
+  function shell(turn: TurnState) {
+    const [evts, run, idx] = buildBashStartEvents(turn)
+    const [a, out] = buildBashChunkEvents(turn, run.part, idx, 0, "")
+    const [b] = buildBashChunkEvents(turn, run.part, idx, 1, out)
+    return [
+      ...evts,
+      { type: "delay", ms: 120 },
+      ...a,
+      { type: "delay", ms: 80 },
+      ...b,
+      { type: "delay", ms: 80 },
+      { type: "part-update", messageID: turn.asstMsgID, partID: run.part.id, patch: run.completePatch },
+      { type: "delay", ms: 100 },
+    ]
+  }
+
+  function pattern() {
+    const prev = currentTurn()
+    const turn = startNewTurn()
+    const prompt = "Can you run one pass with every tool so I can preview the full timeline UI?"
+    const evts: TimelineEvent[] = [...drainRunning()]
+    if (prev) {
+      evts.push(
+        { type: "message", message: mkAssistant(prev.asstMsgID, prev.userMsgID, Date.now()) },
+        { type: "status", status: { type: "idle" } },
+        { type: "delay", ms: 80 },
+      )
+    }
+    evts.push(
+      { type: "status", status: { type: "busy" } },
+      { type: "message", message: mkUser(turn.userMsgID) },
+      { type: "part", part: mkText(turn.userMsgID, prompt) },
+      { type: "delay", ms: 120 },
+      { type: "message", message: mkAssistant(turn.asstMsgID, turn.userMsgID) },
+      { type: "delay", ms: 100 },
+      ...flow(turn, buildReadEvents),
+      ...flow(turn, buildGrepEvents),
+      ...flow(turn, buildGlobEvents),
+      ...flow(turn, buildListEvents),
+      ...shell(turn),
+      ...flow(turn, buildWebFetchEvents),
+      ...flow(turn, buildEditEvents),
+      ...flow(turn, buildWriteEvents),
+      ...flow(turn, buildApplyPatchEvents),
+      ...buildTextEvents(turn),
+      { type: "delay", ms: 120 },
+      { type: "message", message: mkAssistant(turn.asstMsgID, turn.userMsgID, Date.now()) },
+      { type: "status", status: { type: "idle" } },
+    )
+    setCurrentTurn(null)
+    setRunningTool(null)
+    bashState = null
+    pb.appendAndPlay(evts)
+  }
+
   function completeTurn() {
     const turn = currentTurn()
     if (!turn) return
@@ -1042,15 +1318,22 @@ function SessionTimelineSimulator() {
   // --- Flat action list ---
 
   const actions: Action[] = [
+    { key: "p", label: "Pattern", handler: () => pattern() },
     { key: "e", label: "Explore", handler: () => triggerExplore() },
     {
-      key: "b", label: "Bash", handler: () => {
+      key: "b",
+      label: "Bash",
+      handler: () => {
         if (bashState) {
           // Already streaming — append next chunk
           const chunks = bashOutputChunks[bashState.cmdIdx]
           if (bashState.chunkIdx < chunks.length) {
             const [chunkEvents, newOutput] = buildBashChunkEvents(
-              bashState.turn, bashState.part, bashState.cmdIdx, bashState.chunkIdx, bashState.currentOutput,
+              bashState.turn,
+              bashState.part,
+              bashState.cmdIdx,
+              bashState.chunkIdx,
+              bashState.currentOutput,
             )
             bashState.chunkIdx++
             bashState.currentOutput = newOutput
@@ -1075,23 +1358,40 @@ function SessionTimelineSimulator() {
       },
     },
     {
-      key: "t", label: "Text", handler: () => {
+      key: "t",
+      label: "Text",
+      handler: () => {
         const drain = drainRunning()
         const turn = ensureTurn()
         pb.appendAndPlay([...drain, ...buildTextEvents(turn)])
       },
     },
-    { key: "d", label: "Edit", handler: () => triggerTool(buildEditEvents) },
+    {
+      key: "d",
+      label: "Edit/Write/Patch",
+      handler: (() => {
+        const builders = [buildEditEvents, buildWriteEvents, buildApplyPatchEvents]
+        let idx = 0
+        return () => {
+          triggerTool(builders[idx % builders.length]!)
+          idx++
+        }
+      })(),
+    },
     { key: "w", label: "WebFetch", handler: () => triggerTool(buildWebFetchEvents) },
     {
-      key: "x", label: "Error", handler: () => {
+      key: "x",
+      label: "Error",
+      handler: () => {
         const drain = drainRunning()
         const turn = ensureTurn()
         pb.appendAndPlay([...drain, ...buildErrorEvents(turn)])
       },
     },
     {
-      key: "u", label: "User", handler: () => {
+      key: "u",
+      label: "User",
+      handler: () => {
         const prev = currentTurn()
         const drain = drainRunning()
         // Complete previous turn if needed
@@ -1306,7 +1606,9 @@ function SessionTimelineSimulator() {
 
           {/* Speed */}
           <div style={{ display: "flex", "align-items": "center", gap: "4px", "flex-shrink": "0" }}>
-            <span style={{ "font-size": "var(--font-size-small)", color: "var(--text-weak)", "margin-right": "2px" }}>Speed</span>
+            <span style={{ "font-size": "var(--font-size-small)", color: "var(--text-weak)", "margin-right": "2px" }}>
+              Speed
+            </span>
             <For each={[0.25, 0.5, 1, 2, 4]}>
               {(s) => (
                 <button
@@ -1316,8 +1618,7 @@ function SessionTimelineSimulator() {
                     "font-size": "var(--font-size-small)",
                     "font-family": "var(--font-family-mono)",
                     "border-radius": "4px",
-                    border:
-                      "1px solid " + (pb.speed() === s ? "var(--color-blue, #3b82f6)" : "var(--border-base)"),
+                    border: "1px solid " + (pb.speed() === s ? "var(--color-blue, #3b82f6)" : "var(--border-base)"),
                     background: pb.speed() === s ? "var(--color-blue, #3b82f6)" : "transparent",
                     color: pb.speed() === s ? "white" : "var(--text-base)",
                     cursor: "pointer",
@@ -1333,9 +1634,7 @@ function SessionTimelineSimulator() {
         {/* Trigger buttons */}
         <div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap" }}>
           <For each={actions}>
-            {(action) => (
-              <TriggerBtn key={action.key} label={action.label} onClick={action.handler} />
-            )}
+            {(action) => <TriggerBtn key={action.key} label={action.label} onClick={action.handler} />}
           </For>
         </div>
 
@@ -1371,10 +1670,11 @@ Flat control panel — each action auto-completes the previous running tool.
 
 | Key | Action |
 |-----|--------|
+| p | Full pattern (user + every tool + text + completion) |
 | e | Explore (random read/grep/glob/list, stays running) |
 | b | Bash tool (keep pressing to stream output, other key completes) |
 | t | Stream text |
-| d | Edit tool (stays running) |
+| d | Edit/Write/Patch (cycles, stays running) |
 | x | Error tool |
 | u | New user turn |
 | c | Complete assistant turn |

+ 16 - 46
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<HTMLSpanElement>()
-            createEffect(() => {
-              const el = textRef()
-              if (!el || !row.text) return
-              if (wiped.has(row.id)) return
-              wiped.add(row.id)
-              if (reduce()) return
-              el.style.maskImage = WIPE_MASK
-              el.style.webkitMaskImage = WIPE_MASK
-              el.style.maskSize = "240% 100%"
-              el.style.webkitMaskSize = "240% 100%"
-              el.style.maskRepeat = "no-repeat"
-              el.style.webkitMaskRepeat = "no-repeat"
-              el.style.maskPosition = "100% 0%"
-              el.style.webkitMaskPosition = "100% 0%"
-              let done = false
-              const clear = () => {
-                if (done) return
-                done = true
-                clearFadeStyles(el)
-                clearMaskStyles(el)
-              }
-              const anim = animate(
-                el,
-                {
-                  opacity: [0, 1],
-                  filter: ["blur(2px)", "blur(0px)"],
-                  transform: ["translateX(-0.06em)", "translateX(0)"],
-                  maskPosition: "0% 0%",
-                },
-                GROW_SPRING,
-              )
-              anim.finished.catch(() => {}).finally(clear)
-              onCleanup(() => {
-                anim.stop()
-                clear()
-              })
+            useRowWipe({
+              id: () => row.id,
+              text: () => row.text,
+              ref: textRef,
+              seen: wiped,
             })
             return (
               <div data-component="shell-rolling-row">

+ 125 - 42
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<string>
+}) {
+  const reduce = prefersReducedMotion
+
+  createEffect(() => {
+    const id = opts.id()
+    const txt = opts.text()
+    const el = opts.ref()
+    if (!el) return
+    if (!txt) {
+      clearFadeStyles(el)
+      clearMaskStyles(el)
+      return
+    }
+    if (reduce() || typeof window === "undefined") {
+      clearFadeStyles(el)
+      clearMaskStyles(el)
+      return
+    }
+    if (opts.seen.has(id)) {
+      clearFadeStyles(el)
+      clearMaskStyles(el)
+      return
+    }
+    opts.seen.add(id)
+
+    el.style.maskImage = WIPE_MASK
+    el.style.webkitMaskImage = WIPE_MASK
+    el.style.maskSize = "240% 100%"
+    el.style.webkitMaskSize = "240% 100%"
+    el.style.maskRepeat = "no-repeat"
+    el.style.webkitMaskRepeat = "no-repeat"
+    el.style.maskPosition = "100% 0%"
+    el.style.webkitMaskPosition = "100% 0%"
+    el.style.opacity = "0"
+    el.style.filter = "blur(2px)"
+    el.style.transform = "translateX(-0.06em)"
+
+    let done = false
+    const clear = () => {
+      if (done) return
+      done = true
+      clearFadeStyles(el)
+      clearMaskStyles(el)
+    }
+    if (typeof requestAnimationFrame !== "function") {
+      clear()
+      return
+    }
+    let anim: AnimationPlaybackControls | undefined
+    let frame: number | undefined = requestAnimationFrame(() => {
+      frame = undefined
+      const node = opts.ref()
+      if (!node) return
+      anim = animate(
+        node,
+        {
+          opacity: [0, 1],
+          filter: ["blur(2px)", "blur(0px)"],
+          transform: ["translateX(-0.06em)", "translateX(0)"],
+          maskPosition: "0% 0%",
+        },
+        GROW_SPRING,
+      )
+
+      anim.finished.catch(() => {}).finally(clear)
+    })
+
+    onCleanup(() => {
+      if (frame !== undefined) cancelAnimationFrame(frame)
+    })
+  })
+}
+
 export function useToolFade(
   ref: () => HTMLElement | undefined,
   options?: { delay?: number; wipe?: boolean; animate?: boolean },