Bläddra i källkod

revert(app): "STUPID SEXY TIMELINE (#16420)" (#16745)

Adam 1 månad sedan
förälder
incheckning
c71d1bde5e
48 ändrade filer med 1946 tillägg och 5003 borttagningar
  1. 0 7
      bun.lock
  2. 2 5
      packages/app/e2e/actions.ts
  3. 0 2
      packages/app/e2e/selectors.ts
  4. 4 8
      packages/app/e2e/session/session.spec.ts
  5. 216 52
      packages/app/src/pages/session.tsx
  6. 1 1
      packages/app/src/pages/session/composer/session-composer-region.tsx
  7. 2 2
      packages/app/src/pages/session/file-tabs.tsx
  8. 0 35
      packages/app/src/pages/session/history-window.test.ts
  9. 0 273
      packages/app/src/pages/session/history-window.ts
  10. 373 133
      packages/app/src/pages/session/message-timeline.tsx
  11. 0 522
      packages/app/src/pages/session/session-timeline-header.tsx
  12. 26 26
      packages/app/src/pages/session/use-session-hash-scroll.ts
  13. 0 3
      packages/ui/package.json
  14. 5 6
      packages/ui/src/components/animated-number.css
  15. 2 27
      packages/ui/src/components/animated-number.tsx
  16. 47 13
      packages/ui/src/components/basic-tool.css
  17. 97 259
      packages/ui/src/components/basic-tool.tsx
  18. 32 23
      packages/ui/src/components/collapsible.css
  19. 0 197
      packages/ui/src/components/context-tool-results.tsx
  20. 0 432
      packages/ui/src/components/grow-box.tsx
  21. 85 446
      packages/ui/src/components/message-part.css
  22. 634 408
      packages/ui/src/components/message-part.tsx
  23. 7 25
      packages/ui/src/components/motion-spring.tsx
  24. 0 77
      packages/ui/src/components/motion.tsx
  25. 0 92
      packages/ui/src/components/rolling-results.css
  26. 0 325
      packages/ui/src/components/rolling-results.tsx
  27. 12 7
      packages/ui/src/components/scroll-view.css
  28. 14 56
      packages/ui/src/components/scroll-view.tsx
  29. 11 116
      packages/ui/src/components/session-turn.css
  30. 206 348
      packages/ui/src/components/session-turn.tsx
  31. 0 291
      packages/ui/src/components/shell-rolling-results.tsx
  32. 11 1
      packages/ui/src/components/shell-submessage.css
  33. 31 30
      packages/ui/src/components/text-reveal.css
  34. 9 106
      packages/ui/src/components/text-reveal.tsx
  35. 6 11
      packages/ui/src/components/text-shimmer.css
  36. 0 12
      packages/ui/src/components/text-shimmer.tsx
  37. 0 17
      packages/ui/src/components/text-utils.ts
  38. 3 3
      packages/ui/src/components/tool-count-label.css
  39. 16 5
      packages/ui/src/components/tool-count-label.tsx
  40. 11 10
      packages/ui/src/components/tool-count-summary.css
  41. 4 3
      packages/ui/src/components/tool-status-title.css
  42. 21 46
      packages/ui/src/components/tool-status-title.tsx
  43. 0 336
      packages/ui/src/components/tool-utils.ts
  44. 58 187
      packages/ui/src/hooks/create-auto-scroll.tsx
  45. 0 1
      packages/ui/src/hooks/index.ts
  46. 0 10
      packages/ui/src/hooks/use-reduced-motion.ts
  47. 0 1
      packages/ui/src/styles/index.css
  48. 0 7
      packages/util/src/array.ts

+ 0 - 7
bun.lock

@@ -483,11 +483,8 @@
         "@pierre/diffs": "catalog:",
         "@shikijs/transformers": "3.9.2",
         "@solid-primitives/bounds": "0.1.3",
-        "@solid-primitives/lifecycle": "0.1.2",
         "@solid-primitives/media": "2.3.3",
-        "@solid-primitives/page-visibility": "2.1.1",
         "@solid-primitives/resize-observer": "2.1.3",
-        "@solid-primitives/rootless": "1.5.2",
         "@solidjs/meta": "catalog:",
         "@solidjs/router": "catalog:",
         "dompurify": "3.3.1",
@@ -1837,14 +1834,10 @@
 
     "@solid-primitives/keyed": ["@solid-primitives/[email protected]", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zNadtyYBhJSOjXtogkGHmRxjGdz9KHc8sGGVAGlUABkE8BED2tbIZoxkwSqzOwde8OcUEH0bb5DLZUWIMvyBSA=="],
 
-    "@solid-primitives/lifecycle": ["@solid-primitives/[email protected]", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-+K0T10kZXqorocFj0coIqt8NYm2UqoZfpF3nm2RwrDMZMV+C+SC0Oi3N6Dnq2i7W/n1cHAnfpoV4CBLsW21lJw=="],
-
     "@solid-primitives/map": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/trigger": "^1.1.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew=="],
 
     "@solid-primitives/media": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="],
 
-    "@solid-primitives/page-visibility": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.1", "@solid-primitives/rootless": "^1.5.1", "@solid-primitives/utils": "^6.3.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-CV9BqMqhunf4OOyBkhJCH9f5ivg0ADavdcaBsrqoFvwIk1FoD/blPSHYM4CK8IjS/AEXNcsjlNVc34lMu+2Wdg=="],
-
     "@solid-primitives/props": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-lZOTwFJajBrshSyg14nBMEP0h8MXzPowGO0s3OeiR3z6nXHTfj0FhzDtJMv+VYoRJKQHG2QRnJTgCzK6erARAw=="],
 
     "@solid-primitives/refs": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-K7tf2thy7L+YJjdqXspXOg5xvNEOH8tgEWsp0+1mQk3obHBRD6hEjYZk7p7FlJphSZImS35je3UfmWuD7MhDfg=="],

+ 2 - 5
packages/app/e2e/actions.ts

@@ -7,7 +7,6 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
 import {
   dropdownMenuTriggerSelector,
   dropdownMenuContentSelector,
-  sessionTimelineHeaderSelector,
   projectMenuTriggerSelector,
   projectCloseMenuSelector,
   projectWorkspacesToggleSelector,
@@ -244,9 +243,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
 
   const scroller = page.locator(".scroll-view__viewport").first()
   await expect(scroller).toBeVisible()
-  const header = page.locator(sessionTimelineHeaderSelector).first()
-  await expect(header).toBeVisible({ timeout: 30_000 })
-  await expect(header.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
+  await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
 
   const menu = page
     .locator(dropdownMenuContentSelector)
@@ -262,7 +259,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
 
   if (opened) return menu
 
-  const menuTrigger = header.getByRole("button", { name: /more options/i }).first()
+  const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
   await expect(menuTrigger).toBeVisible()
   await menuTrigger.click()
 

+ 0 - 2
packages/app/e2e/selectors.ts

@@ -51,8 +51,6 @@ export const dropdownMenuContentSelector = '[data-component="dropdown-menu-conte
 
 export const inlineInputSelector = '[data-component="inline-input"]'
 
-export const sessionTimelineHeaderSelector = "[data-session-title]"
-
 export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
 
 export const workspaceItemSelector = (slug: string) =>

+ 4 - 8
packages/app/e2e/session/session.spec.ts

@@ -7,7 +7,7 @@ import {
   openSharePopover,
   withSession,
 } from "../actions"
-import { sessionItemSelector, inlineInputSelector, sessionTimelineHeaderSelector } from "../selectors"
+import { sessionItemSelector, inlineInputSelector } from "../selectors"
 
 const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
 
@@ -39,14 +39,12 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
   await withSession(sdk, originalTitle, async (session) => {
     await seedMessage(sdk, session.id)
     await gotoSession(session.id)
-    await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText(
-      originalTitle,
-    )
+    await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
 
     const menu = await openSessionMoreMenu(page, session.id)
     await clickMenuItem(menu, /rename/i)
 
-    const input = page.locator(sessionTimelineHeaderSelector).locator(inlineInputSelector).first()
+    const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
     await expect(input).toBeVisible()
     await expect(input).toBeFocused()
     await input.fill(renamedTitle)
@@ -63,9 +61,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
       )
       .toBe(renamedTitle)
 
-    await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText(
-      renamedTitle,
-    )
+    await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
   })
 })
 

+ 216 - 52
packages/app/src/pages/session.tsx

@@ -41,12 +41,220 @@ import { createScrollSpy } from "@/pages/session/scroll-spy"
 import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
 import { SessionSidePanel } from "@/pages/session/session-side-panel"
 import { TerminalPanel } from "@/pages/session/terminal-panel"
-import { createSessionHistoryWindow, emptyUserMessages } from "@/pages/session/history-window"
 import { useSessionCommands } from "@/pages/session/use-session-commands"
 import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
 import { same } from "@/utils/same"
 import { formatServerError } from "@/utils/server-errors"
 
+const emptyUserMessages: UserMessage[] = []
+
+type SessionHistoryWindowInput = {
+  sessionID: () => string | undefined
+  messagesReady: () => boolean
+  visibleUserMessages: () => UserMessage[]
+  historyMore: () => boolean
+  historyLoading: () => boolean
+  loadMore: (sessionID: string) => Promise<void>
+  userScrolled: () => boolean
+  scroller: () => HTMLDivElement | undefined
+}
+
+/**
+ * Maintains the rendered history window for a session timeline.
+ *
+ * It keeps initial paint bounded to recent turns, reveals cached turns in
+ * small batches while scrolling upward, and prefetches older history near top.
+ */
+function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
+  const turnInit = 10
+  const turnBatch = 8
+  const turnScrollThreshold = 200
+  const turnPrefetchBuffer = 16
+  const prefetchCooldownMs = 400
+  const prefetchNoGrowthLimit = 2
+
+  const [state, setState] = createStore({
+    turnID: undefined as string | undefined,
+    turnStart: 0,
+    prefetchUntil: 0,
+    prefetchNoGrowth: 0,
+  })
+
+  const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
+
+  const turnStart = createMemo(() => {
+    const id = input.sessionID()
+    const len = input.visibleUserMessages().length
+    if (!id || len <= 0) return 0
+    if (state.turnID !== id) return initialTurnStart(len)
+    if (state.turnStart <= 0) return 0
+    if (state.turnStart >= len) return initialTurnStart(len)
+    return state.turnStart
+  })
+
+  const setTurnStart = (start: number) => {
+    const id = input.sessionID()
+    const next = start > 0 ? start : 0
+    if (!id) {
+      setState({ turnID: undefined, turnStart: next })
+      return
+    }
+    setState({ turnID: id, turnStart: next })
+  }
+
+  const renderedUserMessages = createMemo(
+    () => {
+      const msgs = input.visibleUserMessages()
+      const start = turnStart()
+      if (start <= 0) return msgs
+      return msgs.slice(start)
+    },
+    emptyUserMessages,
+    {
+      equals: same,
+    },
+  )
+
+  const preserveScroll = (fn: () => void) => {
+    const el = input.scroller()
+    if (!el) {
+      fn()
+      return
+    }
+    const beforeTop = el.scrollTop
+    const beforeHeight = el.scrollHeight
+    fn()
+    requestAnimationFrame(() => {
+      const delta = el.scrollHeight - beforeHeight
+      if (!delta) return
+      el.scrollTop = beforeTop + delta
+    })
+  }
+
+  const backfillTurns = () => {
+    const start = turnStart()
+    if (start <= 0) return
+
+    const next = start - turnBatch
+    const nextStart = next > 0 ? next : 0
+
+    preserveScroll(() => setTurnStart(nextStart))
+  }
+
+  /** Button path: reveal all cached turns, fetch older history, reveal one batch. */
+  const loadAndReveal = async () => {
+    const id = input.sessionID()
+    if (!id) return
+
+    const start = turnStart()
+    const beforeVisible = input.visibleUserMessages().length
+
+    if (start > 0) setTurnStart(0)
+
+    if (!input.historyMore() || input.historyLoading()) return
+
+    await input.loadMore(id)
+    if (input.sessionID() !== id) return
+
+    const afterVisible = input.visibleUserMessages().length
+    const growth = afterVisible - beforeVisible
+    if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
+    if (growth <= 0) return
+    if (turnStart() !== 0) return
+
+    const target = Math.min(afterVisible, Math.max(beforeVisible, renderedUserMessages().length) + turnBatch)
+    const nextStart = Math.max(0, afterVisible - target)
+    preserveScroll(() => setTurnStart(nextStart))
+  }
+
+  /** Scroll/prefetch path: fetch older history from server. */
+  const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
+    const id = input.sessionID()
+    if (!id) return
+    if (!input.historyMore() || input.historyLoading()) return
+
+    if (opts?.prefetch) {
+      const now = Date.now()
+      if (state.prefetchUntil > now) return
+      if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
+      setState("prefetchUntil", now + prefetchCooldownMs)
+    }
+
+    const start = turnStart()
+    const beforeVisible = input.visibleUserMessages().length
+    const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
+
+    await input.loadMore(id)
+    if (input.sessionID() !== id) return
+
+    const afterVisible = input.visibleUserMessages().length
+    const growth = afterVisible - beforeVisible
+
+    if (opts?.prefetch) {
+      setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1)
+    } else if (growth > 0 && state.prefetchNoGrowth) {
+      setState("prefetchNoGrowth", 0)
+    }
+
+    if (growth <= 0) return
+    if (turnStart() !== start) return
+
+    const reveal = !opts?.prefetch
+    const currentRendered = renderedUserMessages().length
+    const base = Math.max(beforeRendered, currentRendered)
+    const target = reveal ? Math.min(afterVisible, base + turnBatch) : base
+    const nextStart = Math.max(0, afterVisible - target)
+    preserveScroll(() => setTurnStart(nextStart))
+  }
+
+  const onScrollerScroll = () => {
+    if (!input.userScrolled()) return
+    const el = input.scroller()
+    if (!el) return
+    if (el.scrollTop >= turnScrollThreshold) return
+
+    const start = turnStart()
+    if (start > 0) {
+      if (start <= turnPrefetchBuffer) {
+        void fetchOlderMessages({ prefetch: true })
+      }
+      backfillTurns()
+      return
+    }
+
+    void fetchOlderMessages()
+  }
+
+  createEffect(
+    on(
+      input.sessionID,
+      () => {
+        setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
+      },
+      { defer: true },
+    ),
+  )
+
+  createEffect(
+    on(
+      () => [input.sessionID(), input.messagesReady()] as const,
+      ([id, ready]) => {
+        if (!id || !ready) return
+        setTurnStart(initialTurnStart(input.visibleUserMessages().length))
+      },
+      { defer: true },
+    ),
+  )
+
+  return {
+    turnStart,
+    setTurnStart,
+    renderedUserMessages,
+    loadAndReveal,
+    onScrollerScroll,
+  }
+}
+
 export default function Page() {
   const globalSync = useGlobalSync()
   const layout = useLayout()
@@ -886,7 +1094,6 @@ export default function Page() {
 
   let scrollStateFrame: number | undefined
   let scrollStateTarget: HTMLDivElement | undefined
-  let historyFillFrame: number | undefined
   const scrollSpy = createScrollSpy({
     onActive: (id) => {
       if (id === store.messageId) return
@@ -897,7 +1104,7 @@ export default function Page() {
   const updateScrollState = (el: HTMLDivElement) => {
     const max = el.scrollHeight - el.clientHeight
     const overflow = max > 1
-    const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled()
+    const bottom = !overflow || el.scrollTop >= max - 2
 
     if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
     setUi("scroll", { overflow, bottom })
@@ -920,7 +1127,7 @@ export default function Page() {
 
   const resumeScroll = () => {
     setStore("messageId", undefined)
-    autoScroll.smoothScrollToBottom()
+    autoScroll.forceScrollToBottom()
     clearMessageHash()
 
     const el = scroller
@@ -956,9 +1163,7 @@ export default function Page() {
     scroller = el
     autoScroll.scrollRef(el)
     scrollSpy.setContainer(el)
-    if (!el) return
-    scheduleScrollState(el)
-    scheduleHistoryFill()
+    if (el) scheduleScrollState(el)
   }
 
   createResizeObserver(
@@ -967,7 +1172,6 @@ export default function Page() {
       const el = scroller
       if (el) scheduleScrollState(el)
       scrollSpy.markDirty()
-      scheduleHistoryFill()
     },
   )
 
@@ -982,45 +1186,6 @@ export default function Page() {
     scroller: () => scroller,
   })
 
-  const scheduleHistoryFill = () => {
-    if (historyFillFrame !== undefined) return
-
-    historyFillFrame = requestAnimationFrame(() => {
-      historyFillFrame = undefined
-
-      if (!params.id || !messagesReady()) return
-      if (autoScroll.userScrolled() || historyLoading()) return
-
-      const el = scroller
-      if (!el) return
-      if (el.scrollHeight > el.clientHeight + 1) return
-      if (historyWindow.turnStart() <= 0 && !historyMore()) return
-
-      void historyWindow.loadAndReveal()
-    })
-  }
-
-  createEffect(
-    on(
-      () =>
-        [
-          params.id,
-          messagesReady(),
-          historyWindow.turnStart(),
-          historyMore(),
-          historyLoading(),
-          autoScroll.userScrolled(),
-          visibleUserMessages().length,
-        ] as const,
-      ([id, ready, start, more, loading, scrolled]) => {
-        if (!id || !ready || loading || scrolled) return
-        if (start <= 0 && !more) return
-        scheduleHistoryFill()
-      },
-      { defer: true },
-    ),
-  )
-
   createResizeObserver(
     () => promptDock,
     ({ height }) => {
@@ -1030,15 +1195,16 @@ export default function Page() {
 
       const el = scroller
       const delta = next - dockHeight
-      const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false
+      const stick = el
+        ? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta)
+        : false
 
       dockHeight = next
 
-      if (stick) autoScroll.smoothScrollToBottom()
+      if (stick) autoScroll.forceScrollToBottom()
 
       if (el) scheduleScrollState(el)
       scrollSpy.markDirty()
-      scheduleHistoryFill()
     },
   )
 
@@ -1068,7 +1234,6 @@ export default function Page() {
     document.removeEventListener("keydown", handleKeyDown)
     scrollSpy.destroy()
     if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
-    if (historyFillFrame !== undefined) cancelAnimationFrame(historyFillFrame)
   })
 
   return (
@@ -1122,7 +1287,6 @@ export default function Page() {
                     onScrollSpyScroll={scrollSpy.onScroll}
                     onTurnBackfillScroll={historyWindow.onScrollerScroll}
                     onAutoScrollInteraction={autoScroll.handleInteraction}
-                    onPreserveScrollAnchor={autoScroll.preserve}
                     centered={centered()}
                     setContentRef={(el) => {
                       content = el

+ 1 - 1
packages/app/src/pages/session/composer/session-composer-region.tsx

@@ -140,7 +140,7 @@ export function SessionComposerRegion(props: {
       <div
         classList={{
           "w-full px-3 pointer-events-auto": true,
-          "md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered,
+          "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
         }}
       >
         <Show when={props.state.questionRequest()} keyed>

+ 2 - 2
packages/app/src/pages/session/file-tabs.tsx

@@ -446,9 +446,9 @@ export function FileTabContent(props: { tab: string }) {
   )
 
   return (
-    <Tabs.Content value={props.tab} class="mt-3 relative flex h-full min-h-0 flex-col overflow-hidden contain-strict">
+    <Tabs.Content value={props.tab} class="mt-3 relative h-full">
       <ScrollView
-        class="h-full min-h-0 flex-1"
+        class="h-full"
         viewportRef={(el: HTMLDivElement) => {
           scroll = el
           restoreScroll()

+ 0 - 35
packages/app/src/pages/session/history-window.test.ts

@@ -1,35 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { historyLoadMode, historyRevealTop } from "./history-window"
-
-describe("historyLoadMode", () => {
-  test("reveals cached turns before fetching", () => {
-    expect(historyLoadMode({ start: 10, more: true, loading: false })).toBe("reveal")
-  })
-
-  test("fetches older history when cache is already revealed", () => {
-    expect(historyLoadMode({ start: 0, more: true, loading: false })).toBe("fetch")
-  })
-
-  test("does nothing while history is unavailable or loading", () => {
-    expect(historyLoadMode({ start: 0, more: false, loading: false })).toBe("noop")
-    expect(historyLoadMode({ start: 0, more: true, loading: true })).toBe("noop")
-  })
-})
-
-describe("historyRevealTop", () => {
-  test("pins the viewport to the top when older turns were revealed there", () => {
-    expect(historyRevealTop({ top: -400, height: 1000, gap: 0, max: 400 }, { clientHeight: 600, height: 2000 })).toBe(
-      -1400,
-    )
-  })
-
-  test("keeps the latest turns pinned when the viewport was underfilled", () => {
-    expect(historyRevealTop({ top: 0, height: 200, gap: -400, max: -400 }, { clientHeight: 600, height: 2000 })).toBe(0)
-  })
-
-  test("keeps the current anchor when the user was not at the top", () => {
-    expect(historyRevealTop({ top: -200, height: 1000, gap: 200, max: 400 }, { clientHeight: 600, height: 2000 })).toBe(
-      -200,
-    )
-  })
-})

+ 0 - 273
packages/app/src/pages/session/history-window.ts

@@ -1,273 +0,0 @@
-import type { UserMessage } from "@opencode-ai/sdk/v2"
-import { createEffect, createMemo, on } from "solid-js"
-import { createStore } from "solid-js/store"
-import { same } from "@/utils/same"
-
-export const emptyUserMessages: UserMessage[] = []
-
-export type SessionHistoryWindowInput = {
-  sessionID: () => string | undefined
-  messagesReady: () => boolean
-  visibleUserMessages: () => UserMessage[]
-  historyMore: () => boolean
-  historyLoading: () => boolean
-  loadMore: (sessionID: string) => Promise<void>
-  userScrolled: () => boolean
-  scroller: () => HTMLDivElement | undefined
-}
-
-type Snap = {
-  top: number
-  height: number
-  gap: number
-  max: number
-}
-
-export const historyLoadMode = (input: { start: number; more: boolean; loading: boolean }) => {
-  if (input.start > 0) return "reveal"
-  if (!input.more || input.loading) return "noop"
-  return "fetch"
-}
-
-export const historyRevealTop = (
-  mark: { top: number; height: number; gap: number; max: number },
-  next: { clientHeight: number; height: number },
-  threshold = 16,
-) => {
-  const delta = next.height - mark.height
-  if (delta <= 0) return mark.top
-  if (mark.max <= 0) return mark.top
-  if (mark.gap > threshold) return mark.top
-
-  const max = next.height - next.clientHeight
-  if (max <= 0) return 0
-  return Math.max(-max, Math.min(0, mark.top - delta))
-}
-
-const snap = (el: HTMLDivElement | undefined): Snap | undefined => {
-  if (!el) return
-  const max = el.scrollHeight - el.clientHeight
-  return {
-    top: el.scrollTop,
-    height: el.scrollHeight,
-    gap: max + el.scrollTop,
-    max,
-  }
-}
-
-const clamp = (el: HTMLDivElement, top: number) => {
-  const max = el.scrollHeight - el.clientHeight
-  if (max <= 0) return 0
-  return Math.max(-max, Math.min(0, top))
-}
-
-const revealThreshold = 16
-
-const reveal = (input: SessionHistoryWindowInput, mark: Snap | undefined) => {
-  const el = input.scroller()
-  if (!el || !mark) return
-  el.scrollTop = clamp(
-    el,
-    historyRevealTop(mark, { clientHeight: el.clientHeight, height: el.scrollHeight }, revealThreshold),
-  )
-}
-
-const preserve = (input: SessionHistoryWindowInput, fn: () => void) => {
-  const el = input.scroller()
-  if (!el) {
-    fn()
-    return
-  }
-  const top = el.scrollTop
-  fn()
-  el.scrollTop = top
-}
-
-/**
- * Maintains the rendered history window for a session timeline.
- *
- * It keeps initial paint bounded to recent turns, reveals cached turns in
- * small batches while scrolling upward, and prefetches older history near top.
- */
-export function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
-  const turnInit = 10
-  const turnBatch = 8
-  const turnScrollThreshold = 200
-  const turnPrefetchBuffer = 16
-  const prefetchCooldownMs = 400
-  const prefetchNoGrowthLimit = 2
-
-  const [state, setState] = createStore({
-    turnID: undefined as string | undefined,
-    turnStart: 0,
-    prefetchUntil: 0,
-    prefetchNoGrowth: 0,
-  })
-
-  const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
-
-  const turnStart = createMemo(() => {
-    const id = input.sessionID()
-    const len = input.visibleUserMessages().length
-    if (!id || len <= 0) return 0
-    if (state.turnID !== id) return initialTurnStart(len)
-    if (state.turnStart <= 0) return 0
-    if (state.turnStart >= len) return initialTurnStart(len)
-    return state.turnStart
-  })
-
-  const setTurnStart = (start: number) => {
-    const id = input.sessionID()
-    const next = start > 0 ? start : 0
-    if (!id) {
-      setState({ turnID: undefined, turnStart: next })
-      return
-    }
-    setState({ turnID: id, turnStart: next })
-  }
-
-  const renderedUserMessages = createMemo(
-    () => {
-      const msgs = input.visibleUserMessages()
-      const start = turnStart()
-      if (start <= 0) return msgs
-      return msgs.slice(start)
-    },
-    emptyUserMessages,
-    {
-      equals: same,
-    },
-  )
-
-  const backfillTurns = () => {
-    const start = turnStart()
-    if (start <= 0) return
-
-    const next = start - turnBatch
-    const nextStart = next > 0 ? next : 0
-
-    preserve(input, () => setTurnStart(nextStart))
-  }
-
-  /** Button path: reveal cached turns first, then fetch older history. */
-  const loadAndReveal = async () => {
-    const id = input.sessionID()
-    if (!id) return
-
-    const start = turnStart()
-    const mode = historyLoadMode({
-      start,
-      more: input.historyMore(),
-      loading: input.historyLoading(),
-    })
-
-    if (mode === "reveal") {
-      const mark = snap(input.scroller())
-      setTurnStart(0)
-      reveal(input, mark)
-      return
-    }
-
-    if (mode === "noop") return
-
-    const beforeVisible = input.visibleUserMessages().length
-    const mark = snap(input.scroller())
-
-    await input.loadMore(id)
-    if (input.sessionID() !== id) return
-
-    const afterVisible = input.visibleUserMessages().length
-    const growth = afterVisible - beforeVisible
-    if (growth <= 0) return
-    if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
-
-    reveal(input, mark)
-  }
-
-  /** Scroll/prefetch path: fetch older history from server. */
-  const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
-    const id = input.sessionID()
-    if (!id) return
-    if (!input.historyMore() || input.historyLoading()) return
-
-    if (opts?.prefetch) {
-      const now = Date.now()
-      if (state.prefetchUntil > now) return
-      if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
-      setState("prefetchUntil", now + prefetchCooldownMs)
-    }
-
-    const start = turnStart()
-    const beforeVisible = input.visibleUserMessages().length
-    const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
-
-    await input.loadMore(id)
-    if (input.sessionID() !== id) return
-
-    const afterVisible = input.visibleUserMessages().length
-    const growth = afterVisible - beforeVisible
-
-    if (opts?.prefetch) {
-      setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1)
-    } else if (growth > 0 && state.prefetchNoGrowth) {
-      setState("prefetchNoGrowth", 0)
-    }
-
-    if (growth <= 0) return
-    if (turnStart() !== start) return
-
-    const revealMore = !opts?.prefetch
-    const currentRendered = renderedUserMessages().length
-    const base = Math.max(beforeRendered, currentRendered)
-    const target = revealMore ? Math.min(afterVisible, base + turnBatch) : base
-    const nextStart = Math.max(0, afterVisible - target)
-    preserve(input, () => setTurnStart(nextStart))
-  }
-
-  const onScrollerScroll = () => {
-    if (!input.userScrolled()) return
-    const el = input.scroller()
-    if (!el) return
-    if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
-
-    const start = turnStart()
-    if (start > 0) {
-      if (start <= turnPrefetchBuffer) {
-        void fetchOlderMessages({ prefetch: true })
-      }
-      backfillTurns()
-      return
-    }
-
-    void fetchOlderMessages()
-  }
-
-  createEffect(
-    on(
-      input.sessionID,
-      () => {
-        setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
-      },
-      { defer: true },
-    ),
-  )
-
-  createEffect(
-    on(
-      () => [input.sessionID(), input.messagesReady()] as const,
-      ([id, ready]) => {
-        if (!id || !ready) return
-        setTurnStart(initialTurnStart(input.visibleUserMessages().length))
-      },
-      { defer: true },
-    ),
-  )
-
-  return {
-    turnStart,
-    setTurnStart,
-    renderedUserMessages,
-    loadAndReveal,
-    onScrollerScroll,
-  }
-}

+ 373 - 133
packages/app/src/pages/session/message-timeline.tsx

@@ -1,31 +1,27 @@
-import {
-  For,
-  Index,
-  createEffect,
-  createMemo,
-  createSignal,
-  on,
-  onCleanup,
-  Show,
-  startTransition,
-  type JSX,
-} from "solid-js"
-import { createStore } from "solid-js/store"
-import { useParams } from "@solidjs/router"
+import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import { useNavigate, useParams } from "@solidjs/router"
 import { Button } from "@opencode-ai/ui/button"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { InlineInput } from "@opencode-ai/ui/inline-input"
 import { SessionTurn } from "@opencode-ai/ui/session-turn"
 import { ScrollView } from "@opencode-ai/ui/scroll-view"
 import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
+import { showToast } from "@opencode-ai/ui/toast"
 import { Binary } from "@opencode-ai/util/binary"
 import { getFilename } from "@opencode-ai/util/path"
 import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
+import { SessionContextUsage } from "@/components/session-context-usage"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useLanguage } from "@/context/language"
 import { useSettings } from "@/context/settings"
+import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
 import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
-import { SessionTimelineHeader } from "@/pages/session/session-timeline-header"
 
 type MessageComment = {
   path: string
@@ -37,9 +33,7 @@ type MessageComment = {
 }
 
 const emptyMessages: MessageType[] = []
-
-const isDefaultSessionTitle = (title?: string) =>
-  !!title && /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
+const idle = { type: "idle" as const }
 
 const messageComments = (parts: Part[]): MessageComment[] =>
   parts.flatMap((part) => {
@@ -116,8 +110,6 @@ function createTimelineStaging(input: TimelineStageInput) {
     completedSession: "",
     count: 0,
   })
-  const [readySession, setReadySession] = createSignal("")
-  let active = ""
 
   const stagedCount = createMemo(() => {
     const total = input.messages().length
@@ -142,46 +134,23 @@ function createTimelineStaging(input: TimelineStageInput) {
     cancelAnimationFrame(frame)
     frame = undefined
   }
-  const scheduleReady = (sessionKey: string) => {
-    if (input.sessionKey() !== sessionKey) return
-    if (readySession() === sessionKey) return
-    setReadySession(sessionKey)
-  }
 
   createEffect(
     on(
       () => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const,
       ([sessionKey, isWindowed, total]) => {
-        const switched = active !== sessionKey
-        if (switched) {
-          active = sessionKey
-          setReadySession("")
-        }
-
-        const staging = state.activeSession === sessionKey && state.completedSession !== sessionKey
-        const shouldStage = isWindowed && total > input.config.init && state.completedSession !== sessionKey
-
-        if (staging && !switched && shouldStage && frame !== undefined) return
-
         cancel()
-
-        if (shouldStage) setReadySession("")
+        const shouldStage =
+          isWindowed &&
+          total > input.config.init &&
+          state.completedSession !== sessionKey &&
+          state.activeSession !== sessionKey
         if (!shouldStage) {
-          setState({
-            activeSession: "",
-            completedSession: isWindowed ? sessionKey : state.completedSession,
-            count: total,
-          })
-          if (total <= 0) {
-            setReadySession("")
-            return
-          }
-          if (readySession() !== sessionKey) scheduleReady(sessionKey)
+          setState({ activeSession: "", count: total })
           return
         }
 
         let count = Math.min(total, input.config.init)
-        if (staging) count = Math.min(total, Math.max(count, state.count))
         setState({ activeSession: sessionKey, count })
 
         const step = () => {
@@ -191,11 +160,10 @@ function createTimelineStaging(input: TimelineStageInput) {
           }
           const currentTotal = input.messages().length
           count = Math.min(currentTotal, count + input.config.batch)
-          startTransition(() => setState("count", count))
+          setState("count", count)
           if (count >= currentTotal) {
             setState({ completedSession: sessionKey, activeSession: "" })
             frame = undefined
-            scheduleReady(sessionKey)
             return
           }
           frame = requestAnimationFrame(step)
@@ -209,12 +177,9 @@ function createTimelineStaging(input: TimelineStageInput) {
     const key = input.sessionKey()
     return state.activeSession === key && state.completedSession !== key
   })
-  const ready = createMemo(() => readySession() === input.sessionKey())
 
-  onCleanup(() => {
-    cancel()
-  })
-  return { messages: stagedUserMessages, isStaging, ready }
+  onCleanup(cancel)
+  return { messages: stagedUserMessages, isStaging }
 }
 
 export function MessageTimeline(props: {
@@ -231,7 +196,6 @@ export function MessageTimeline(props: {
   onScrollSpyScroll: () => void
   onTurnBackfillScroll: () => void
   onAutoScrollInteraction: (event: MouseEvent) => void
-  onPreserveScrollAnchor: (target: HTMLElement) => void
   centered: boolean
   setContentRef: (el: HTMLDivElement) => void
   turnStart: number
@@ -246,19 +210,14 @@ export function MessageTimeline(props: {
   let touchGesture: number | undefined
 
   const params = useParams()
+  const navigate = useNavigate()
+  const sdk = useSDK()
   const sync = useSync()
   const settings = useSettings()
+  const dialog = useDialog()
   const language = useLanguage()
 
-  const trigger = (target: EventTarget | null) => {
-    const next =
-      target instanceof Element
-        ? target.closest('[data-slot="collapsible-trigger"], [data-slot="accordion-trigger"], [data-scroll-preserve]')
-        : undefined
-    if (!(next instanceof HTMLElement)) return
-    return next
-  }
-
+  const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const sessionID = createMemo(() => params.id)
   const sessionMessages = createMemo(() => {
@@ -271,20 +230,28 @@ export function MessageTimeline(props: {
       (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
     ),
   )
-  const sessionStatus = createMemo(() => sync.data.session_status[sessionID() ?? ""]?.type ?? "idle")
+  const sessionStatus = createMemo(() => {
+    const id = sessionID()
+    if (!id) return idle
+    return sync.data.session_status[id] ?? idle
+  })
   const activeMessageID = createMemo(() => {
-    const messages = sessionMessages()
-    const message = pending()
-    if (message?.parentID) {
-      const result = Binary.search(messages, message.parentID, (item) => item.id)
-      const parent = result.found ? messages[result.index] : messages.find((item) => item.id === message.parentID)
-      if (parent?.role === "user") return parent.id
+    const parentID = pending()?.parentID
+    if (parentID) {
+      const messages = sessionMessages()
+      const result = Binary.search(messages, parentID, (message) => message.id)
+      const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
+      if (message && message.role === "user") return message.id
     }
 
-    if (sessionStatus() === "idle") return undefined
-    for (let i = messages.length - 1; i >= 0; i--) {
-      if (messages[i].role === "user") return messages[i].id
+    const status = sessionStatus()
+    if (status.type !== "idle") {
+      const messages = sessionMessages()
+      for (let i = messages.length - 1; i >= 0; i--) {
+        if (messages[i].role === "user") return messages[i].id
+      }
     }
+
     return undefined
   })
   const info = createMemo(() => {
@@ -292,19 +259,9 @@ export function MessageTimeline(props: {
     if (!id) return
     return sync.session.get(id)
   })
-  const titleValue = createMemo(() => {
-    const title = info()?.title
-    if (!title) return
-    if (isDefaultSessionTitle(title)) return language.t("command.session.new")
-    return title
-  })
-  const defaultTitle = createMemo(() => isDefaultSessionTitle(info()?.title))
-  const headerTitle = createMemo(
-    () => titleValue() ?? (props.renderedUserMessages.length ? language.t("command.session.new") : undefined),
-  )
-  const placeholderTitle = createMemo(() => defaultTitle() || (!info()?.title && props.renderedUserMessages.length > 0))
+  const titleValue = createMemo(() => info()?.title)
   const parentID = createMemo(() => info()?.parentID)
-  const showHeader = createMemo(() => !!(headerTitle() || parentID()))
+  const showHeader = createMemo(() => !!(titleValue() || parentID()))
   const stageCfg = { init: 1, batch: 3 }
   const staging = createTimelineStaging({
     sessionKey,
@@ -312,7 +269,212 @@ export function MessageTimeline(props: {
     messages: () => props.renderedUserMessages,
     config: stageCfg,
   })
-  const rendered = createMemo(() => staging.messages().map((message) => message.id))
+
+  const [title, setTitle] = createStore({
+    draft: "",
+    editing: false,
+    saving: false,
+    menuOpen: false,
+    pendingRename: false,
+  })
+  let titleRef: HTMLInputElement | undefined
+
+  const errorMessage = (err: unknown) => {
+    if (err && typeof err === "object" && "data" in err) {
+      const data = (err as { data?: { message?: string } }).data
+      if (data?.message) return data.message
+    }
+    if (err instanceof Error) return err.message
+    return language.t("common.requestFailed")
+  }
+
+  createEffect(
+    on(
+      sessionKey,
+      () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
+      { defer: true },
+    ),
+  )
+
+  const openTitleEditor = () => {
+    if (!sessionID()) return
+    setTitle({ editing: true, draft: titleValue() ?? "" })
+    requestAnimationFrame(() => {
+      titleRef?.focus()
+      titleRef?.select()
+    })
+  }
+
+  const closeTitleEditor = () => {
+    if (title.saving) return
+    setTitle({ editing: false, saving: false })
+  }
+
+  const saveTitleEditor = async () => {
+    const id = sessionID()
+    if (!id) return
+    if (title.saving) return
+
+    const next = title.draft.trim()
+    if (!next || next === (titleValue() ?? "")) {
+      setTitle({ editing: false, saving: false })
+      return
+    }
+
+    setTitle("saving", true)
+    await sdk.client.session
+      .update({ sessionID: id, title: next })
+      .then(() => {
+        sync.set(
+          produce((draft) => {
+            const index = draft.session.findIndex((s) => s.id === id)
+            if (index !== -1) draft.session[index].title = next
+          }),
+        )
+        setTitle({ editing: false, saving: false })
+      })
+      .catch((err) => {
+        setTitle("saving", false)
+        showToast({
+          title: language.t("common.requestFailed"),
+          description: errorMessage(err),
+        })
+      })
+  }
+
+  const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
+    if (params.id !== sessionID) return
+    if (parentID) {
+      navigate(`/${params.dir}/session/${parentID}`)
+      return
+    }
+    if (nextSessionID) {
+      navigate(`/${params.dir}/session/${nextSessionID}`)
+      return
+    }
+    navigate(`/${params.dir}/session`)
+  }
+
+  const archiveSession = async (sessionID: string) => {
+    const session = sync.session.get(sessionID)
+    if (!session) return
+
+    const sessions = sync.data.session ?? []
+    const index = sessions.findIndex((s) => s.id === sessionID)
+    const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
+
+    await sdk.client.session
+      .update({ sessionID, time: { archived: Date.now() } })
+      .then(() => {
+        sync.set(
+          produce((draft) => {
+            const index = draft.session.findIndex((s) => s.id === sessionID)
+            if (index !== -1) draft.session.splice(index, 1)
+          }),
+        )
+        navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
+      })
+      .catch((err) => {
+        showToast({
+          title: language.t("common.requestFailed"),
+          description: errorMessage(err),
+        })
+      })
+  }
+
+  const deleteSession = async (sessionID: string) => {
+    const session = sync.session.get(sessionID)
+    if (!session) return false
+
+    const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
+    const index = sessions.findIndex((s) => s.id === sessionID)
+    const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
+
+    const result = await sdk.client.session
+      .delete({ sessionID })
+      .then((x) => x.data)
+      .catch((err) => {
+        showToast({
+          title: language.t("session.delete.failed.title"),
+          description: errorMessage(err),
+        })
+        return false
+      })
+
+    if (!result) return false
+
+    sync.set(
+      produce((draft) => {
+        const removed = new Set<string>([sessionID])
+
+        const byParent = new Map<string, string[]>()
+        for (const item of draft.session) {
+          const parentID = item.parentID
+          if (!parentID) continue
+          const existing = byParent.get(parentID)
+          if (existing) {
+            existing.push(item.id)
+            continue
+          }
+          byParent.set(parentID, [item.id])
+        }
+
+        const stack = [sessionID]
+        while (stack.length) {
+          const parentID = stack.pop()
+          if (!parentID) continue
+
+          const children = byParent.get(parentID)
+          if (!children) continue
+
+          for (const child of children) {
+            if (removed.has(child)) continue
+            removed.add(child)
+            stack.push(child)
+          }
+        }
+
+        draft.session = draft.session.filter((s) => !removed.has(s.id))
+      }),
+    )
+
+    navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
+    return true
+  }
+
+  const navigateParent = () => {
+    const id = parentID()
+    if (!id) return
+    navigate(`/${params.dir}/session/${id}`)
+  }
+
+  function DialogDeleteSession(props: { sessionID: string }) {
+    const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
+    const handleDelete = async () => {
+      await deleteSession(props.sessionID)
+      dialog.close()
+    }
+
+    return (
+      <Dialog title={language.t("session.delete.title")} fit>
+        <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
+          <div class="flex flex-col gap-1">
+            <span class="text-14-regular text-text-strong">
+              {language.t("session.delete.confirm", { name: name() })}
+            </span>
+          </div>
+          <div class="flex justify-end gap-2">
+            <Button variant="ghost" size="large" onClick={() => dialog.close()}>
+              {language.t("common.cancel")}
+            </Button>
+            <Button variant="primary" size="large" onClick={handleDelete}>
+              {language.t("session.delete.button")}
+            </Button>
+          </div>
+        </div>
+      </Dialog>
+    )
+  }
 
   return (
     <Show
@@ -336,18 +498,7 @@ export function MessageTimeline(props: {
             <Icon name="arrow-down-to-line" />
           </button>
         </div>
-        <SessionTimelineHeader
-          centered={props.centered}
-          showHeader={showHeader}
-          sessionKey={sessionKey}
-          sessionID={sessionID}
-          parentID={parentID}
-          titleValue={titleValue}
-          headerTitle={headerTitle}
-          placeholderTitle={placeholderTitle}
-        />
         <ScrollView
-          reverse
           viewportRef={props.setScrollRef}
           onWheel={(e) => {
             const root = e.currentTarget
@@ -381,18 +532,9 @@ export function MessageTimeline(props: {
             touchGesture = undefined
           }}
           onPointerDown={(e) => {
-            const next = trigger(e.target)
-            if (next) props.onPreserveScrollAnchor(next)
-
             if (e.target !== e.currentTarget) return
             props.onMarkScrollGesture(e.currentTarget)
           }}
-          onKeyDown={(e) => {
-            if (e.key !== "Enter" && e.key !== " ") return
-            const next = trigger(e.target)
-            if (!next) return
-            props.onPreserveScrollAnchor(next)
-          }}
           onScroll={(e) => {
             props.onScheduleScrollState(e.currentTarget)
             props.onTurnBackfillScroll()
@@ -401,24 +543,134 @@ export function MessageTimeline(props: {
             props.onMarkScrollGesture(e.currentTarget)
             if (props.isDesktop) props.onScrollSpyScroll()
           }}
-          onClick={(e) => {
-            props.onAutoScrollInteraction(e)
-          }}
+          onClick={props.onAutoScrollInteraction}
           class="relative min-w-0 w-full h-full"
           style={{
-            "--session-title-height": showHeader() ? "72px" : "0px",
+            "--session-title-height": showHeader() ? "40px" : "0px",
             "--sticky-accordion-top": showHeader() ? "48px" : "0px",
           }}
         >
-          <div>
+          <div ref={props.setContentRef} class="min-w-0 w-full">
+            <Show when={showHeader()}>
+              <div
+                data-session-title
+                classList={{
+                  "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
+                  "w-full": true,
+                  "pb-4": true,
+                  "pl-2 pr-3 md:pl-4 md:pr-3": true,
+                  "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
+                }}
+              >
+                <div class="h-12 w-full flex items-center justify-between gap-2">
+                  <div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
+                    <Show when={parentID()}>
+                      <IconButton
+                        tabIndex={-1}
+                        icon="arrow-left"
+                        variant="ghost"
+                        onClick={navigateParent}
+                        aria-label={language.t("common.goBack")}
+                      />
+                    </Show>
+                    <Show when={titleValue() || title.editing}>
+                      <Show
+                        when={title.editing}
+                        fallback={
+                          <h1
+                            class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
+                            onDblClick={openTitleEditor}
+                          >
+                            {titleValue()}
+                          </h1>
+                        }
+                      >
+                        <InlineInput
+                          ref={(el) => {
+                            titleRef = el
+                          }}
+                          value={title.draft}
+                          disabled={title.saving}
+                          class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
+                          style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
+                          onInput={(event) => setTitle("draft", event.currentTarget.value)}
+                          onKeyDown={(event) => {
+                            event.stopPropagation()
+                            if (event.key === "Enter") {
+                              event.preventDefault()
+                              void saveTitleEditor()
+                              return
+                            }
+                            if (event.key === "Escape") {
+                              event.preventDefault()
+                              closeTitleEditor()
+                            }
+                          }}
+                          onBlur={closeTitleEditor}
+                        />
+                      </Show>
+                    </Show>
+                  </div>
+                  <Show when={sessionID()}>
+                    {(id) => (
+                      <div class="shrink-0 flex items-center gap-3">
+                        <SessionContextUsage placement="bottom" />
+                        <DropdownMenu
+                          gutter={4}
+                          placement="bottom-end"
+                          open={title.menuOpen}
+                          onOpenChange={(open) => setTitle("menuOpen", open)}
+                        >
+                          <DropdownMenu.Trigger
+                            as={IconButton}
+                            icon="dot-grid"
+                            variant="ghost"
+                            class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
+                            aria-label={language.t("common.moreOptions")}
+                          />
+                          <DropdownMenu.Portal>
+                            <DropdownMenu.Content
+                              style={{ "min-width": "104px" }}
+                              onCloseAutoFocus={(event) => {
+                                if (!title.pendingRename) return
+                                event.preventDefault()
+                                setTitle("pendingRename", false)
+                                openTitleEditor()
+                              }}
+                            >
+                              <DropdownMenu.Item
+                                onSelect={() => {
+                                  setTitle("pendingRename", true)
+                                  setTitle("menuOpen", false)
+                                }}
+                              >
+                                <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
+                              </DropdownMenu.Item>
+                              <DropdownMenu.Item onSelect={() => void archiveSession(id())}>
+                                <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
+                              </DropdownMenu.Item>
+                              <DropdownMenu.Separator />
+                              <DropdownMenu.Item
+                                onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
+                              >
+                                <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
+                              </DropdownMenu.Item>
+                            </DropdownMenu.Content>
+                          </DropdownMenu.Portal>
+                        </DropdownMenu>
+                      </div>
+                    )}
+                  </Show>
+                </div>
+              </div>
+            </Show>
+
             <div
-              ref={props.setContentRef}
               role="log"
-              class="flex flex-col gap-0 items-start justify-start pb-16 transition-[margin]"
-              style={{ "padding-top": "var(--session-title-height)" }}
+              class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
               classList={{
                 "w-full": true,
-                "md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered,
+                "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
                 "mt-0.5": props.centered,
                 "mt-0": !props.centered,
               }}
@@ -440,15 +692,6 @@ export function MessageTimeline(props: {
               </Show>
               <For each={rendered()}>
                 {(messageID) => {
-                  // Capture at creation time: animate only messages added after the
-                  // timeline finishes its initial backfill staging, plus the first
-                  // turn while a brand new session is still using its default title.
-                  const isNew =
-                    staging.ready() ||
-                    (defaultTitle() &&
-                      sessionStatus() !== "idle" &&
-                      props.renderedUserMessages.length === 1 &&
-                      messageID === props.renderedUserMessages[0]?.id)
                   const active = createMemo(() => activeMessageID() === messageID)
                   const queued = createMemo(() => {
                     if (active()) return false
@@ -457,10 +700,7 @@ export function MessageTimeline(props: {
                     return false
                   })
                   const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
-                    equals: (a, b) => {
-                      if (a.length !== b.length) return false
-                      return a.every((x, i) => x.path === b[i].path && x.comment === b[i].comment)
-                    },
+                    equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
                   })
                   const commentCount = createMemo(() => comments().length)
                   return (
@@ -473,7 +713,7 @@ export function MessageTimeline(props: {
                       }}
                       classList={{
                         "min-w-0 w-full max-w-full": true,
-                        "md:max-w-[500px] 2xl:max-w-[700px]": props.centered,
+                        "md:max-w-200 2xl:max-w-[1000px]": props.centered,
                       }}
                     >
                       <Show when={commentCount() > 0}>
@@ -517,7 +757,7 @@ export function MessageTimeline(props: {
                         messageID={messageID}
                         active={active()}
                         queued={queued()}
-                        animate={isNew || active()}
+                        status={active() ? sessionStatus() : undefined}
                         showReasoningSummaries={settings.general.showReasoningSummaries()}
                         shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
                         editToolDefaultOpen={settings.general.editToolPartsExpanded()}

+ 0 - 522
packages/app/src/pages/session/session-timeline-header.tsx

@@ -1,522 +0,0 @@
-import { createEffect, createMemo, on, onCleanup, Show } from "solid-js"
-import { createStore, produce } from "solid-js/store"
-import { useNavigate, useParams } from "@solidjs/router"
-import { Button } from "@opencode-ai/ui/button"
-import { useReducedMotion } from "@opencode-ai/ui/hooks"
-import { IconButton } from "@opencode-ai/ui/icon-button"
-import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
-import { Dialog } from "@opencode-ai/ui/dialog"
-import { InlineInput } from "@opencode-ai/ui/inline-input"
-import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion"
-import { showToast } from "@opencode-ai/ui/toast"
-import { errorMessage } from "@/pages/layout/helpers"
-import { SessionContextUsage } from "@/components/session-context-usage"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { useLanguage } from "@/context/language"
-import { useSDK } from "@/context/sdk"
-import { useSync } from "@/context/sync"
-
-export function SessionTimelineHeader(props: {
-  centered: boolean
-  showHeader: () => boolean
-  sessionKey: () => string
-  sessionID: () => string | undefined
-  parentID: () => string | undefined
-  titleValue: () => string | undefined
-  headerTitle: () => string | undefined
-  placeholderTitle: () => boolean
-}) {
-  const navigate = useNavigate()
-  const params = useParams()
-  const sdk = useSDK()
-  const sync = useSync()
-  const dialog = useDialog()
-  const language = useLanguage()
-  const reduce = useReducedMotion()
-
-  const [title, setTitle] = createStore({
-    draft: "",
-    editing: false,
-    saving: false,
-    menuOpen: false,
-    pendingRename: false,
-  })
-  const [headerText, setHeaderText] = createStore({
-    session: props.sessionKey(),
-    value: props.headerTitle(),
-    prev: undefined as string | undefined,
-    muted: props.placeholderTitle(),
-    prevMuted: false,
-  })
-  let headerAnim: AnimationPlaybackControls | undefined
-  let enterAnim: AnimationPlaybackControls | undefined
-  let leaveAnim: AnimationPlaybackControls | undefined
-  let titleRef: HTMLInputElement | undefined
-  let headerRef: HTMLDivElement | undefined
-  let enterRef: HTMLSpanElement | undefined
-  let leaveRef: HTMLSpanElement | undefined
-
-  const clearHeaderAnim = () => {
-    headerAnim?.stop()
-    headerAnim = undefined
-  }
-
-  const animateHeader = () => {
-    const el = headerRef
-    if (!el) return
-
-    clearHeaderAnim()
-    if (!headerText.muted || reduce()) {
-      el.style.opacity = "1"
-      return
-    }
-
-    headerAnim = animate(el, { opacity: [0, 1] }, { type: "spring", visualDuration: 1.0, bounce: 0 })
-    headerAnim.finished.then(() => {
-      if (headerRef !== el) return
-      clearFadeStyles(el)
-    })
-  }
-
-  const clearTitleAnims = () => {
-    enterAnim?.stop()
-    enterAnim = undefined
-    leaveAnim?.stop()
-    leaveAnim = undefined
-  }
-
-  const settleTitleEnter = () => {
-    if (enterRef) clearFadeStyles(enterRef)
-  }
-
-  const hideLeave = () => {
-    if (!leaveRef) return
-    leaveRef.style.opacity = "0"
-    leaveRef.style.filter = ""
-    leaveRef.style.transform = ""
-  }
-
-  const animateEnterSpan = () => {
-    if (!enterRef) return
-    if (reduce()) {
-      settleTitleEnter()
-      return
-    }
-    enterAnim = animate(
-      enterRef,
-      { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"], transform: ["translateY(-2px)", "translateY(0)"] },
-      FAST_SPRING,
-    )
-    enterAnim.finished.then(() => settleTitleEnter())
-  }
-
-  const crossfadeTitle = (nextTitle: string, nextMuted: boolean) => {
-    clearTitleAnims()
-    setHeaderText({ prev: headerText.value, prevMuted: headerText.muted })
-    setHeaderText({ value: nextTitle, muted: nextMuted })
-
-    if (reduce()) {
-      setHeaderText({ prev: undefined, prevMuted: false })
-      hideLeave()
-      settleTitleEnter()
-      return
-    }
-
-    if (leaveRef) {
-      leaveAnim = animate(
-        leaveRef,
-        { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"], transform: ["translateY(0)", "translateY(2px)"] },
-        FAST_SPRING,
-      )
-      leaveAnim.finished.then(() => {
-        setHeaderText({ prev: undefined, prevMuted: false })
-        hideLeave()
-      })
-    }
-
-    animateEnterSpan()
-  }
-
-  const fadeInTitle = (nextTitle: string, nextMuted: boolean) => {
-    clearTitleAnims()
-    setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
-    animateEnterSpan()
-  }
-
-  const snapTitle = (nextTitle: string | undefined, nextMuted: boolean) => {
-    clearTitleAnims()
-    setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
-    settleTitleEnter()
-  }
-
-  createEffect(
-    on(props.showHeader, (show, prev) => {
-      if (!show) {
-        clearHeaderAnim()
-        return
-      }
-      if (show === prev) return
-      animateHeader()
-    }),
-  )
-
-  createEffect(
-    on(
-      () => [props.sessionKey(), props.headerTitle(), props.placeholderTitle()] as const,
-      ([nextSession, nextTitle, nextMuted]) => {
-        if (nextSession !== headerText.session) {
-          setHeaderText("session", nextSession)
-          if (nextTitle && nextMuted) {
-            fadeInTitle(nextTitle, nextMuted)
-            return
-          }
-          snapTitle(nextTitle, nextMuted)
-          return
-        }
-        if (nextTitle === headerText.value && nextMuted === headerText.muted) return
-        if (!nextTitle) {
-          snapTitle(undefined, false)
-          return
-        }
-        if (!headerText.value) {
-          fadeInTitle(nextTitle, nextMuted)
-          return
-        }
-        if (title.saving || title.editing) {
-          snapTitle(nextTitle, nextMuted)
-          return
-        }
-        crossfadeTitle(nextTitle, nextMuted)
-      },
-    ),
-  )
-
-  onCleanup(() => {
-    clearHeaderAnim()
-    clearTitleAnims()
-  })
-
-  const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed"))
-
-  createEffect(
-    on(
-      props.sessionKey,
-      () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
-      { defer: true },
-    ),
-  )
-
-  const openTitleEditor = () => {
-    if (!props.sessionID()) return
-    setTitle({ editing: true, draft: props.titleValue() ?? "" })
-    requestAnimationFrame(() => {
-      titleRef?.focus()
-      titleRef?.select()
-    })
-  }
-
-  const closeTitleEditor = () => {
-    if (title.saving) return
-    setTitle({ editing: false, saving: false })
-  }
-
-  const saveTitleEditor = async () => {
-    const id = props.sessionID()
-    if (!id) return
-    if (title.saving) return
-
-    const next = title.draft.trim()
-    if (!next || next === (props.titleValue() ?? "")) {
-      setTitle({ editing: false, saving: false })
-      return
-    }
-
-    setTitle("saving", true)
-    await sdk.client.session
-      .update({ sessionID: id, title: next })
-      .then(() => {
-        sync.set(
-          produce((draft) => {
-            const index = draft.session.findIndex((session) => session.id === id)
-            if (index !== -1) draft.session[index].title = next
-          }),
-        )
-        setTitle({ editing: false, saving: false })
-      })
-      .catch((err) => {
-        setTitle("saving", false)
-        showToast({
-          title: language.t("common.requestFailed"),
-          description: toastError(err),
-        })
-      })
-  }
-
-  const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
-    if (params.id !== sessionID) return
-    if (parentID) {
-      navigate(`/${params.dir}/session/${parentID}`)
-      return
-    }
-    if (nextSessionID) {
-      navigate(`/${params.dir}/session/${nextSessionID}`)
-      return
-    }
-    navigate(`/${params.dir}/session`)
-  }
-
-  const archiveSession = async (sessionID: string) => {
-    const session = sync.session.get(sessionID)
-    if (!session) return
-
-    const sessions = sync.data.session ?? []
-    const index = sessions.findIndex((item) => item.id === sessionID)
-    const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
-
-    await sdk.client.session
-      .update({ sessionID, time: { archived: Date.now() } })
-      .then(() => {
-        sync.set(
-          produce((draft) => {
-            const index = draft.session.findIndex((item) => item.id === sessionID)
-            if (index !== -1) draft.session.splice(index, 1)
-          }),
-        )
-        navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
-      })
-      .catch((err) => {
-        showToast({
-          title: language.t("common.requestFailed"),
-          description: toastError(err),
-        })
-      })
-  }
-
-  const deleteSession = async (sessionID: string) => {
-    const session = sync.session.get(sessionID)
-    if (!session) return false
-
-    const sessions = (sync.data.session ?? []).filter((item) => !item.parentID && !item.time?.archived)
-    const index = sessions.findIndex((item) => item.id === sessionID)
-    const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
-
-    const result = await sdk.client.session
-      .delete({ sessionID })
-      .then((x) => x.data)
-      .catch((err) => {
-        showToast({
-          title: language.t("session.delete.failed.title"),
-          description: toastError(err),
-        })
-        return false
-      })
-
-    if (!result) return false
-
-    sync.set(
-      produce((draft) => {
-        const removed = new Set<string>([sessionID])
-        const byParent = new Map<string, string[]>()
-
-        for (const item of draft.session) {
-          const parentID = item.parentID
-          if (!parentID) continue
-
-          const existing = byParent.get(parentID)
-          if (existing) {
-            existing.push(item.id)
-            continue
-          }
-          byParent.set(parentID, [item.id])
-        }
-
-        const stack = [sessionID]
-        while (stack.length) {
-          const parentID = stack.pop()
-          if (!parentID) continue
-
-          const children = byParent.get(parentID)
-          if (!children) continue
-
-          for (const child of children) {
-            if (removed.has(child)) continue
-            removed.add(child)
-            stack.push(child)
-          }
-        }
-
-        draft.session = draft.session.filter((item) => !removed.has(item.id))
-      }),
-    )
-
-    navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
-    return true
-  }
-
-  const navigateParent = () => {
-    const id = props.parentID()
-    if (!id) return
-    navigate(`/${params.dir}/session/${id}`)
-  }
-
-  function DialogDeleteSession(input: { sessionID: string }) {
-    const name = createMemo(() => sync.session.get(input.sessionID)?.title ?? language.t("command.session.new"))
-
-    const handleDelete = async () => {
-      await deleteSession(input.sessionID)
-      dialog.close()
-    }
-
-    return (
-      <Dialog title={language.t("session.delete.title")} fit>
-        <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
-          <div class="flex flex-col gap-1">
-            <span class="text-14-regular text-text-strong">
-              {language.t("session.delete.confirm", { name: name() })}
-            </span>
-          </div>
-          <div class="flex justify-end gap-2">
-            <Button variant="ghost" size="large" onClick={() => dialog.close()}>
-              {language.t("common.cancel")}
-            </Button>
-            <Button variant="primary" size="large" onClick={handleDelete}>
-              {language.t("session.delete.button")}
-            </Button>
-          </div>
-        </div>
-      </Dialog>
-    )
-  }
-
-  return (
-    <Show when={props.showHeader()}>
-      <div
-        data-session-title
-        ref={(el) => {
-          headerRef = el
-          el.style.opacity = "0"
-        }}
-        class="pointer-events-none absolute inset-x-0 top-0 z-30"
-      >
-        <div
-          classList={{
-            "bg-[linear-gradient(to_bottom,var(--background-stronger)_38px,transparent)]": true,
-            "w-full": true,
-            "pb-10": true,
-            "px-4 md:px-5": true,
-            "md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered,
-          }}
-        >
-          <div class="pointer-events-auto h-12 w-full flex items-center justify-between gap-2">
-            <div class="flex items-center gap-1 min-w-0 flex-1">
-              <Show when={props.parentID()}>
-                <div>
-                  <IconButton
-                    tabIndex={-1}
-                    icon="arrow-left"
-                    variant="ghost"
-                    onClick={navigateParent}
-                    aria-label={language.t("common.goBack")}
-                  />
-                </div>
-              </Show>
-              <Show when={!!headerText.value || title.editing}>
-                <Show
-                  when={title.editing}
-                  fallback={
-                    <h1 class="text-14-medium text-text-strong grow-1 min-w-0" onDblClick={openTitleEditor}>
-                      <span class="grid min-w-0" style={{ overflow: "clip" }}>
-                        <span ref={enterRef} class="col-start-1 row-start-1 min-w-0 truncate">
-                          <span classList={{ "opacity-60": headerText.muted }}>{headerText.value}</span>
-                        </span>
-                        <span
-                          ref={leaveRef}
-                          class="col-start-1 row-start-1 min-w-0 truncate pointer-events-none"
-                          style={{ opacity: "0" }}
-                        >
-                          <span classList={{ "opacity-60": headerText.prevMuted }}>{headerText.prev}</span>
-                        </span>
-                      </span>
-                    </h1>
-                  }
-                >
-                  <InlineInput
-                    ref={(el) => {
-                      titleRef = el
-                    }}
-                    value={title.draft}
-                    disabled={title.saving}
-                    class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
-                    style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
-                    onInput={(event) => setTitle("draft", event.currentTarget.value)}
-                    onKeyDown={(event) => {
-                      event.stopPropagation()
-                      if (event.key === "Enter") {
-                        event.preventDefault()
-                        void saveTitleEditor()
-                        return
-                      }
-                      if (event.key === "Escape") {
-                        event.preventDefault()
-                        closeTitleEditor()
-                      }
-                    }}
-                    onBlur={closeTitleEditor}
-                  />
-                </Show>
-              </Show>
-            </div>
-            <Show when={props.sessionID()}>
-              {(id) => (
-                <div class="shrink-0 flex items-center gap-3">
-                  <SessionContextUsage placement="bottom" />
-                  <DropdownMenu
-                    gutter={4}
-                    placement="bottom-end"
-                    open={title.menuOpen}
-                    onOpenChange={(open) => setTitle("menuOpen", open)}
-                  >
-                    <DropdownMenu.Trigger
-                      as={IconButton}
-                      icon="dot-grid"
-                      variant="ghost"
-                      class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
-                      aria-label={language.t("common.moreOptions")}
-                    />
-                    <DropdownMenu.Portal>
-                      <DropdownMenu.Content
-                        style={{ "min-width": "104px" }}
-                        onCloseAutoFocus={(event) => {
-                          if (!title.pendingRename) return
-                          event.preventDefault()
-                          setTitle("pendingRename", false)
-                          openTitleEditor()
-                        }}
-                      >
-                        <DropdownMenu.Item
-                          onSelect={() => {
-                            setTitle("pendingRename", true)
-                            setTitle("menuOpen", false)
-                          }}
-                        >
-                          <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
-                        </DropdownMenu.Item>
-                        <DropdownMenu.Item onSelect={() => void archiveSession(id())}>
-                          <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
-                        </DropdownMenu.Item>
-                        <DropdownMenu.Separator />
-                        <DropdownMenu.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}>
-                          <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
-                        </DropdownMenu.Item>
-                      </DropdownMenu.Content>
-                    </DropdownMenu.Portal>
-                  </DropdownMenu>
-                </div>
-              )}
-            </Show>
-          </div>
-        </div>
-      </div>
-    </Show>
-  )
-}

+ 26 - 26
packages/app/src/pages/session/use-session-hash-scroll.ts

@@ -1,5 +1,6 @@
 import type { UserMessage } from "@opencode-ai/sdk/v2"
-import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
+import { useLocation, useNavigate } from "@solidjs/router"
+import { createEffect, createMemo, onMount } from "solid-js"
 import { messageIdFromHash } from "./message-id-from-hash"
 
 export { messageIdFromHash } from "./message-id-from-hash"
@@ -15,7 +16,7 @@ export const useSessionHashScroll = (input: {
   setPendingMessage: (value: string | undefined) => void
   setActiveMessage: (message: UserMessage | undefined) => void
   setTurnStart: (value: number) => void
-  autoScroll: { pause: () => void; snapToBottom: () => void }
+  autoScroll: { pause: () => void; forceScrollToBottom: () => void }
   scroller: () => HTMLDivElement | undefined
   anchor: (id: string) => string
   scheduleScrollState: (el: HTMLDivElement) => void
@@ -26,13 +27,18 @@ export const useSessionHashScroll = (input: {
   const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
   let pendingKey = ""
 
+  const location = useLocation()
+  const navigate = useNavigate()
+
   const clearMessageHash = () => {
-    if (!window.location.hash) return
-    window.history.replaceState(null, "", window.location.pathname + window.location.search)
+    if (!location.hash) return
+    navigate(location.pathname + location.search, { replace: true })
   }
 
   const updateHash = (id: string) => {
-    window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${input.anchor(id)}`)
+    navigate(location.pathname + location.search + `#${input.anchor(id)}`, {
+      replace: true,
+    })
   }
 
   const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
@@ -41,15 +47,15 @@ export const useSessionHashScroll = (input: {
 
     const a = el.getBoundingClientRect()
     const b = root.getBoundingClientRect()
-    const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
-    const inset = Number.isNaN(title) ? 0 : title
-    // With column-reverse, scrollTop is negative — don't clamp to 0
-    const top = a.top - b.top + root.scrollTop - inset
+    const sticky = root.querySelector("[data-session-title]")
+    const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0
+    const top = Math.max(0, a.top - b.top + root.scrollTop - inset)
     root.scrollTo({ top, behavior })
     return true
   }
 
   const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
+    console.log({ message, behavior })
     if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
 
     const index = messageIndex().get(message.id) ?? -1
@@ -97,9 +103,9 @@ export const useSessionHashScroll = (input: {
   }
 
   const applyHash = (behavior: ScrollBehavior) => {
-    const hash = window.location.hash.slice(1)
+    const hash = location.hash.slice(1)
     if (!hash) {
-      input.autoScroll.snapToBottom()
+      input.autoScroll.forceScrollToBottom()
       const el = input.scroller()
       if (el) input.scheduleScrollState(el)
       return
@@ -123,26 +129,13 @@ export const useSessionHashScroll = (input: {
       return
     }
 
-    input.autoScroll.snapToBottom()
+    input.autoScroll.forceScrollToBottom()
     const el = input.scroller()
     if (el) input.scheduleScrollState(el)
   }
 
-  onMount(() => {
-    if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
-      window.history.scrollRestoration = "manual"
-    }
-
-    const handler = () => {
-      if (!input.sessionID() || !input.messagesReady()) return
-      requestAnimationFrame(() => applyHash("auto"))
-    }
-
-    window.addEventListener("hashchange", handler)
-    onCleanup(() => window.removeEventListener("hashchange", handler))
-  })
-
   createEffect(() => {
+    location.hash
     if (!input.sessionID() || !input.messagesReady()) return
     requestAnimationFrame(() => applyHash("auto"))
   })
@@ -166,6 +159,7 @@ export const useSessionHashScroll = (input: {
       }
     }
 
+    if (!targetId) targetId = messageIdFromHash(location.hash)
     if (!targetId) return
     if (input.currentMessageId() === targetId) return
 
@@ -177,6 +171,12 @@ export const useSessionHashScroll = (input: {
     requestAnimationFrame(() => scrollToMessage(msg, "auto"))
   })
 
+  onMount(() => {
+    if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
+      window.history.scrollRestoration = "manual"
+    }
+  })
+
   return {
     clearMessageHash,
     scrollToMessage,

+ 0 - 3
packages/ui/package.json

@@ -48,11 +48,8 @@
     "@pierre/diffs": "catalog:",
     "@shikijs/transformers": "3.9.2",
     "@solid-primitives/bounds": "0.1.3",
-    "@solid-primitives/lifecycle": "0.1.2",
     "@solid-primitives/media": "2.3.3",
-    "@solid-primitives/page-visibility": "2.1.1",
     "@solid-primitives/resize-observer": "2.1.3",
-    "@solid-primitives/rootless": "1.5.2",
     "@solidjs/meta": "catalog:",
     "@solidjs/router": "catalog:",
     "dompurify": "3.3.1",

+ 5 - 6
packages/ui/src/components/animated-number.css

@@ -9,20 +9,19 @@
     display: inline-flex;
     flex-direction: row-reverse;
     align-items: baseline;
-    justify-content: flex-start;
+    justify-content: flex-end;
     line-height: inherit;
     width: var(--animated-number-width, 1ch);
-    overflow: clip;
-    transition: width var(--tool-motion-spring-ms, 800ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
+    overflow: hidden;
+    transition: width var(--tool-motion-spring-ms, 560ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
   }
 
   [data-slot="animated-number-digit"] {
     display: inline-block;
-    flex-shrink: 0;
     width: 1ch;
     height: 1em;
     line-height: 1em;
-    overflow: clip;
+    overflow: hidden;
     vertical-align: baseline;
     -webkit-mask-image: linear-gradient(
       to bottom,
@@ -47,7 +46,7 @@
     flex-direction: column;
     transform: translateY(calc(var(--animated-number-offset, 10) * -1em));
     transition-property: transform;
-    transition-duration: var(--animated-number-duration, 600ms);
+    transition-duration: var(--animated-number-duration, 560ms);
     transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
   }
 

+ 2 - 27
packages/ui/src/components/animated-number.tsx

@@ -1,7 +1,7 @@
 import { For, Index, createEffect, createMemo, createSignal, on } from "solid-js"
 
 const TRACK = Array.from({ length: 30 }, (_, index) => index % 10)
-const DURATION = 800
+const DURATION = 600
 
 function normalize(value: number) {
   return ((value % 10) + 10) % 10
@@ -90,35 +90,10 @@ export function AnimatedNumber(props: { value: number; class?: string }) {
   )
   const width = createMemo(() => `${digits().length}ch`)
 
-  const [exitingDigits, setExitingDigits] = createSignal<number[]>([])
-  let exitTimer: number | undefined
-
-  createEffect(
-    on(
-      digits,
-      (current, prev) => {
-        if (prev && current.length < prev.length) {
-          setExitingDigits(prev.slice(current.length))
-          clearTimeout(exitTimer)
-          exitTimer = window.setTimeout(() => setExitingDigits([]), DURATION)
-        } else {
-          clearTimeout(exitTimer)
-          setExitingDigits([])
-        }
-      },
-      { defer: true },
-    ),
-  )
-
-  const displayDigits = createMemo(() => {
-    const exiting = exitingDigits()
-    return exiting.length ? [...digits(), ...exiting] : digits()
-  })
-
   return (
     <span data-component="animated-number" class={props.class} aria-label={label()}>
       <span data-slot="animated-number-value" style={{ "--animated-number-width": width() }}>
-        <Index each={displayDigits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
+        <Index each={digits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
       </span>
     </span>
   )

+ 47 - 13
packages/ui/src/components/basic-tool.css

@@ -8,28 +8,54 @@
   justify-content: flex-start;
 
   [data-slot="basic-tool-tool-trigger-content"] {
-    width: 100%;
-    min-width: 0;
+    width: auto;
     display: flex;
     align-items: center;
     align-self: stretch;
     gap: 8px;
   }
 
+  [data-slot="basic-tool-tool-indicator"] {
+    width: 16px;
+    height: 16px;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    flex-shrink: 0;
+
+    [data-component="spinner"] {
+      width: 16px;
+      height: 16px;
+    }
+  }
+
+  [data-slot="basic-tool-tool-spinner"] {
+    width: 16px;
+    height: 16px;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    flex-shrink: 0;
+    color: var(--text-weak);
+
+    [data-component="spinner"] {
+      width: 16px;
+      height: 16px;
+    }
+  }
+
   [data-slot="icon-svg"] {
     flex-shrink: 0;
   }
 
   [data-slot="basic-tool-tool-info"] {
-    flex: 1 1 auto;
+    flex: 0 1 auto;
     min-width: 0;
     font-size: 14px;
   }
 
   [data-slot="basic-tool-tool-info-structured"] {
     width: auto;
-    max-width: 100%;
-    min-width: 0;
     display: flex;
     align-items: center;
     gap: 8px;
@@ -37,12 +63,11 @@
   }
 
   [data-slot="basic-tool-tool-info-main"] {
-    flex: 0 1 auto;
     display: flex;
-    align-items: center;
+    align-items: baseline;
     gap: 8px;
     min-width: 0;
-    overflow: clip;
+    overflow: hidden;
   }
 
   [data-slot="basic-tool-tool-title"] {
@@ -54,14 +79,22 @@
     line-height: var(--line-height-large);
     letter-spacing: var(--letter-spacing-normal);
     color: var(--text-strong);
+
+    &.capitalize {
+      text-transform: capitalize;
+    }
+
+    &.agent-title {
+      color: var(--text-strong);
+      font-weight: var(--font-weight-medium);
+    }
   }
 
   [data-slot="basic-tool-tool-subtitle"] {
-    display: inline-block;
-    flex: 0 1 auto;
-    max-width: 100%;
+    flex-shrink: 1;
     min-width: 0;
-    overflow: clip;
+    overflow: hidden;
+    text-overflow: ellipsis;
     white-space: nowrap;
     font-family: var(--font-family-sans);
     font-variant-numeric: tabular-nums;
@@ -106,7 +139,8 @@
   [data-slot="basic-tool-tool-arg"] {
     flex-shrink: 1;
     min-width: 0;
-    overflow: clip;
+    overflow: hidden;
+    text-overflow: ellipsis;
     white-space: nowrap;
     font-family: var(--font-family-sans);
     font-variant-numeric: tabular-nums;

+ 97 - 259
packages/ui/src/components/basic-tool.tsx

@@ -1,20 +1,8 @@
-import {
-  createEffect,
-  createSignal,
-  For,
-  Match,
-  on,
-  onCleanup,
-  onMount,
-  Show,
-  splitProps,
-  Switch,
-  type JSX,
-} from "solid-js"
-import { animate, type AnimationPlaybackControls, tunableSpringValue, COLLAPSIBLE_SPRING } from "./motion"
+import { createEffect, createSignal, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
+import { animate, type AnimationPlaybackControls } from "motion"
 import { Collapsible } from "./collapsible"
+import type { IconProps } from "./icon"
 import { TextShimmer } from "./text-shimmer"
-import { hold } from "./tool-utils"
 
 export type TriggerTitle = {
   title: string
@@ -32,99 +20,26 @@ const isTriggerTitle = (val: any): val is TriggerTitle => {
   )
 }
 
-interface ToolCallPanelBaseProps {
-  icon: string
+export interface BasicToolProps {
+  icon: IconProps["name"]
   trigger: TriggerTitle | JSX.Element
   children?: JSX.Element
   status?: string
-  animate?: boolean
   hideDetails?: boolean
   defaultOpen?: boolean
   forceOpen?: boolean
   defer?: boolean
   locked?: boolean
-  watchDetails?: boolean
-  springContent?: boolean
+  animated?: boolean
   onSubtitleClick?: () => void
 }
 
-function ToolCallTriggerBody(props: {
-  trigger: TriggerTitle | JSX.Element
-  pending: boolean
-  onSubtitleClick?: () => void
-  arrow?: boolean
-}) {
-  return (
-    <div data-component="tool-trigger" data-arrow={props.arrow ? "" : undefined}>
-      <div data-slot="basic-tool-tool-trigger-content">
-        <div data-slot="basic-tool-tool-info">
-          <Switch>
-            <Match when={isTriggerTitle(props.trigger) && props.trigger}>
-              {(trigger) => (
-                <div data-slot="basic-tool-tool-info-structured">
-                  <div data-slot="basic-tool-tool-info-main">
-                    <span
-                      data-slot="basic-tool-tool-title"
-                      classList={{
-                        [trigger().titleClass ?? ""]: !!trigger().titleClass,
-                      }}
-                    >
-                      <TextShimmer text={trigger().title} active={props.pending} />
-                    </span>
-                    <Show when={!props.pending}>
-                      <Show when={trigger().subtitle}>
-                        <span
-                          data-slot="basic-tool-tool-subtitle"
-                          classList={{
-                            [trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
-                            clickable: !!props.onSubtitleClick,
-                          }}
-                          onClick={(e) => {
-                            if (!props.onSubtitleClick) return
-                            e.stopPropagation()
-                            props.onSubtitleClick()
-                          }}
-                        >
-                          {trigger().subtitle}
-                        </span>
-                      </Show>
-                      <Show when={trigger().args?.length}>
-                        <For each={trigger().args}>
-                          {(arg) => (
-                            <span
-                              data-slot="basic-tool-tool-arg"
-                              classList={{
-                                [trigger().argsClass ?? ""]: !!trigger().argsClass,
-                              }}
-                            >
-                              {arg}
-                            </span>
-                          )}
-                        </For>
-                      </Show>
-                    </Show>
-                  </div>
-                  <Show when={!props.pending && trigger().action}>{trigger().action}</Show>
-                </div>
-              )}
-            </Match>
-            <Match when={true}>{props.trigger as JSX.Element}</Match>
-          </Switch>
-        </div>
-      </div>
-      <Show when={props.arrow}>
-        <Collapsible.Arrow />
-      </Show>
-    </div>
-  )
-}
+const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
 
-function ToolCallPanel(props: ToolCallPanelBaseProps) {
+export function BasicTool(props: BasicToolProps) {
   const [open, setOpen] = createSignal(props.defaultOpen ?? false)
   const [ready, setReady] = createSignal(open())
-  const pendingRaw = () => props.status === "pending" || props.status === "running"
-  const pending = hold(pendingRaw, 1000)
-  const watchDetails = () => props.watchDetails !== false
+  const pending = () => props.status === "pending" || props.status === "running"
 
   let frame: number | undefined
 
@@ -144,7 +59,7 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) {
     on(
       open,
       (value) => {
-        if (!props.defer || props.springContent) return
+        if (!props.defer) return
         if (!value) {
           cancel()
           setReady(false)
@@ -162,110 +77,36 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) {
     ),
   )
 
-  // Animated content height — single springValue drives all height changes
+  // Animated height for collapsible open/close
   let contentRef: HTMLDivElement | undefined
-  let bodyRef: HTMLDivElement | undefined
-  let fadeAnim: AnimationPlaybackControls | undefined
-  let observer: ResizeObserver | undefined
-  let resizeFrame: number | undefined
+  let heightAnim: AnimationPlaybackControls | undefined
   const initialOpen = open()
-  const heightSpring = tunableSpringValue<number>(0, COLLAPSIBLE_SPRING)
-
-  const read = () => Math.max(0, Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0))
-
-  const doOpen = () => {
-    if (!contentRef || !bodyRef) return
-    contentRef.style.display = ""
-    // Ensure fade starts from 0 if content was hidden (first open or after close cleared styles)
-    if (bodyRef.style.opacity === "") {
-      bodyRef.style.opacity = "0"
-      bodyRef.style.filter = "blur(2px)"
-    }
-    const next = read()
-    fadeAnim?.stop()
-    fadeAnim = animate(bodyRef, { opacity: 1, filter: "blur(0px)" }, COLLAPSIBLE_SPRING)
-    fadeAnim.finished.then(() => {
-      if (!bodyRef) return
-      bodyRef.style.opacity = ""
-      bodyRef.style.filter = ""
-    })
-    heightSpring.set(next)
-  }
-
-  const doClose = () => {
-    if (!contentRef || !bodyRef) return
-    fadeAnim?.stop()
-    fadeAnim = animate(bodyRef, { opacity: 0, filter: "blur(2px)" }, COLLAPSIBLE_SPRING)
-    fadeAnim.finished.then(() => {
-      if (!contentRef || open()) return
-      contentRef.style.display = "none"
-    })
-    heightSpring.set(0)
-  }
-
-  const grow = () => {
-    if (!contentRef || !open()) return
-    const next = read()
-    if (Math.abs(next - heightSpring.get()) < 1) return
-    heightSpring.set(next)
-  }
-
-  onMount(() => {
-    if (!props.springContent || props.animate === false || !contentRef || !bodyRef) return
-
-    const offChange = heightSpring.on("change", (v) => {
-      if (!contentRef) return
-      contentRef.style.height = `${Math.max(0, Math.ceil(v))}px`
-    })
-    onCleanup(() => {
-      offChange()
-    })
-
-    if (watchDetails()) {
-      observer = new ResizeObserver(() => {
-        if (resizeFrame !== undefined) return
-        resizeFrame = requestAnimationFrame(() => {
-          resizeFrame = undefined
-          grow()
-        })
-      })
-      observer.observe(bodyRef)
-    }
-
-    if (!open()) return
-    if (contentRef.style.display !== "none") {
-      const next = read()
-      heightSpring.jump(next)
-      contentRef.style.height = `${next}px`
-      return
-    }
-    let mountFrame: number | undefined = requestAnimationFrame(() => {
-      mountFrame = undefined
-      if (!open()) return
-      doOpen()
-    })
-    onCleanup(() => {
-      if (mountFrame !== undefined) cancelAnimationFrame(mountFrame)
-    })
-  })
 
   createEffect(
     on(
       open,
       (isOpen) => {
-        if (!props.springContent || props.animate === false || !contentRef) return
-        if (isOpen) doOpen()
-        else doClose()
+        if (!props.animated || !contentRef) return
+        heightAnim?.stop()
+        if (isOpen) {
+          contentRef.style.overflow = "hidden"
+          heightAnim = animate(contentRef, { height: "auto" }, SPRING)
+          heightAnim.finished.then(() => {
+            if (!contentRef || !open()) return
+            contentRef.style.overflow = "visible"
+            contentRef.style.height = "auto"
+          })
+        } else {
+          contentRef.style.overflow = "hidden"
+          heightAnim = animate(contentRef, { height: "0px" }, SPRING)
+        }
       },
       { defer: true },
     ),
   )
 
   onCleanup(() => {
-    if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
-    observer?.disconnect()
-    fadeAnim?.stop()
-    heightSpring.destroy()
+    heightAnim?.stop()
   })
 
   const handleOpenChange = (value: boolean) => {
@@ -277,34 +118,85 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) {
   return (
     <Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
       <Collapsible.Trigger>
-        <ToolCallTriggerBody
-          trigger={props.trigger}
-          pending={pending()}
-          onSubtitleClick={props.onSubtitleClick}
-          arrow={!!props.children && !props.hideDetails && !props.locked && !pending()}
-        />
+        <div data-component="tool-trigger">
+          <div data-slot="basic-tool-tool-trigger-content">
+            <div data-slot="basic-tool-tool-info">
+              <Switch>
+                <Match when={isTriggerTitle(props.trigger) && props.trigger}>
+                  {(trigger) => (
+                    <div data-slot="basic-tool-tool-info-structured">
+                      <div data-slot="basic-tool-tool-info-main">
+                        <span
+                          data-slot="basic-tool-tool-title"
+                          classList={{
+                            [trigger().titleClass ?? ""]: !!trigger().titleClass,
+                          }}
+                        >
+                          <TextShimmer text={trigger().title} active={pending()} />
+                        </span>
+                        <Show when={!pending()}>
+                          <Show when={trigger().subtitle}>
+                            <span
+                              data-slot="basic-tool-tool-subtitle"
+                              classList={{
+                                [trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
+                                clickable: !!props.onSubtitleClick,
+                              }}
+                              onClick={(e) => {
+                                if (props.onSubtitleClick) {
+                                  e.stopPropagation()
+                                  props.onSubtitleClick()
+                                }
+                              }}
+                            >
+                              {trigger().subtitle}
+                            </span>
+                          </Show>
+                          <Show when={trigger().args?.length}>
+                            <For each={trigger().args}>
+                              {(arg) => (
+                                <span
+                                  data-slot="basic-tool-tool-arg"
+                                  classList={{
+                                    [trigger().argsClass ?? ""]: !!trigger().argsClass,
+                                  }}
+                                >
+                                  {arg}
+                                </span>
+                              )}
+                            </For>
+                          </Show>
+                        </Show>
+                      </div>
+                      <Show when={!pending() && trigger().action}>{trigger().action}</Show>
+                    </div>
+                  )}
+                </Match>
+                <Match when={true}>{props.trigger as JSX.Element}</Match>
+              </Switch>
+            </div>
+          </div>
+          <Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
+            <Collapsible.Arrow />
+          </Show>
+        </div>
       </Collapsible.Trigger>
-      <Show when={props.springContent && props.animate !== false && props.children && !props.hideDetails}>
+      <Show when={props.animated && props.children && !props.hideDetails}>
         <div
           ref={contentRef}
           data-slot="collapsible-content"
-          data-spring-content
+          data-animated
           style={{
             height: initialOpen ? "auto" : "0px",
-            overflow: "hidden",
-            display: initialOpen ? undefined : "none",
+            overflow: initialOpen ? "visible" : "hidden",
           }}
         >
-          <div ref={bodyRef} data-slot="basic-tool-content-inner">
-            {props.children}
-          </div>
+          {props.children}
         </div>
       </Show>
-      <Show when={(!props.springContent || props.animate === false) && props.children && !props.hideDetails}>
+      <Show when={!props.animated && props.children && !props.hideDetails}>
         <Collapsible.Content>
-          <Show when={!props.defer || ready()}>
-            <div data-slot="basic-tool-content-inner">{props.children}</div>
-          </Show>
+          <Show when={!props.defer || ready()}>{props.children}</Show>
         </Collapsible.Content>
       </Show>
     </Collapsible>
@@ -330,60 +222,6 @@ function args(input: Record<string, unknown> | undefined) {
     .slice(0, 3)
 }
 
-export interface ToolCallRowProps {
-  variant: "row"
-  icon: string
-  trigger: TriggerTitle | JSX.Element
-  status?: string
-  animate?: boolean
-  onSubtitleClick?: () => void
-  open?: boolean
-  showArrow?: boolean
-  onOpenChange?: (value: boolean) => void
-}
-export interface ToolCallPanelProps extends Omit<ToolCallPanelBaseProps, "hideDetails"> {
-  variant: "panel"
-}
-export type ToolCallProps = ToolCallRowProps | ToolCallPanelProps
-function ToolCallRoot(props: ToolCallProps) {
-  const pending = () => props.status === "pending" || props.status === "running"
-  if (props.variant === "row") {
-    return (
-      <Show
-        when={props.onOpenChange}
-        fallback={
-          <div data-component="collapsible" data-variant="normal" class="tool-collapsible">
-            <div data-slot="collapsible-trigger">
-              <ToolCallTriggerBody
-                trigger={props.trigger}
-                pending={pending()}
-                onSubtitleClick={props.onSubtitleClick}
-              />
-            </div>
-          </div>
-        }
-      >
-        {(onOpenChange) => (
-          <Collapsible open={props.open ?? true} onOpenChange={onOpenChange()} class="tool-collapsible">
-            <Collapsible.Trigger>
-              <ToolCallTriggerBody
-                trigger={props.trigger}
-                pending={pending()}
-                onSubtitleClick={props.onSubtitleClick}
-                arrow={!!props.showArrow}
-              />
-            </Collapsible.Trigger>
-          </Collapsible>
-        )}
-      </Show>
-    )
-  }
-
-  const [, rest] = splitProps(props, ["variant"])
-  return <ToolCallPanel {...rest} />
-}
-export const ToolCall = ToolCallRoot
-
 export function GenericTool(props: {
   tool: string
   status?: string
@@ -391,8 +229,7 @@ export function GenericTool(props: {
   input?: Record<string, unknown>
 }) {
   return (
-    <ToolCall
-      variant={props.hideDetails ? "row" : "panel"}
+    <BasicTool
       icon="mcp"
       status={props.status}
       trigger={{
@@ -400,6 +237,7 @@ export function GenericTool(props: {
         subtitle: label(props.input),
         args: args(props.input),
       }}
+      hideDetails={props.hideDetails}
     />
   )
 }

+ 32 - 23
packages/ui/src/components/collapsible.css

@@ -8,18 +8,14 @@
   border-radius: var(--radius-md);
   overflow: visible;
 
-  &.tool-collapsible [data-slot="collapsible-trigger"] {
-    height: 37px;
-  }
-
-  &.tool-collapsible [data-slot="basic-tool-content-inner"] {
-    padding-top: 0;
+  &.tool-collapsible {
+    gap: 8px;
   }
 
   [data-slot="collapsible-trigger"] {
     width: 100%;
     display: flex;
-    height: 36px;
+    height: 32px;
     padding: 0;
     align-items: center;
     align-self: stretch;
@@ -27,17 +23,6 @@
     user-select: none;
     color: var(--text-base);
 
-    > [data-component="tool-trigger"][data-arrow] {
-      width: auto;
-      max-width: 100%;
-      flex: 0 1 auto;
-
-      [data-slot="basic-tool-tool-trigger-content"] {
-        width: auto;
-        max-width: 100%;
-      }
-    }
-
     [data-slot="collapsible-arrow"] {
       opacity: 0;
       transition: opacity 0.15s ease;
@@ -65,6 +50,9 @@
     line-height: var(--line-height-large); /* 166.667% */
     letter-spacing: var(--letter-spacing-normal);
 
+    /* &:hover { */
+    /*   background-color: var(--surface-base); */
+    /* } */
     &:focus-visible {
       outline: none;
       background-color: var(--surface-raised-base-hover);
@@ -94,16 +82,16 @@
   }
 
   [data-slot="collapsible-content"] {
-    overflow: clip;
+    overflow: hidden;
+    /* animation: slideUp 250ms ease-out; */
 
     &[data-expanded] {
       overflow: visible;
     }
 
-    /* JS-animated content: overflow managed by animate() */
-    &[data-spring-content] {
-      overflow: clip;
-    }
+    /* &[data-expanded] { */
+    /*   animation: slideDown 250ms ease-out; */
+    /* } */
   }
 
   &[data-variant="ghost"] {
@@ -115,6 +103,9 @@
       border: none;
       padding: 0;
 
+      /* &:hover { */
+      /*   color: var(--text-strong); */
+      /* } */
       &:focus-visible {
         outline: none;
         background-color: var(--surface-raised-base-hover);
@@ -131,3 +122,21 @@
     }
   }
 }
+
+@keyframes slideDown {
+  from {
+    height: 0;
+  }
+  to {
+    height: var(--kb-collapsible-content-height);
+  }
+}
+
+@keyframes slideUp {
+  from {
+    height: var(--kb-collapsible-content-height);
+  }
+  to {
+    height: 0;
+  }
+}

+ 0 - 197
packages/ui/src/components/context-tool-results.tsx

@@ -1,197 +0,0 @@
-import { createMemo, createSignal, For, onMount } from "solid-js"
-import type { ToolPart } from "@opencode-ai/sdk/v2"
-import { getFilename } from "@opencode-ai/util/path"
-import { useReducedMotion } from "../hooks/use-reduced-motion"
-import { useI18n } from "../context/i18n"
-import { ToolCall } from "./basic-tool"
-import { ToolStatusTitle } from "./tool-status-title"
-import { AnimatedCountList } from "./tool-count-summary"
-import { RollingResults } from "./rolling-results"
-import { GROW_SPRING } from "./motion"
-import { useSpring } from "./motion-spring"
-import { busy, updateScrollMask, useCollapsible, useRowWipe } from "./tool-utils"
-
-function contextToolLabel(part: ToolPart): { action: string; detail: string } {
-  const state = part.state
-  const title = "title" in state ? (state.title as string | undefined) : undefined
-  const input = state.input
-  if (part.tool === "read") {
-    const path = input?.filePath as string | undefined
-    return { action: "Read", detail: title || (path ? getFilename(path) : "") }
-  }
-  if (part.tool === "grep") {
-    const pattern = input?.pattern as string | undefined
-    return { action: "Search", detail: title || (pattern ? `"${pattern}"` : "") }
-  }
-  if (part.tool === "glob") {
-    const pattern = input?.pattern as string | undefined
-    return { action: "Find", detail: title || (pattern ?? "") }
-  }
-  if (part.tool === "list") {
-    const path = input?.path as string | undefined
-    return { action: "List", detail: title || (path ? getFilename(path) : "") }
-  }
-  return { action: part.tool, detail: title || "" }
-}
-
-function contextToolSummary(parts: ToolPart[]) {
-  let read = 0
-  let search = 0
-  let list = 0
-  for (const part of parts) {
-    if (part.tool === "read") read++
-    else if (part.tool === "glob" || part.tool === "grep") search++
-    else if (part.tool === "list") list++
-  }
-  return { read, search, list }
-}
-
-export function ContextToolGroupHeader(props: {
-  parts: ToolPart[]
-  pending: boolean
-  open: boolean
-  onOpenChange: (value: boolean) => void
-}) {
-  const i18n = useI18n()
-  const summary = createMemo(() => contextToolSummary(props.parts))
-  return (
-    <ToolCall
-      variant="row"
-      icon="magnifying-glass-menu"
-      open={props.open}
-      showArrow
-      onOpenChange={props.onOpenChange}
-      trigger={
-        <div data-component="context-tool-group-trigger" data-pending={props.pending || undefined}>
-          <span
-            data-slot="context-tool-group-title"
-            class="min-w-0 flex items-center gap-2 text-14-medium text-text-strong"
-          >
-            <span data-slot="context-tool-group-label" class="shrink-0">
-              <ToolStatusTitle
-                active={props.pending}
-                activeText={i18n.t("ui.sessionTurn.status.gatheringContext")}
-                doneText={i18n.t("ui.sessionTurn.status.gatheredContext")}
-                split={false}
-              />
-            </span>
-            <span
-              data-slot="context-tool-group-summary"
-              class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal text-text-base"
-            >
-              <AnimatedCountList
-                items={[
-                  {
-                    key: "read",
-                    count: summary().read,
-                    one: i18n.t("ui.messagePart.context.read.one"),
-                    other: i18n.t("ui.messagePart.context.read.other"),
-                  },
-                  {
-                    key: "search",
-                    count: summary().search,
-                    one: i18n.t("ui.messagePart.context.search.one"),
-                    other: i18n.t("ui.messagePart.context.search.other"),
-                  },
-                  {
-                    key: "list",
-                    count: summary().list,
-                    one: i18n.t("ui.messagePart.context.list.one"),
-                    other: i18n.t("ui.messagePart.context.list.other"),
-                  },
-                ]}
-                fallback=""
-              />
-            </span>
-          </span>
-        </div>
-      }
-    />
-  )
-}
-
-export function ContextToolExpandedList(props: { parts: ToolPart[]; expanded: boolean }) {
-  let contentRef: HTMLDivElement | undefined
-  let bodyRef: HTMLDivElement | undefined
-  let scrollRef: HTMLDivElement | undefined
-  const updateMask = () => {
-    if (scrollRef) updateScrollMask(scrollRef)
-  }
-
-  useCollapsible({
-    content: () => contentRef,
-    body: () => bodyRef,
-    open: () => props.expanded,
-    onOpen: updateMask,
-  })
-
-  return (
-    <div ref={contentRef} style={{ overflow: "clip", height: "0px", display: "none" }}>
-      <div ref={bodyRef}>
-        <div ref={scrollRef} data-component="context-tool-expanded-list" onScroll={updateMask}>
-          <For each={props.parts}>
-            {(part) => {
-              const label = createMemo(() => contextToolLabel(part))
-              return (
-                <div data-component="context-tool-expanded-row">
-                  <span data-slot="context-tool-expanded-action">{label().action}</span>
-                  <span data-slot="context-tool-expanded-detail">{label().detail}</span>
-                </div>
-              )
-            }}
-          </For>
-        </div>
-      </div>
-    </div>
-  )
-}
-
-export function ContextToolRollingResults(props: { parts: ToolPart[]; pending: boolean }) {
-  const reduce = useReducedMotion()
-  const wiped = new Set<string>()
-  const [mounted, setMounted] = createSignal(false)
-  onMount(() => setMounted(true))
-  const show = () => mounted() && props.pending
-  const opacity = useSpring(() => (show() ? 1 : 0), GROW_SPRING)
-  const blur = useSpring(() => (show() ? 0 : 2), GROW_SPRING)
-  return (
-    <div style={{ opacity: reduce() ? (show() ? 1 : 0) : opacity(), filter: `blur(${reduce() ? 0 : blur()}px)` }}>
-      <RollingResults
-        items={props.parts}
-        rows={5}
-        rowHeight={22}
-        rowGap={0}
-        open={props.pending}
-        animate
-        getKey={(part) => part.callID || part.id}
-        render={(part) => {
-          const label = createMemo(() => contextToolLabel(part))
-          const k = part.callID || part.id
-          return (
-            <div data-component="context-tool-rolling-row">
-              <span data-slot="context-tool-rolling-action">{label().action}</span>
-              {(() => {
-                const [detailRef, setDetailRef] = createSignal<HTMLSpanElement>()
-                useRowWipe({
-                  id: () => k,
-                  text: () => label().detail,
-                  ref: detailRef,
-                  seen: wiped,
-                })
-                return (
-                  <span
-                    ref={setDetailRef}
-                    data-slot="context-tool-rolling-detail"
-                    style={{ display: label().detail ? undefined : "none" }}
-                  >
-                    {label().detail}
-                  </span>
-                )
-              })()}
-            </div>
-          )
-        }}
-      />
-    </div>
-  )
-}

+ 0 - 432
packages/ui/src/components/grow-box.tsx

@@ -1,432 +0,0 @@
-import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js"
-import { useReducedMotion } from "../hooks/use-reduced-motion"
-import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion"
-
-export interface GrowBoxProps {
-  children: JSX.Element
-  /** Enable animation. When false, content shows immediately at full height. */
-  animate?: boolean
-  /** Animate height from 0 to content height. Default: true. */
-  grow?: boolean
-  /** Keep watching body size and animate subsequent height changes. Default: false. */
-  watch?: boolean
-  /** Fade in body content (opacity + blur). Default: true. */
-  fade?: boolean
-  /** Top padding in px on the body wrapper. Default: 0. */
-  gap?: number
-  /** Reset to height:auto after grow completes, or stay at fixed px. Default: true. */
-  autoHeight?: boolean
-  /** Controlled visibility for animating open/close without unmounting children. */
-  open?: boolean
-  /** Animate controlled open/close changes after mount. Default: true. */
-  animateToggle?: boolean
-  /** data-slot attribute on the root div. */
-  slot?: string
-  /** CSS class on the root div. */
-  class?: string
-  /** Override mount and resize spring config. Default: GROW_SPRING. */
-  spring?: SpringConfig
-  /** Override controlled open/close spring config. Default: spring. */
-  toggleSpring?: SpringConfig
-  /** Show a temporary bottom edge fade while height animation is running. */
-  edge?: boolean
-  /** Edge fade height in px. Default: 20. */
-  edgeHeight?: number
-  /** Edge fade opacity (0-1). Default: 1. */
-  edgeOpacity?: number
-  /** Delay before edge fades out after height settles. Default: 320. */
-  edgeIdle?: number
-  /** Edge fade-out duration in seconds. Default: 0.24. */
-  edgeFade?: number
-  /** Edge fade-in duration in seconds. Default: 0.2. */
-  edgeRise?: number
-}
-
-/**
- * Wraps children in a container that animates from zero height on mount.
- *
- * Includes a ResizeObserver so content changes after mount are also spring-animated.
- * Used for timeline turns, assistant part groups, and user messages.
- */
-export function GrowBox(props: GrowBoxProps) {
-  const reduce = useReducedMotion()
-  const spring = () => props.spring ?? GROW_SPRING
-  const toggleSpring = () => props.toggleSpring ?? spring()
-  let mode: "mount" | "toggle" = "mount"
-  let root: HTMLDivElement | undefined
-  let body: HTMLDivElement | undefined
-  let fadeAnim: AnimationPlaybackControls | undefined
-  let edgeRef: HTMLDivElement | undefined
-  let edgeAnim: AnimationPlaybackControls | undefined
-  let edgeTimer: ReturnType<typeof setTimeout> | undefined
-  let edgeOn = false
-  let mountFrame: number | undefined
-  let resizeFrame: number | undefined
-  let observer: ResizeObserver | undefined
-  let springTarget = -1
-  const height = tunableSpringValue<number>(0, {
-    type: "spring",
-    get visualDuration() {
-      return (mode === "toggle" ? toggleSpring() : spring()).visualDuration
-    },
-    get bounce() {
-      return (mode === "toggle" ? toggleSpring() : spring()).bounce
-    },
-  })
-
-  const gap = () => Math.max(0, props.gap ?? 0)
-  const grow = () => props.grow !== false
-  const watch = () => props.watch === true
-  const open = () => props.open !== false
-  const animateToggle = () => props.animateToggle !== false
-  const edge = () => props.edge === true
-  const edgeHeight = () => Math.max(0, props.edgeHeight ?? 20)
-  const edgeOpacity = () => Math.min(1, Math.max(0, props.edgeOpacity ?? 1))
-  const edgeIdle = () => Math.max(0, props.edgeIdle ?? 320)
-  const edgeFade = () => Math.max(0.05, props.edgeFade ?? 0.24)
-  const edgeRise = () => Math.max(0.05, props.edgeRise ?? 0.2)
-  const animated = () => props.animate !== false && !reduce()
-  const edgeReady = () => animated() && open() && edge() && edgeHeight() > 0
-
-  const stopEdgeTimer = () => {
-    if (edgeTimer === undefined) return
-    clearTimeout(edgeTimer)
-    edgeTimer = undefined
-  }
-
-  const hideEdge = (instant = false) => {
-    stopEdgeTimer()
-    if (!edgeRef) {
-      edgeOn = false
-      return
-    }
-    edgeAnim?.stop()
-    edgeAnim = undefined
-    if (instant || reduce()) {
-      edgeRef.style.opacity = "0"
-      edgeOn = false
-      return
-    }
-    if (!edgeOn) {
-      edgeRef.style.opacity = "0"
-      return
-    }
-    const current = animate(edgeRef, { opacity: 0 }, { type: "spring", visualDuration: edgeFade(), bounce: 0 })
-    edgeAnim = current
-    current.finished
-      .catch(() => {})
-      .finally(() => {
-        if (edgeAnim !== current) return
-        edgeAnim = undefined
-        if (!edgeRef) return
-        edgeRef.style.opacity = "0"
-        edgeOn = false
-      })
-  }
-
-  const showEdge = () => {
-    stopEdgeTimer()
-    if (!edgeRef) return
-    if (reduce()) {
-      edgeRef.style.opacity = `${edgeOpacity()}`
-      edgeOn = true
-      return
-    }
-    if (edgeOn && edgeAnim === undefined) {
-      edgeRef.style.opacity = `${edgeOpacity()}`
-      return
-    }
-    edgeAnim?.stop()
-    edgeAnim = undefined
-    if (!edgeOn) edgeRef.style.opacity = "0"
-    const current = animate(
-      edgeRef,
-      { opacity: edgeOpacity() },
-      { type: "spring", visualDuration: edgeRise(), bounce: 0 },
-    )
-    edgeAnim = current
-    edgeOn = true
-    current.finished
-      .catch(() => {})
-      .finally(() => {
-        if (edgeAnim !== current) return
-        edgeAnim = undefined
-        if (!edgeRef) return
-        edgeRef.style.opacity = `${edgeOpacity()}`
-      })
-  }
-
-  const queueEdgeHide = () => {
-    stopEdgeTimer()
-    if (!edgeOn) return
-    if (edgeIdle() <= 0) {
-      hideEdge()
-      return
-    }
-    edgeTimer = setTimeout(() => {
-      edgeTimer = undefined
-      hideEdge()
-    }, edgeIdle())
-  }
-
-  const hideBody = () => {
-    if (!body) return
-    body.style.opacity = "0"
-    body.style.filter = "blur(2px)"
-  }
-
-  const clearBody = () => {
-    if (!body) return
-    body.style.opacity = ""
-    body.style.filter = ""
-  }
-
-  const fadeBodyIn = (nextMode: "mount" | "toggle" = "mount") => {
-    if (props.fade === false || !body) return
-    if (reduce()) {
-      clearBody()
-      return
-    }
-    hideBody()
-    fadeAnim?.stop()
-    fadeAnim = animate(body, { opacity: 1, filter: "blur(0px)" }, nextMode === "toggle" ? toggleSpring() : spring())
-    fadeAnim.finished.then(() => {
-      if (!body || !open()) return
-      clearBody()
-    })
-  }
-
-  const setInstant = (visible: boolean) => {
-    const next = visible ? targetHeight() : 0
-    springTarget = next
-    height.jump(next)
-    root!.style.height = visible ? "" : "0px"
-    root!.style.overflow = visible ? "" : "clip"
-    hideEdge(true)
-    if (visible || props.fade === false) clearBody()
-    else hideBody()
-  }
-
-  const currentHeight = () => {
-    if (!root) return 0
-    const v = root.style.height
-    if (v && v !== "auto") {
-      const n = Number.parseFloat(v)
-      if (!Number.isNaN(n)) return n
-    }
-    return Math.max(0, root.getBoundingClientRect().height)
-  }
-
-  const targetHeight = () => Math.max(0, Math.ceil(body?.getBoundingClientRect().height ?? 0))
-
-  const setHeight = (nextMode: "mount" | "toggle" = "mount") => {
-    if (!root || !open()) return
-    const next = targetHeight()
-    if (reduce()) {
-      springTarget = next
-      height.jump(next)
-      if (props.autoHeight === false || watch()) {
-        root.style.height = `${next}px`
-        root.style.overflow = next > 0 ? "visible" : "clip"
-        return
-      }
-      root.style.height = "auto"
-      root.style.overflow = next > 0 ? "visible" : "clip"
-      return
-    }
-    if (next === springTarget) return
-    const prev = currentHeight()
-    if (Math.abs(next - prev) < 1) {
-      springTarget = next
-      if (props.autoHeight === false || watch()) {
-        root.style.height = `${next}px`
-        root.style.overflow = next > 0 ? "visible" : "clip"
-      }
-      return
-    }
-    root.style.overflow = "clip"
-    springTarget = next
-    mode = nextMode
-    height.set(next)
-  }
-
-  onMount(() => {
-    if (!root || !body) return
-
-    const offChange = height.on("change", (next) => {
-      if (!root) return
-      root.style.height = `${Math.max(0, next)}px`
-    })
-    const offStart = height.on("animationStart", () => {
-      if (!root) return
-      root.style.overflow = "clip"
-      root.style.willChange = "height"
-      root.style.contain = "layout style"
-      if (edgeReady()) showEdge()
-    })
-    const offComplete = height.on("animationComplete", () => {
-      if (!root) return
-      root.style.willChange = ""
-      root.style.contain = ""
-      if (!open()) {
-        springTarget = 0
-        root.style.height = "0px"
-        root.style.overflow = "clip"
-        return
-      }
-      const next = targetHeight()
-      springTarget = next
-      if (props.autoHeight === false || watch()) {
-        root.style.height = `${next}px`
-        root.style.overflow = next > 0 ? "visible" : "clip"
-        if (edgeReady()) queueEdgeHide()
-        return
-      }
-      root.style.height = "auto"
-      root.style.overflow = "visible"
-      if (edgeReady()) queueEdgeHide()
-    })
-
-    onCleanup(() => {
-      offComplete()
-      offStart()
-      offChange()
-    })
-
-    if (watch()) {
-      observer = new ResizeObserver(() => {
-        if (!open()) return
-        if (resizeFrame !== undefined) return
-        resizeFrame = requestAnimationFrame(() => {
-          resizeFrame = undefined
-          setHeight("mount")
-        })
-      })
-      observer.observe(body)
-    }
-
-    if (!animated()) {
-      setInstant(open())
-      return
-    }
-
-    if (props.fade !== false) hideBody()
-    hideEdge(true)
-
-    if (!open()) {
-      root.style.height = "0px"
-      root.style.overflow = "clip"
-    } else {
-      if (grow()) {
-        root.style.height = "0px"
-        root.style.overflow = "clip"
-      } else {
-        root.style.height = "auto"
-        root.style.overflow = "visible"
-      }
-      mountFrame = requestAnimationFrame(() => {
-        mountFrame = undefined
-        fadeBodyIn("mount")
-        if (grow()) setHeight("mount")
-      })
-    }
-  })
-
-  createEffect(
-    on(
-      () => props.open,
-      (value) => {
-        if (value === undefined) return
-        if (!root || !body) return
-        if (!animateToggle() || reduce()) {
-          setInstant(value)
-          return
-        }
-        fadeAnim?.stop()
-        if (!value) hideEdge(true)
-        if (!value) {
-          const next = currentHeight()
-          if (Math.abs(next - height.get()) >= 1) {
-            springTarget = next
-            height.jump(next)
-            root.style.height = `${next}px`
-          }
-          if (props.fade !== false) {
-            fadeAnim = animate(body, { opacity: 0, filter: "blur(2px)" }, toggleSpring())
-          }
-          root.style.overflow = "clip"
-          springTarget = 0
-          mode = "toggle"
-          height.set(0)
-          return
-        }
-        fadeBodyIn("toggle")
-        setHeight("toggle")
-      },
-      { defer: true },
-    ),
-  )
-
-  createEffect(() => {
-    if (!edgeRef) return
-    edgeRef.style.height = `${edgeHeight()}px`
-    if (!animated() || !open() || edgeHeight() <= 0) {
-      hideEdge(true)
-      return
-    }
-    if (edge()) return
-    hideEdge()
-  })
-
-  createEffect(() => {
-    if (!root || !body) return
-    if (!reduce()) return
-    fadeAnim?.stop()
-    edgeAnim?.stop()
-    setInstant(open())
-  })
-
-  onCleanup(() => {
-    stopEdgeTimer()
-    if (mountFrame !== undefined) cancelAnimationFrame(mountFrame)
-    if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
-    observer?.disconnect()
-    height.destroy()
-    fadeAnim?.stop()
-    edgeAnim?.stop()
-    edgeAnim = undefined
-    edgeOn = false
-  })
-
-  return (
-    <div
-      ref={root}
-      data-slot={props.slot}
-      class={props.class}
-      style={{
-        transform: "translateZ(0)",
-        position: "relative",
-        height: open() ? undefined : "0px",
-        overflow: open() ? undefined : "clip",
-      }}
-    >
-      <div ref={body} style={{ "padding-top": gap() > 0 ? `${gap()}px` : undefined }}>
-        {props.children}
-      </div>
-      <div
-        ref={edgeRef}
-        data-slot="grow-box-edge"
-        style={{
-          position: "absolute",
-          left: "0",
-          right: "0",
-          bottom: "0",
-          height: `${edgeHeight()}px`,
-          opacity: 0,
-          "pointer-events": "none",
-          background: "linear-gradient(to bottom, transparent 0%, var(--background-stronger) 100%)",
-        }}
-      />
-    </div>
-  )
-}

+ 85 - 446
packages/ui/src/components/message-part.css

@@ -1,20 +1,10 @@
 [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: 0;
-}
-
-[data-component="assistant-part-item"] {
-  width: 100%;
-  min-width: 0;
+  gap: 12px;
 }
 
 [data-component="user-message"] {
@@ -37,14 +27,6 @@
     color: var(--text-weak);
   }
 
-  [data-slot="user-message-inner"] {
-    position: relative;
-    display: flex;
-    flex-direction: column;
-    align-items: flex-end;
-    width: 100%;
-    gap: 4px;
-  }
   [data-slot="user-message-attachments"] {
     display: flex;
     flex-wrap: wrap;
@@ -53,7 +35,6 @@
     width: fit-content;
     max-width: min(82%, 64ch);
     margin-left: auto;
-    margin-bottom: 4px;
   }
 
   [data-slot="user-message-attachment"] {
@@ -153,7 +134,7 @@
 
   [data-slot="user-message-copy-wrapper"] {
     min-height: 24px;
-    margin-top: 0;
+    margin-top: 4px;
     display: flex;
     align-items: center;
     justify-content: flex-end;
@@ -163,6 +144,7 @@
     pointer-events: none;
     transition: opacity 0.15s ease;
     will-change: opacity;
+
     [data-component="tooltip-trigger"] {
       display: inline-flex;
       width: fit-content;
@@ -205,21 +187,56 @@
     opacity: 1;
     pointer-events: auto;
   }
+
+  .text-text-strong {
+    color: var(--text-strong);
+  }
+
+  .font-medium {
+    font-weight: var(--font-weight-medium);
+  }
 }
 
 [data-component="text-part"] {
   width: 100%;
-  margin-top: 0;
-  padding-block: 4px;
-  position: relative;
+  margin-top: 24px;
 
   [data-slot="text-part-body"] {
     margin-top: 0;
   }
 
-  [data-slot="text-part-turn-summary"] {
+  [data-slot="text-part-copy-wrapper"] {
+    min-height: 24px;
+    margin-top: 4px;
+    display: flex;
+    align-items: center;
+    justify-content: flex-start;
+    gap: 10px;
+    opacity: 0;
+    pointer-events: none;
+    transition: opacity 0.15s ease;
+    will-change: opacity;
+
+    [data-component="tooltip-trigger"] {
+      display: inline-flex;
+      width: fit-content;
+    }
+  }
+
+  [data-slot="text-part-meta"] {
+    user-select: none;
+  }
+
+  [data-slot="text-part-copy-wrapper"][data-interrupted] {
     width: 100%;
-    min-width: 0;
+    justify-content: flex-end;
+    gap: 12px;
+  }
+
+  &:hover [data-slot="text-part-copy-wrapper"],
+  &:focus-within [data-slot="text-part-copy-wrapper"] {
+    opacity: 1;
+    pointer-events: auto;
   }
 
   [data-component="markdown"] {
@@ -228,10 +245,6 @@
   }
 }
 
-[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;
@@ -265,6 +278,7 @@
   line-height: var(--line-height-normal);
 
   [data-component="markdown"] {
+    margin-top: 24px;
     font-style: normal;
     font-size: inherit;
     color: var(--text-weak);
@@ -358,16 +372,13 @@
     height: auto;
     max-height: 240px;
     overflow-y: auto;
-    overscroll-behavior: contain;
     scrollbar-width: none;
     -ms-overflow-style: none;
-    -webkit-mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%);
-    mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%);
-    -webkit-mask-repeat: no-repeat;
-    mask-repeat: no-repeat;
+
     &::-webkit-scrollbar {
       display: none;
     }
+
     [data-component="markdown"] {
       overflow: visible;
     }
@@ -437,7 +448,7 @@
 [data-component="write-trigger"] {
   display: flex;
   align-items: center;
-  justify-content: flex-start;
+  justify-content: space-between;
   gap: 8px;
   width: 100%;
 
@@ -450,8 +461,7 @@
   }
 
   [data-slot="message-part-title"] {
-    flex-shrink: 1;
-    min-width: 0;
+    flex-shrink: 0;
     display: flex;
     align-items: center;
     gap: 8px;
@@ -483,45 +493,40 @@
   [data-slot="message-part-title-text"] {
     text-transform: capitalize;
     color: var(--text-strong);
-    flex-shrink: 0;
   }
 
-  [data-slot="message-part-meta-line"],
-  .message-part-meta-line {
-    min-width: 0;
-    display: inline-flex;
-    align-items: center;
-    gap: 6px;
+  [data-slot="message-part-title-filename"] {
+    /* No text-transform - preserve original filename casing */
     font-weight: var(--font-weight-regular);
-
-    [data-component="diff-changes"] {
-      flex-shrink: 0;
-      gap: 6px;
-    }
   }
 
-  .message-part-meta-line.soft {
-    [data-slot="message-part-title-filename"] {
-      color: var(--text-base);
-    }
-  }
-
-  [data-slot="message-part-title-filename"] {
-    /* No text-transform - preserve original filename casing */
-    color: var(--text-strong);
-    flex-shrink: 0;
+  [data-slot="message-part-path"] {
+    display: flex;
+    flex-grow: 1;
+    min-width: 0;
+    font-weight: var(--font-weight-regular);
   }
 
-  [data-slot="message-part-directory-inline"] {
+  [data-slot="message-part-directory"] {
     color: var(--text-weak);
-    min-width: 0;
-    max-width: min(48vw, 36ch);
     text-overflow: ellipsis;
     overflow: hidden;
     white-space: nowrap;
     direction: rtl;
     text-align: left;
   }
+
+  [data-slot="message-part-filename"] {
+    color: var(--text-strong);
+    flex-shrink: 0;
+  }
+
+  [data-slot="message-part-actions"] {
+    display: flex;
+    gap: 16px;
+    align-items: center;
+    justify-content: flex-end;
+  }
 }
 
 [data-component="edit-content"] {
@@ -612,17 +617,6 @@
   }
 }
 
-[data-slot="webfetch-meta"] {
-  min-width: 0;
-  display: inline-flex;
-  align-items: center;
-  gap: 8px;
-
-  [data-component="tool-action"] {
-    flex-shrink: 0;
-  }
-}
-
 [data-component="todos"] {
   padding: 10px 0 24px 0;
   display: flex;
@@ -645,6 +639,7 @@
 }
 
 [data-component="context-tool-group-trigger"] {
+  width: 100%;
   min-height: 24px;
   display: flex;
   align-items: center;
@@ -652,352 +647,28 @@
   gap: 0px;
   cursor: pointer;
 
-  &[data-pending] {
-    cursor: default;
-  }
-
   [data-slot="context-tool-group-title"] {
     flex-shrink: 1;
     min-width: 0;
   }
-}
-
-/* Prevent the trigger content from stretching full-width so the arrow sits after the text */
-[data-slot="basic-tool-tool-trigger-content"]:has([data-component="context-tool-group-trigger"]) {
-  width: auto;
-  flex: 0 1 auto;
 
-  [data-slot="basic-tool-tool-info"] {
-    flex: 0 1 auto;
+  [data-slot="collapsible-arrow"] {
+    color: var(--icon-weaker);
   }
 }
 
-[data-component="context-tool-step"] {
-  width: 100%;
-  min-width: 0;
-  padding-left: 12px;
-}
-
-[data-component="context-tool-expanded-list"] {
+[data-component="context-tool-group-list"] {
+  padding: 6px 0 4px 0;
   display: flex;
   flex-direction: column;
-  padding: 4px 0 4px 12px;
-  max-height: 200px;
-  overflow-y: auto;
-  overscroll-behavior: contain;
-  scrollbar-width: none;
-  -ms-overflow-style: none;
-  -webkit-mask-repeat: no-repeat;
-  mask-repeat: no-repeat;
+  gap: 2px;
 
-  &::-webkit-scrollbar {
-    display: none;
-  }
-}
-
-[data-component="context-tool-expanded-row"] {
-  display: flex;
-  align-items: center;
-  gap: 6px;
-  min-width: 0;
-  height: 22px;
-  flex-shrink: 0;
-  white-space: nowrap;
-  overflow: hidden;
-
-  [data-slot="context-tool-expanded-action"] {
-    flex-shrink: 0;
-    font-size: var(--font-size-base);
-    font-weight: 500;
-    color: var(--text-base);
-  }
-
-  [data-slot="context-tool-expanded-detail"] {
-    flex-shrink: 1;
+  [data-slot="context-tool-group-item"] {
     min-width: 0;
-    overflow: hidden;
-    text-overflow: ellipsis;
-    font-size: var(--font-size-base);
-    color: var(--text-base);
-    opacity: 0.75;
+    padding: 6px 0;
   }
 }
 
-[data-component="context-tool-rolling-row"] {
-  display: inline-flex;
-  align-items: center;
-  gap: 6px;
-  width: 100%;
-  min-width: 0;
-  white-space: nowrap;
-  overflow: hidden;
-  padding-left: 12px;
-
-  [data-slot="context-tool-rolling-action"] {
-    flex-shrink: 0;
-    font-size: var(--font-size-base);
-    font-weight: 500;
-    color: var(--text-base);
-  }
-
-  [data-slot="context-tool-rolling-detail"] {
-    flex-shrink: 1;
-    min-width: 0;
-    overflow: hidden;
-    text-overflow: ellipsis;
-    font-size: var(--font-size-base);
-    color: var(--text-weak);
-  }
-}
-
-[data-component="shell-rolling-results"] {
-  width: 100%;
-  min-width: 0;
-  display: flex;
-  flex-direction: column;
-
-  [data-slot="shell-rolling-header-clip"] {
-    &:hover [data-slot="shell-rolling-actions"] {
-      opacity: 1;
-    }
-
-    &[data-clickable="true"] {
-      cursor: pointer;
-    }
-  }
-
-  [data-slot="shell-rolling-header"] {
-    display: inline-flex;
-    align-items: center;
-    gap: 8px;
-    min-width: 0;
-    max-width: 100%;
-    height: 37px;
-    box-sizing: border-box;
-  }
-
-  [data-slot="shell-rolling-title"] {
-    flex-shrink: 0;
-    font-family: var(--font-family-sans);
-    font-size: 14px;
-    font-style: normal;
-    font-weight: var(--font-weight-medium);
-    line-height: var(--line-height-large);
-    letter-spacing: var(--letter-spacing-normal);
-    color: var(--text-strong);
-  }
-
-  [data-slot="shell-rolling-subtitle"] {
-    flex: 0 1 auto;
-    min-width: 0;
-    overflow: hidden;
-    text-overflow: ellipsis;
-    white-space: nowrap;
-    font-family: var(--font-family-sans);
-    font-size: 14px;
-    font-weight: var(--font-weight-normal);
-    line-height: var(--line-height-large);
-    color: var(--text-weak);
-  }
-
-  [data-slot="shell-rolling-actions"] {
-    flex-shrink: 0;
-    display: inline-flex;
-    align-items: center;
-    gap: 2px;
-    opacity: 0;
-    transition: opacity 0.15s ease;
-  }
-
-  .shell-rolling-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-rolling-arrow"] {
-    display: inline-flex;
-    align-items: center;
-    justify-content: center;
-    color: var(--icon-weaker);
-    transform: rotate(-90deg);
-    transition: transform 0.15s ease;
-  }
-
-  [data-slot="shell-rolling-arrow"][data-open="true"] {
-    transform: rotate(0deg);
-  }
-}
-
-[data-component="shell-rolling-output"] {
-  width: 100%;
-  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;
-  align-items: center;
-  width: 100%;
-  min-width: 0;
-  overflow: hidden;
-  white-space: pre;
-  padding-left: 12px;
-}
-
-[data-slot="shell-rolling-text"] {
-  min-width: 0;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  font-family: var(--font-family-mono);
-  font-feature-settings: var(--font-family-mono--font-feature-settings);
-  font-size: var(--font-size-small);
-  line-height: var(--line-height-large);
-}
-
-[data-component="shell-rolling-command"] [data-slot="shell-rolling-text"] {
-  color: var(--text-base);
-}
-
-[data-component="shell-rolling-command"] [data-slot="shell-rolling-prompt"] {
-  color: var(--text-weaker);
-}
-
-[data-component="shell-rolling-row"] [data-slot="shell-rolling-text"] {
-  color: var(--text-weak);
-}
-
 [data-component="diagnostics"] {
   display: flex;
   flex-direction: column;
@@ -1058,30 +729,6 @@
   width: 100%;
 }
 
-[data-slot="assistant-part-grow"] {
-  width: 100%;
-  min-width: 0;
-  overflow: visible;
-}
-
-[data-component="tool-part-wrapper"][data-tool="bash"] {
-  [data-component="tool-trigger"] {
-    width: auto;
-    max-width: 100%;
-  }
-
-  [data-slot="basic-tool-tool-info-main"] {
-    align-items: center;
-  }
-
-  [data-slot="basic-tool-tool-title"],
-  [data-slot="basic-tool-tool-subtitle"] {
-    display: inline-flex;
-    align-items: center;
-    line-height: var(--line-height-large);
-  }
-}
-
 [data-component="dock-prompt"][data-kind="permission"] {
   position: relative;
   display: flex;
@@ -1540,7 +1187,8 @@
     position: sticky;
     top: var(--sticky-accordion-top, 0px);
     z-index: 20;
-    height: 37px;
+    height: 40px;
+    padding-bottom: 8px;
     background-color: var(--background-stronger);
   }
 }
@@ -1551,12 +1199,11 @@
   }
 
   [data-slot="apply-patch-trigger-content"] {
-    display: inline-flex;
+    display: flex;
     align-items: center;
-    justify-content: flex-start;
-    max-width: 100%;
-    min-width: 0;
-    gap: 8px;
+    justify-content: space-between;
+    width: 100%;
+    gap: 20px;
   }
 
   [data-slot="apply-patch-file-info"] {
@@ -1590,9 +1237,9 @@
   [data-slot="apply-patch-trigger-actions"] {
     flex-shrink: 0;
     display: flex;
-    gap: 8px;
+    gap: 16px;
     align-items: center;
-    justify-content: flex-start;
+    justify-content: flex-end;
   }
 
   [data-slot="apply-patch-change"] {
@@ -1632,11 +1279,10 @@
 }
 
 [data-component="tool-loaded-file"] {
-  min-width: 0;
   display: flex;
   align-items: center;
   gap: 8px;
-  padding: 4px 0 4px 12px;
+  padding: 4px 0 4px 28px;
   font-family: var(--font-family-sans);
   font-size: var(--font-size-small);
   font-weight: var(--font-weight-regular);
@@ -1647,11 +1293,4 @@
     flex-shrink: 0;
     color: var(--icon-weak);
   }
-
-  span {
-    min-width: 0;
-    overflow: hidden;
-    text-overflow: ellipsis;
-    white-space: nowrap;
-  }
 }

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 634 - 408
packages/ui/src/components/message-part.tsx


+ 7 - 25
packages/ui/src/components/motion-spring.tsx

@@ -1,9 +1,8 @@
 import { attachSpring, motionValue } from "motion"
 import type { SpringOptions } from "motion"
 import { createEffect, createSignal, onCleanup } from "solid-js"
-import { useReducedMotion } from "../hooks/use-reduced-motion"
 
-type Opt = Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">
+type Opt = Partial<Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">>
 const eq = (a: Opt | undefined, b: Opt | undefined) =>
   a?.visualDuration === b?.visualDuration &&
   a?.bounce === b?.bounce &&
@@ -14,41 +13,24 @@ const eq = (a: Opt | undefined, b: Opt | undefined) =>
 
 export function useSpring(target: () => number, options?: Opt | (() => Opt)) {
   const read = () => (typeof options === "function" ? options() : options)
-  const reduce = useReducedMotion()
   const [value, setValue] = createSignal(target())
   const source = motionValue(value())
   const spring = motionValue(value())
   let config = read()
-  let reduced = reduce()
-  let stop = reduced ? () => {} : attachSpring(spring, source, config)
-  let off = spring.on("change", (next) => setValue(next))
+  let stop = attachSpring(spring, source, config)
+  let off = spring.on("change", (next: number) => setValue(next))
 
   createEffect(() => {
-    const next = target()
-    if (reduced) {
-      source.set(next)
-      spring.set(next)
-      setValue(next)
-      return
-    }
-    source.set(next)
+    source.set(target())
   })
 
   createEffect(() => {
+    if (!options) return
     const next = read()
-    const skip = reduce()
-    if (eq(config, next) && reduced === skip) return
+    if (eq(config, next)) return
     config = next
-    reduced = skip
     stop()
-    stop = skip ? () => {} : attachSpring(spring, source, next)
-    if (skip) {
-      const value = target()
-      source.set(value)
-      spring.set(value)
-      setValue(value)
-      return
-    }
+    stop = attachSpring(spring, source, next)
     setValue(spring.get())
   })
 

+ 0 - 77
packages/ui/src/components/motion.tsx

@@ -1,77 +0,0 @@
-import { followValue } from "motion"
-import type { MotionValue } from "motion"
-
-export { animate, springValue } from "motion"
-export type { AnimationPlaybackControls } from "motion"
-
-/**
- * Like `springValue` but preserves getters on the config object.
- * `springValue` spreads config at creation, snapshotting getter values.
- * This passes the config through to `followValue` intact, so getters
- * on `visualDuration` etc. fire on every `.set()` call.
- */
-export function tunableSpringValue<T extends string | number>(initial: T, config: SpringConfig): MotionValue<T> {
-  return followValue(initial, config as any)
-}
-
-let _growDuration = 0.5
-let _collapsibleDuration = 0.3
-
-export const GROW_SPRING = {
-  type: "spring" as const,
-  get visualDuration() {
-    return _growDuration
-  },
-  bounce: 0,
-}
-
-export const COLLAPSIBLE_SPRING = {
-  type: "spring" as const,
-  get visualDuration() {
-    return _collapsibleDuration
-  },
-  bounce: 0,
-}
-
-export const setGrowDuration = (v: number) => {
-  _growDuration = v
-}
-export const setCollapsibleDuration = (v: number) => {
-  _collapsibleDuration = v
-}
-export const getGrowDuration = () => _growDuration
-export const getCollapsibleDuration = () => _collapsibleDuration
-
-export type SpringConfig = { type: "spring"; visualDuration: number; bounce: number }
-
-export const FAST_SPRING = {
-  type: "spring" as const,
-  visualDuration: 0.35,
-  bounce: 0,
-}
-
-export const GLOW_SPRING = {
-  type: "spring" as const,
-  visualDuration: 0.4,
-  bounce: 0.15,
-}
-
-export const WIPE_MASK =
-  "linear-gradient(to right, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 45%, rgba(0,0,0,0) 60%, rgba(0,0,0,0) 100%)"
-
-export const clearMaskStyles = (el: HTMLElement) => {
-  el.style.maskImage = ""
-  el.style.webkitMaskImage = ""
-  el.style.maskSize = ""
-  el.style.webkitMaskSize = ""
-  el.style.maskRepeat = ""
-  el.style.webkitMaskRepeat = ""
-  el.style.maskPosition = ""
-  el.style.webkitMaskPosition = ""
-}
-
-export const clearFadeStyles = (el: HTMLElement) => {
-  el.style.opacity = ""
-  el.style.filter = ""
-  el.style.transform = ""
-}

+ 0 - 92
packages/ui/src/components/rolling-results.css

@@ -1,92 +0,0 @@
-[data-component="rolling-results"] {
-  --rolling-results-row-height: 22px;
-  --rolling-results-fixed-height: var(--rolling-results-row-height);
-  --rolling-results-fixed-gap: 0px;
-  --rolling-results-row-gap: 0px;
-
-  display: block;
-  width: 100%;
-  min-width: 0;
-
-  [data-slot="rolling-results-viewport"] {
-    position: relative;
-    min-width: 0;
-    height: 0;
-    overflow: clip;
-  }
-
-  &[data-overflowing="true"]:not([data-scrollable="true"]) [data-slot="rolling-results-window"] {
-    mask-image: linear-gradient(
-      to bottom,
-      transparent 0%,
-      black var(--rolling-results-fade),
-      black calc(100% - calc(var(--rolling-results-fade) * 0.5)),
-      transparent 100%
-    );
-    -webkit-mask-image: linear-gradient(
-      to bottom,
-      transparent 0%,
-      black var(--rolling-results-fade),
-      black calc(100% - calc(var(--rolling-results-fade) * 0.5)),
-      transparent 100%
-    );
-  }
-
-  [data-slot="rolling-results-fixed"] {
-    min-width: 0;
-    height: var(--rolling-results-fixed-height);
-    min-height: var(--rolling-results-fixed-height);
-    display: flex;
-    align-items: center;
-  }
-
-  [data-slot="rolling-results-window"] {
-    min-width: 0;
-    margin-top: var(--rolling-results-fixed-gap);
-    height: calc(100% - var(--rolling-results-fixed-height) - var(--rolling-results-fixed-gap));
-    overflow: clip;
-  }
-
-  &[data-scrollable="true"] [data-slot="rolling-results-window"] {
-    scrollbar-width: none;
-    -ms-overflow-style: none;
-
-    &::-webkit-scrollbar {
-      display: none;
-    }
-  }
-
-  &[data-scrollable="true"] [data-slot="rolling-results-track"] {
-    transform: none !important;
-    will-change: auto;
-  }
-
-  [data-slot="rolling-results-body"] {
-    min-width: 0;
-  }
-
-  [data-slot="rolling-results-track"] {
-    display: flex;
-    min-width: 0;
-    flex-direction: column;
-    gap: var(--rolling-results-row-gap);
-    will-change: transform;
-  }
-
-  [data-slot="rolling-results-row"],
-  [data-slot="rolling-results-empty"] {
-    min-width: 0;
-    height: var(--rolling-results-row-height);
-    min-height: var(--rolling-results-row-height);
-    display: flex;
-    align-items: center;
-  }
-
-  [data-slot="rolling-results-row"] {
-    color: var(--text-base);
-  }
-
-  [data-slot="rolling-results-empty"] {
-    color: var(--text-weaker);
-  }
-}

+ 0 - 325
packages/ui/src/components/rolling-results.tsx

@@ -1,325 +0,0 @@
-import { For, Show, batch, createEffect, createMemo, createSignal, on, onCleanup, onMount, type JSX } from "solid-js"
-import { useReducedMotion } from "../hooks/use-reduced-motion"
-import { animate, clearMaskStyles, GROW_SPRING, type AnimationPlaybackControls, type SpringConfig } from "./motion"
-
-export type RollingResultsProps<T> = {
-  items: T[]
-  render: (item: T, index: number) => JSX.Element
-  fixed?: JSX.Element
-  getKey?: (item: T, index: number) => string
-  rows?: number
-  rowHeight?: number
-  fixedHeight?: number
-  rowGap?: number
-  open?: boolean
-  scrollable?: boolean
-  spring?: SpringConfig
-  animate?: boolean
-  class?: string
-  empty?: JSX.Element
-  noFadeOnCollapse?: boolean
-}
-
-export function RollingResults<T>(props: RollingResultsProps<T>) {
-  let view: HTMLDivElement | undefined
-  let track: HTMLDivElement | undefined
-  let windowEl: HTMLDivElement | undefined
-  let shift: AnimationPlaybackControls | undefined
-  let resize: AnimationPlaybackControls | undefined
-  let edgeFade: AnimationPlaybackControls | undefined
-  const reduce = useReducedMotion()
-
-  const rows = createMemo(() => Math.max(1, Math.round(props.rows ?? 3)))
-  const rowHeight = createMemo(() => Math.max(16, Math.round(props.rowHeight ?? 22)))
-  const fixedHeight = createMemo(() => Math.max(0, Math.round(props.fixedHeight ?? rowHeight())))
-  const rowGap = createMemo(() => Math.max(0, Math.round(props.rowGap ?? 0)))
-  const fixed = createMemo(() => props.fixed !== undefined)
-  const list = createMemo(() => props.items ?? [])
-  const count = createMemo(() => list().length)
-
-  // scrollReady is the internal "transition complete" state.
-  // It only becomes true after props.scrollable is true AND the offset animation has settled.
-  const [scrollReady, setScrollReady] = createSignal(false)
-
-  const backstop = createMemo(() => Math.max(rows() * 2, 12))
-  const rendered = createMemo(() => {
-    const items = list()
-    if (scrollReady()) return items
-    const max = backstop()
-    return items.length > max ? items.slice(-max) : items
-  })
-  const skipped = createMemo(() => {
-    if (scrollReady()) return 0
-    return count() - rendered().length
-  })
-  const open = createMemo(() => props.open !== false)
-  const active = createMemo(() => (props.animate !== false || props.spring !== undefined) && !reduce())
-  const noFade = () => props.noFadeOnCollapse === true
-  const overflowing = createMemo(() => count() > rows())
-  const shown = createMemo(() => Math.min(rows(), count()))
-  const step = createMemo(() => rowHeight() + rowGap())
-  const offset = createMemo(() => Math.max(0, count() - shown()) * step())
-  const body = createMemo(() => {
-    if (shown() > 0) {
-      return shown() * rowHeight() + Math.max(0, shown() - 1) * rowGap()
-    }
-    if (props.empty === undefined) return 0
-    return rowHeight()
-  })
-  const gap = createMemo(() => {
-    if (!fixed()) return 0
-    if (body() <= 0) return 0
-    return rowGap()
-  })
-  const height = createMemo(() => {
-    if (!open()) return 0
-    if (!fixed()) return body()
-    return fixedHeight() + gap() + body()
-  })
-
-  const key = (item: T, index: number) => {
-    const value = props.getKey
-    if (value) return value(item, index)
-    return String(index)
-  }
-
-  const setTrack = (value: number) => {
-    if (!track) return
-    track.style.transform = `translateY(${-Math.round(value)}px)`
-  }
-
-  const setView = (value: number) => {
-    if (!view) return
-    view.style.height = `${Math.max(0, Math.round(value))}px`
-  }
-
-  onMount(() => {
-    setTrack(offset())
-  })
-
-  // Original WAAPI offset animation — untouched rolling behavior.
-  createEffect(
-    on(
-      offset,
-      (next) => {
-        if (!track) return
-        if (scrollReady()) return
-        if (props.scrollable) return
-        if (!active()) {
-          shift?.stop()
-          shift = undefined
-          setTrack(next)
-          return
-        }
-        shift?.stop()
-        const anim = animate(track, { transform: `translateY(${-next}px)` }, props.spring ?? GROW_SPRING)
-        shift = anim
-        anim.finished
-          .catch(() => {})
-          .finally(() => {
-            if (shift !== anim) return
-            setTrack(next)
-            shift = undefined
-          })
-      },
-      { defer: true },
-    ),
-  )
-
-  // Scrollable transition: wait for the offset animation to finish,
-  // then batch all DOM changes in one synchronous pass.
-  createEffect(
-    on(
-      () => props.scrollable === true,
-      (isScrollable) => {
-        if (!isScrollable) {
-          setScrollReady(false)
-          if (windowEl) {
-            windowEl.style.overflowY = ""
-            windowEl.style.maskImage = ""
-            windowEl.style.webkitMaskImage = ""
-          }
-          return
-        }
-        // Wait for the current offset animation to settle (if any).
-        const done = shift?.finished ?? Promise.resolve()
-        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))
-
-            // 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()
-          })
-      },
-    ),
-  )
-
-  // Auto-scroll to bottom when new items arrive in scrollable mode
-  const [userScrolled, setUserScrolled] = createSignal(false)
-
-  const updateScrollMask = () => {
-    if (!windowEl) return
-    if (!scrollReady()) {
-      windowEl.style.maskImage = ""
-      windowEl.style.webkitMaskImage = ""
-      return
-    }
-    const { scrollTop, scrollHeight, clientHeight } = windowEl
-    const atBottom = scrollHeight - scrollTop - clientHeight < 8
-    // Top fade is always present in scrollable mode (matches rolling mode appearance).
-    // Bottom fade only when not scrolled to the end.
-    const mask = atBottom
-      ? "linear-gradient(to bottom, transparent 0, black 8px)"
-      : "linear-gradient(to bottom, transparent 0, black 8px, black calc(100% - 8px), transparent 100%)"
-    windowEl.style.maskImage = mask
-    windowEl.style.webkitMaskImage = mask
-  }
-
-  createEffect(() => {
-    if (!scrollReady()) {
-      setUserScrolled(false)
-      return
-    }
-    const _n = count()
-    const scrolled = userScrolled()
-    if (scrolled) return
-    if (windowEl) {
-      windowEl.scrollTop = windowEl.scrollHeight
-      updateScrollMask()
-    }
-  })
-
-  const onWindowScroll = () => {
-    if (!windowEl || !scrollReady()) return
-    const atBottom = windowEl.scrollHeight - windowEl.scrollTop - windowEl.clientHeight < 8
-    setUserScrolled(!atBottom)
-    updateScrollMask()
-  }
-
-  const EDGE_MASK = "linear-gradient(to top, transparent 0%, black 8px)"
-  const applyEdge = () => {
-    if (!view) return
-    edgeFade?.stop()
-    edgeFade = undefined
-    view.style.maskImage = EDGE_MASK
-    view.style.webkitMaskImage = EDGE_MASK
-    view.style.maskSize = "100% 100%"
-    view.style.maskRepeat = "no-repeat"
-  }
-  const clearEdge = () => {
-    if (!view) return
-    if (!active()) {
-      clearMaskStyles(view)
-      return
-    }
-    edgeFade?.stop()
-    const anim = animate(view, { maskSize: "100% 200%" }, props.spring ?? GROW_SPRING)
-    edgeFade = anim
-    anim.finished
-      .catch(() => {})
-      .then(() => {
-        if (edgeFade !== anim || !view) return
-        clearMaskStyles(view)
-        edgeFade = undefined
-      })
-  }
-
-  createEffect(
-    on(height, (next, prev) => {
-      if (!view) return
-      if (!active()) {
-        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, noFade() ? { height: `${next}px` } : { height: `${next}px`, opacity: 0 }, spring)
-        : expanding
-          ? 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)
-          resize = undefined
-          clearEdge()
-        })
-    }),
-  )
-
-  onCleanup(() => {
-    shift?.stop()
-    resize?.stop()
-    edgeFade?.stop()
-    shift = undefined
-    resize = undefined
-    edgeFade = undefined
-  })
-
-  return (
-    <div
-      data-component="rolling-results"
-      class={props.class}
-      data-open={open() ? "true" : "false"}
-      data-overflowing={overflowing() ? "true" : "false"}
-      data-scrollable={scrollReady() ? "true" : "false"}
-      data-fixed={fixed() ? "true" : "false"}
-      style={{
-        "--rolling-results-row-height": `${rowHeight()}px`,
-        "--rolling-results-fixed-height": `${fixed() ? fixedHeight() : 0}px`,
-        "--rolling-results-fixed-gap": `${gap()}px`,
-        "--rolling-results-row-gap": `${rowGap()}px`,
-        "--rolling-results-fade": `${Math.round(rowHeight() * 0.6)}px`,
-      }}
-    >
-      <div ref={view} data-slot="rolling-results-viewport" aria-live="polite">
-        <Show when={fixed()}>
-          <div data-slot="rolling-results-fixed">{props.fixed}</div>
-        </Show>
-        <div ref={windowEl} data-slot="rolling-results-window" onScroll={onWindowScroll}>
-          <div data-slot="rolling-results-body">
-            <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` }}
-            >
-              <For each={rendered()}>
-                {(item, index) => (
-                  <div data-slot="rolling-results-row" data-key={key(item, index())}>
-                    {props.render(item, index())}
-                  </div>
-                )}
-              </For>
-            </div>
-          </div>
-        </div>
-      </div>
-    </div>
-  )
-}

+ 12 - 7
packages/ui/src/components/scroll-view.css

@@ -9,13 +9,6 @@
   overflow-y: auto;
   scrollbar-width: none;
   outline: none;
-  display: block;
-  overflow-anchor: none;
-}
-
-.scroll-view__viewport[data-reverse="true"] {
-  display: flex;
-  flex-direction: column-reverse;
 }
 
 .scroll-view__viewport::-webkit-scrollbar {
@@ -52,6 +45,18 @@
   background-color: var(--border-strong-base);
 }
 
+.dark .scroll-view__thumb::after,
+[data-theme="dark"] .scroll-view__thumb::after {
+  background-color: var(--border-weak-base);
+}
+
+.dark .scroll-view__thumb:hover::after,
+[data-theme="dark"] .scroll-view__thumb:hover::after,
+.dark .scroll-view__thumb[data-dragging="true"]::after,
+[data-theme="dark"] .scroll-view__thumb[data-dragging="true"]::after {
+  background-color: var(--border-strong-base);
+}
+
 .scroll-view__thumb[data-visible="true"] {
   opacity: 1;
 }

+ 14 - 56
packages/ui/src/components/scroll-view.tsx

@@ -1,18 +1,17 @@
-import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show } from "solid-js"
-import { animate, type AnimationPlaybackControls } from "motion"
+import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js"
 import { useI18n } from "../context/i18n"
-import { FAST_SPRING } from "./motion"
 
 export interface ScrollViewProps extends ComponentProps<"div"> {
   viewportRef?: (el: HTMLDivElement) => void
-  reverse?: boolean
+  orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb
 }
 
 export function ScrollView(props: ScrollViewProps) {
   const i18n = useI18n()
+  const merged = mergeProps({ orientation: "vertical" }, props)
   const [local, events, rest] = splitProps(
-    props,
-    ["class", "children", "viewportRef", "style", "reverse"],
+    merged,
+    ["class", "children", "viewportRef", "orientation", "style"],
     [
       "onScroll",
       "onWheel",
@@ -26,9 +25,9 @@ export function ScrollView(props: ScrollViewProps) {
     ],
   )
 
+  let rootRef!: HTMLDivElement
   let viewportRef!: HTMLDivElement
   let thumbRef!: HTMLDivElement
-  let anim: AnimationPlaybackControls | undefined
 
   const [isHovered, setIsHovered] = createSignal(false)
   const [isDragging, setIsDragging] = createSignal(false)
@@ -37,8 +36,6 @@ export function ScrollView(props: ScrollViewProps) {
   const [thumbTop, setThumbTop] = createSignal(0)
   const [showThumb, setShowThumb] = createSignal(false)
 
-  const reverse = () => local.reverse === true
-
   const updateThumb = () => {
     if (!viewportRef) return
     const { scrollTop, scrollHeight, clientHeight } = viewportRef
@@ -60,13 +57,9 @@ export function ScrollView(props: ScrollViewProps) {
     const maxScrollTop = scrollHeight - clientHeight
     const maxThumbTop = trackHeight - height
 
-    const top = (() => {
-      if (maxScrollTop <= 0) return 0
-      if (!reverse()) return (scrollTop / maxScrollTop) * maxThumbTop
-      return ((maxScrollTop + scrollTop) / maxScrollTop) * maxThumbTop
-    })()
+    const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0
 
-    // Ensure thumb stays within bounds
+    // Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety)
     const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop))
 
     setThumbHeight(height)
@@ -89,7 +82,6 @@ export function ScrollView(props: ScrollViewProps) {
     }
 
     onCleanup(() => {
-      stop()
       observer.disconnect()
     })
 
@@ -131,31 +123,6 @@ export function ScrollView(props: ScrollViewProps) {
     thumbRef.addEventListener("pointerup", onPointerUp)
   }
 
-  const stop = () => {
-    if (!anim) return
-    anim.stop()
-    anim = undefined
-  }
-
-  const limit = (top: number) => {
-    const max = viewportRef.scrollHeight - viewportRef.clientHeight
-    if (reverse()) return Math.max(-max, Math.min(0, top))
-    return Math.max(0, Math.min(max, top))
-  }
-
-  const glide = (top: number) => {
-    stop()
-    anim = animate(viewportRef.scrollTop, limit(top), {
-      ...FAST_SPRING,
-      onUpdate: (v) => {
-        viewportRef.scrollTop = v
-      },
-      onComplete: () => {
-        anim = undefined
-      },
-    })
-  }
-
   // Keybinds implementation
   // We ensure the viewport has a tabindex so it can receive focus
   // We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior,
@@ -180,11 +147,11 @@ export function ScrollView(props: ScrollViewProps) {
         break
       case "Home":
         e.preventDefault()
-        glide(reverse() ? -(viewportRef.scrollHeight - viewportRef.clientHeight) : 0)
+        viewportRef.scrollTo({ top: 0, behavior: "smooth" })
         break
       case "End":
         e.preventDefault()
-        glide(reverse() ? 0 : viewportRef.scrollHeight - viewportRef.clientHeight)
+        viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" })
         break
       case "ArrowUp":
         e.preventDefault()
@@ -199,6 +166,7 @@ export function ScrollView(props: ScrollViewProps) {
 
   return (
     <div
+      ref={rootRef}
       class={`scroll-view ${local.class || ""}`}
       style={local.style}
       onPointerEnter={() => setIsHovered(true)}
@@ -209,26 +177,16 @@ export function ScrollView(props: ScrollViewProps) {
       <div
         ref={viewportRef}
         class="scroll-view__viewport"
-        data-reverse={reverse() ? "true" : undefined}
         onScroll={(e) => {
           updateThumb()
           if (typeof events.onScroll === "function") events.onScroll(e as any)
         }}
-        onWheel={(e) => {
-          if (e.deltaY) stop()
-          if (typeof events.onWheel === "function") events.onWheel(e as any)
-        }}
-        onTouchStart={(e) => {
-          stop()
-          if (typeof events.onTouchStart === "function") events.onTouchStart(e as any)
-        }}
+        onWheel={events.onWheel as any}
+        onTouchStart={events.onTouchStart as any}
         onTouchMove={events.onTouchMove as any}
         onTouchEnd={events.onTouchEnd as any}
         onTouchCancel={events.onTouchCancel as any}
-        onPointerDown={(e) => {
-          stop()
-          if (typeof events.onPointerDown === "function") events.onPointerDown(e as any)
-        }}
+        onPointerDown={events.onPointerDown as any}
         onClick={events.onClick as any}
         tabIndex={0}
         role="region"

+ 11 - 116
packages/ui/src/components/session-turn.css

@@ -1,4 +1,5 @@
 [data-component="session-turn"] {
+  --sticky-header-height: calc(var(--session-title-height, 0px) + 24px);
   height: 100%;
   min-height: 0;
   min-width: 0;
@@ -25,7 +26,7 @@
     align-items: flex-start;
     align-self: stretch;
     min-width: 0;
-    gap: 0px;
+    gap: 18px;
     overflow-anchor: none;
   }
 
@@ -42,127 +43,30 @@
     align-self: stretch;
   }
 
-  [data-slot="session-turn-assistant-lane"] {
-    width: 100%;
-    min-width: 0;
-    display: flex;
-    flex-direction: column;
-    align-self: stretch;
-  }
-
   [data-slot="session-turn-thinking"] {
     display: flex;
-    flex-wrap: nowrap;
     align-items: center;
     gap: 8px;
     width: 100%;
     min-width: 0;
-    white-space: nowrap;
     color: var(--text-weak);
     font-family: var(--font-family-sans);
     font-size: var(--font-size-base);
     font-weight: var(--font-weight-medium);
-    line-height: var(--line-height-large);
-    height: 36px;
+    line-height: 20px;
+    min-height: 20px;
 
     [data-component="spinner"] {
       width: 16px;
       height: 16px;
     }
-
-    > [data-component="text-shimmer"] {
-      flex: 0 0 auto;
-      white-space: nowrap;
-    }
-  }
-
-  [data-slot="session-turn-handoff-wrap"] {
-    width: 100%;
-    min-width: 0;
-    overflow: visible;
-  }
-
-  [data-slot="session-turn-handoff"] {
-    width: 100%;
-    min-width: 0;
-    min-height: 37px;
-    position: relative;
-  }
-
-  [data-slot="session-turn-thinking"] {
-    position: absolute;
-    inset: 0;
-    will-change: opacity, filter;
-    transition:
-      opacity 180ms ease-out,
-      filter 180ms ease-out,
-      transform 180ms ease-out;
-  }
-
-  [data-slot="session-turn-thinking"][data-visible="false"] {
-    opacity: 0;
-    filter: blur(2px);
-    transform: translateY(1px);
-    pointer-events: none;
-  }
-
-  [data-slot="session-turn-thinking"][data-visible="true"] {
-    opacity: 1;
-    filter: blur(0px);
-    transform: translateY(0px);
-  }
-
-  [data-slot="session-turn-meta"] {
-    position: absolute;
-    inset: 0;
-    min-height: 37px;
-    display: flex;
-    align-items: center;
-    justify-content: flex-start;
-    gap: 10px;
-    opacity: 0;
-    pointer-events: none;
-    transition: opacity 0.15s ease;
-  }
-
-  [data-slot="session-turn-meta"][data-interrupted] {
-    gap: 12px;
-  }
-
-  [data-slot="session-turn-meta"] [data-component="tooltip-trigger"] {
-    display: inline-flex;
-    width: fit-content;
-  }
-
-  [data-slot="session-turn-message-container"]:hover [data-slot="session-turn-meta"][data-visible="true"],
-  [data-slot="session-turn-message-container"]:focus-within [data-slot="session-turn-meta"][data-visible="true"] {
-    opacity: 1;
-    pointer-events: auto;
-  }
-
-  [data-slot="session-turn-meta-label"] {
-    user-select: none;
-    min-width: 0;
-    overflow: clip;
-    white-space: nowrap;
-    text-overflow: ellipsis;
   }
 
   [data-component="text-reveal"].session-turn-thinking-heading {
     flex: 1 1 auto;
     min-width: 0;
-    overflow: clip;
-    white-space: nowrap;
-    line-height: inherit;
     color: var(--text-weaker);
     font-weight: var(--font-weight-regular);
-
-    [data-slot="text-reveal-track"],
-    [data-slot="text-reveal-entering"],
-    [data-slot="text-reveal-leaving"] {
-      min-height: 0;
-      line-height: inherit;
-    }
   }
 
   .error-card {
@@ -180,7 +84,7 @@
     display: flex;
     flex-direction: column;
     align-self: stretch;
-    gap: 0px;
+    gap: 12px;
 
     > :first-child > [data-component="markdown"]:first-child {
       margin-top: 0;
@@ -205,7 +109,6 @@
 
   [data-component="session-turn-diffs-trigger"] {
     width: 100%;
-    height: 36px;
     display: flex;
     align-items: center;
     justify-content: flex-start;
@@ -215,7 +118,7 @@
 
   [data-slot="session-turn-diffs-title"] {
     display: inline-flex;
-    align-items: center;
+    align-items: baseline;
     gap: 8px;
   }
 
@@ -233,7 +136,7 @@
     font-variant-numeric: tabular-nums;
     font-size: var(--font-size-base);
     font-weight: var(--font-weight-regular);
-    line-height: var(--line-height-large);
+    line-height: var(--line-height-x-large);
   }
 
   [data-slot="session-turn-diffs-meta"] {
@@ -269,10 +172,8 @@
 
   [data-slot="session-turn-diff-path"] {
     display: flex;
+    flex-grow: 1;
     min-width: 0;
-    align-items: baseline;
-    overflow: clip;
-    white-space: nowrap;
 
     font-family: var(--font-family-sans);
     font-size: var(--font-size-small);
@@ -280,22 +181,16 @@
   }
 
   [data-slot="session-turn-diff-directory"] {
-    flex: 1 1 auto;
-    color: var(--text-weak);
-    min-width: 0;
-    overflow: clip;
+    color: var(--text-base);
+    overflow: hidden;
+    text-overflow: ellipsis;
     white-space: nowrap;
     direction: rtl;
-    unicode-bidi: plaintext;
     text-align: left;
   }
 
   [data-slot="session-turn-diff-filename"] {
     flex-shrink: 0;
-    max-width: 100%;
-    min-width: 0;
-    overflow: clip;
-    white-space: nowrap;
     color: var(--text-strong);
     font-weight: var(--font-weight-medium);
   }

+ 206 - 348
packages/ui/src/components/session-turn.tsx

@@ -3,27 +3,23 @@ import type { SessionStatus } from "@opencode-ai/sdk/v2"
 import { useData } from "../context"
 import { useFileComponent } from "../context/file"
 
-import { same } from "@opencode-ai/util/array"
 import { Binary } from "@opencode-ai/util/binary"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { createEffect, createMemo, createSignal, For, on, onCleanup, ParentProps, Show } from "solid-js"
+import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
 import { Dynamic } from "solid-js/web"
-import { GrowBox } from "./grow-box"
-import { AssistantParts, UserMessageDisplay, Part, PART_MAPPING } from "./message-part"
+import { AssistantParts, Message, Part, PART_MAPPING } from "./message-part"
 import { Card } from "./card"
 import { Accordion } from "./accordion"
 import { StickyAccordionHeader } from "./sticky-accordion-header"
 import { Collapsible } from "./collapsible"
 import { DiffChanges } from "./diff-changes"
 import { Icon } from "./icon"
-import { IconButton } from "./icon-button"
 import { TextShimmer } from "./text-shimmer"
-import { TextReveal } from "./text-reveal"
-import { list } from "./text-utils"
 import { SessionRetry } from "./session-retry"
-import { Tooltip } from "./tooltip"
+import { TextReveal } from "./text-reveal"
 import { createAutoScroll } from "../hooks"
 import { useI18n } from "../context/i18n"
+
 function record(value: unknown): value is Record<string, unknown> {
   return !!value && typeof value === "object" && !Array.isArray(value)
 }
@@ -77,12 +73,18 @@ function unwrap(message: string) {
   return message
 }
 
+function same<T>(a: readonly T[], b: readonly T[]) {
+  if (a === b) return true
+  if (a.length !== b.length) return false
+  return a.every((x, i) => x === b[i])
+}
+
+function list<T>(value: T[] | undefined | null, fallback: T[]) {
+  if (Array.isArray(value)) return value
+  return fallback
+}
+
 const hidden = new Set(["todowrite", "todoread"])
-const emptyMessages: MessageType[] = []
-const emptyAssistant: AssistantMessage[] = []
-const emptyDiffs: FileDiff[] = []
-const idle: SessionStatus = { type: "idle" as const }
-const handoffHoldMs = 120
 
 function partState(part: PartType, showReasoningSummaries: boolean) {
   if (part.type === "tool") {
@@ -139,7 +141,6 @@ export function SessionTurn(
   props: ParentProps<{
     sessionID: string
     messageID: string
-    animate?: boolean
     showReasoningSummaries?: boolean
     shellToolDefaultOpen?: boolean
     editToolDefaultOpen?: boolean
@@ -158,7 +159,11 @@ export function SessionTurn(
   const i18n = useI18n()
   const fileComponent = useFileComponent()
 
+  const emptyMessages: MessageType[] = []
   const emptyParts: PartType[] = []
+  const emptyAssistant: AssistantMessage[] = []
+  const emptyDiffs: FileDiff[] = []
+  const idle = { type: "idle" as const }
 
   const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages))
 
@@ -186,8 +191,42 @@ export function SessionTurn(
     return msg
   })
 
-  const active = createMemo(() => props.active ?? false)
-  const queued = createMemo(() => props.queued ?? false)
+  const pending = createMemo(() => {
+    if (typeof props.active === "boolean" && typeof props.queued === "boolean") return
+    const messages = allMessages() ?? emptyMessages
+    return messages.findLast(
+      (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
+    )
+  })
+
+  const pendingUser = createMemo(() => {
+    const item = pending()
+    if (!item?.parentID) return
+    const messages = allMessages() ?? emptyMessages
+    const result = Binary.search(messages, item.parentID, (m) => m.id)
+    const msg = result.found ? messages[result.index] : messages.find((m) => m.id === item.parentID)
+    if (!msg || msg.role !== "user") return
+    return msg
+  })
+
+  const active = createMemo(() => {
+    if (typeof props.active === "boolean") return props.active
+    const msg = message()
+    const parent = pendingUser()
+    if (!msg || !parent) return false
+    return parent.id === msg.id
+  })
+
+  const queued = createMemo(() => {
+    if (typeof props.queued === "boolean") return props.queued
+    const id = message()?.id
+    if (!id) return false
+    if (!pendingUser()) return false
+    const item = pending()
+    if (!item) return false
+    return id > item.id
+  })
+
   const parts = createMemo(() => {
     const msg = message()
     if (!msg) return emptyParts
@@ -250,7 +289,7 @@ export function SessionTurn(
   const error = createMemo(
     () => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error,
   )
-  const assistantCopyPart = createMemo(() => {
+  const showAssistantCopyPartID = createMemo(() => {
     const messages = assistantMessages()
 
     for (let i = messages.length - 1; i >= 0; i--) {
@@ -260,18 +299,13 @@ export function SessionTurn(
       const parts = list(data.store.part?.[message.id], emptyParts)
       for (let j = parts.length - 1; j >= 0; j--) {
         const part = parts[j]
-        if (!part || part.type !== "text") continue
-        const text = part.text?.trim()
-        if (!text) continue
-        return {
-          id: part.id,
-          text,
-          message,
-        }
+        if (!part || part.type !== "text" || !part.text?.trim()) continue
+        return part.id
       }
     }
+
+    return undefined
   })
-  const assistantCopyPartID = createMemo(() => assistantCopyPart()?.id ?? null)
   const errorText = createMemo(() => {
     const msg = error()?.data?.message
     if (typeof msg === "string") return unwrap(msg)
@@ -279,14 +313,18 @@ export function SessionTurn(
     return unwrap(String(msg))
   })
 
-  const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle)
-  const working = createMemo(() => {
-    if (status().type === "idle") return false
-    if (!message()) return false
-    return active()
+  const status = createMemo(() => {
+    if (props.status !== undefined) return props.status
+    if (typeof props.active === "boolean" && !props.active) return idle
+    return data.store.session_status[props.sessionID] ?? idle
   })
+  const working = createMemo(() => status().type !== "idle" && active())
   const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true)
-  const showDiffSummary = createMemo(() => edited() > 0 && !working())
+
+  const assistantCopyPartID = createMemo(() => {
+    if (working()) return null
+    return showAssistantCopyPartID() ?? null
+  })
   const turnDurationMs = createMemo(() => {
     const start = message()?.time.created
     if (typeof start !== "number") return undefined
@@ -326,109 +364,13 @@ export function SessionTurn(
       .filter((text): text is string => !!text)
       .at(-1),
   )
-  const thinking = createMemo(() => {
+  const showThinking = createMemo(() => {
     if (!working() || !!error()) return false
     if (queued()) return false
     if (status().type === "retry") return false
     if (showReasoningSummaries()) return assistantVisible() === 0
     return true
   })
-  const hasAssistant = createMemo(() => assistantMessages().length > 0)
-  const animateEnabled = createMemo(() => props.animate !== false)
-  const [live, setLive] = createSignal(false)
-  const thinkingOpen = createMemo(() => thinking() && (live() || !animateEnabled()))
-  const metaOpen = createMemo(() => !working() && !!assistantCopyPart())
-  const duration = createMemo(() => {
-    const ms = turnDurationMs()
-    if (typeof ms !== "number" || ms < 0) return ""
-
-    const total = Math.round(ms / 1000)
-    if (total < 60) return `${total}s`
-
-    const minutes = Math.floor(total / 60)
-    const seconds = total % 60
-    return `${minutes}m ${seconds}s`
-  })
-  const meta = createMemo(() => {
-    const item = assistantCopyPart()
-    if (!item) return ""
-
-    const agent = item.message.agent ? item.message.agent[0]?.toUpperCase() + item.message.agent.slice(1) : ""
-    const model = item.message.modelID
-      ? (data.store.provider?.all?.find((provider) => provider.id === item.message.providerID)?.models?.[
-          item.message.modelID
-        ]?.name ?? item.message.modelID)
-      : ""
-    return [agent, model, duration()].filter((value) => !!value).join("\u00A0\u00B7\u00A0")
-  })
-  const [copied, setCopied] = createSignal(false)
-  const [handoffHold, setHandoffHold] = createSignal(false)
-  const thinkingVisible = createMemo(() => thinkingOpen() || handoffHold())
-  const handoffOpen = createMemo(() => thinkingVisible() || metaOpen())
-  const lane = createMemo(() => hasAssistant() || handoffOpen())
-
-  let liveFrame: number | undefined
-  let copiedTimer: ReturnType<typeof setTimeout> | undefined
-  let handoffTimer: ReturnType<typeof setTimeout> | undefined
-
-  const copyAssistant = async () => {
-    const text = assistantCopyPart()?.text
-    if (!text) return
-
-    await navigator.clipboard.writeText(text)
-    setCopied(true)
-    if (copiedTimer !== undefined) clearTimeout(copiedTimer)
-    copiedTimer = setTimeout(() => {
-      copiedTimer = undefined
-      setCopied(false)
-    }, 2000)
-  }
-
-  createEffect(
-    on(
-      () => [animateEnabled(), working()] as const,
-      ([enabled, isWorking]) => {
-        if (liveFrame !== undefined) {
-          cancelAnimationFrame(liveFrame)
-          liveFrame = undefined
-        }
-        if (!enabled || !isWorking || live()) return
-        liveFrame = requestAnimationFrame(() => {
-          liveFrame = undefined
-          setLive(true)
-        })
-      },
-    ),
-  )
-
-  createEffect(
-    on(
-      () => [thinkingOpen(), metaOpen()] as const,
-      ([thinkingNow, metaNow]) => {
-        if (handoffTimer !== undefined) {
-          clearTimeout(handoffTimer)
-          handoffTimer = undefined
-        }
-
-        if (thinkingNow) {
-          setHandoffHold(true)
-          return
-        }
-
-        if (metaNow) {
-          setHandoffHold(false)
-          return
-        }
-
-        if (!handoffHold()) return
-        handoffTimer = setTimeout(() => {
-          handoffTimer = undefined
-          setHandoffHold(false)
-        }, handoffHoldMs)
-      },
-      { defer: true },
-    ),
-  )
 
   const autoScroll = createAutoScroll({
     working,
@@ -436,119 +378,6 @@ export function SessionTurn(
     overflowAnchor: "dynamic",
   })
 
-  onCleanup(() => {
-    if (liveFrame !== undefined) cancelAnimationFrame(liveFrame)
-    if (copiedTimer !== undefined) clearTimeout(copiedTimer)
-    if (handoffTimer !== undefined) clearTimeout(handoffTimer)
-  })
-
-  const turnDiffSummary = () => (
-    <div data-slot="session-turn-diffs">
-      <Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
-        <Collapsible.Trigger>
-          <div data-component="session-turn-diffs-trigger">
-            <div data-slot="session-turn-diffs-title">
-              <span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span>
-              <span data-slot="session-turn-diffs-count">
-                {edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
-              </span>
-              <div data-slot="session-turn-diffs-meta">
-                <DiffChanges changes={diffs()} variant="bars" />
-                <Collapsible.Arrow />
-              </div>
-            </div>
-          </div>
-        </Collapsible.Trigger>
-        <Collapsible.Content>
-          <Show when={open()}>
-            <div data-component="session-turn-diffs-content">
-              <Accordion
-                multiple
-                style={{ "--sticky-accordion-offset": "37px" }}
-                value={expanded()}
-                onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
-              >
-                <For each={diffs()}>
-                  {(diff) => {
-                    const active = createMemo(() => expanded().includes(diff.file))
-                    const [visible, setVisible] = createSignal(false)
-
-                    createEffect(
-                      on(
-                        active,
-                        (value) => {
-                          if (!value) {
-                            setVisible(false)
-                            return
-                          }
-
-                          requestAnimationFrame(() => {
-                            if (!active()) return
-                            setVisible(true)
-                          })
-                        },
-                        { defer: true },
-                      ),
-                    )
-
-                    return (
-                      <Accordion.Item value={diff.file}>
-                        <StickyAccordionHeader>
-                          <Accordion.Trigger>
-                            <div data-slot="session-turn-diff-trigger">
-                              <span data-slot="session-turn-diff-path">
-                                <Show when={diff.file.includes("/")}>
-                                  <span data-slot="session-turn-diff-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
-                                </Show>
-                                <span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
-                              </span>
-                              <div data-slot="session-turn-diff-meta">
-                                <span data-slot="session-turn-diff-changes">
-                                  <DiffChanges changes={diff} />
-                                </span>
-                                <span data-slot="session-turn-diff-chevron">
-                                  <Icon name="chevron-down" size="small" />
-                                </span>
-                              </div>
-                            </div>
-                          </Accordion.Trigger>
-                        </StickyAccordionHeader>
-                        <Accordion.Content>
-                          <Show when={visible()}>
-                            <div data-slot="session-turn-diff-view" data-scrollable>
-                              <Dynamic
-                                component={fileComponent}
-                                mode="diff"
-                                before={{ name: diff.file, contents: diff.before }}
-                                after={{ name: diff.file, contents: diff.after }}
-                              />
-                            </div>
-                          </Show>
-                        </Accordion.Content>
-                      </Accordion.Item>
-                    )
-                  }}
-                </For>
-              </Accordion>
-            </div>
-          </Show>
-        </Collapsible.Content>
-      </Collapsible>
-    </div>
-  )
-
-  const divider = (label: string) => (
-    <div data-component="compaction-part">
-      <div data-slot="compaction-part-divider">
-        <span data-slot="compaction-part-line" />
-        <span data-slot="compaction-part-label" class="text-12-regular text-text-weak">
-          {label}
-        </span>
-        <span data-slot="compaction-part-line" />
-      </div>
-    </div>
-  )
-
   return (
     <div data-component="session-turn" class={props.classes?.root}>
       <div
@@ -559,120 +388,149 @@ export function SessionTurn(
       >
         <div onClick={autoScroll.handleInteraction}>
           <Show when={message()}>
-            {(msg) => (
-              <div
-                ref={autoScroll.contentRef}
-                data-message={msg().id}
-                data-slot="session-turn-message-container"
-                class={props.classes?.container}
-              >
-                <div data-slot="session-turn-message-content" aria-live="off">
-                  <UserMessageDisplay
-                    message={msg()}
-                    parts={parts()}
-                    interrupted={interrupted()}
-                    animate={props.animate}
-                    queued={queued()}
+            <div
+              ref={autoScroll.contentRef}
+              data-message={message()!.id}
+              data-slot="session-turn-message-container"
+              class={props.classes?.container}
+            >
+              <div data-slot="session-turn-message-content" aria-live="off">
+                <Message message={message()!} parts={parts()} interrupted={interrupted()} queued={queued()} />
+              </div>
+              <Show when={compaction()}>
+                <div data-slot="session-turn-compaction">
+                  <Part part={compaction()!} message={message()!} hideDetails />
+                </div>
+              </Show>
+              <Show when={assistantMessages().length > 0}>
+                <div data-slot="session-turn-assistant-content" aria-hidden={working()}>
+                  <AssistantParts
+                    messages={assistantMessages()}
+                    showAssistantCopyPartID={assistantCopyPartID()}
+                    turnDurationMs={turnDurationMs()}
+                    working={working()}
+                    showReasoningSummaries={showReasoningSummaries()}
+                    shellToolDefaultOpen={props.shellToolDefaultOpen}
+                    editToolDefaultOpen={props.editToolDefaultOpen}
                   />
                 </div>
-                <Show when={compaction()}>
-                  {(part) => (
-                    <GrowBox animate={props.animate !== false} fade gap={8} class="w-full min-w-0">
-                      <div data-slot="session-turn-compaction">
-                        <Part part={part()} message={msg()} hideDetails />
-                      </div>
-                    </GrowBox>
-                  )}
-                </Show>
-                <div data-slot="session-turn-assistant-lane" aria-hidden={!lane()}>
-                  <Show when={hasAssistant()}>
-                    <div
-                      data-slot="session-turn-assistant-content"
-                      aria-hidden={working()}
-                      style={{ contain: "layout paint" }}
-                    >
-                      <AssistantParts
-                        messages={assistantMessages()}
-                        showAssistantCopyPartID={assistantCopyPartID()}
-                        showTurnDiffSummary={showDiffSummary()}
-                        turnDiffSummary={turnDiffSummary}
-                        working={working()}
-                        animate={live()}
-                        showReasoningSummaries={showReasoningSummaries()}
-                        shellToolDefaultOpen={props.shellToolDefaultOpen}
-                        editToolDefaultOpen={props.editToolDefaultOpen}
-                      />
-                    </div>
+              </Show>
+              <Show when={showThinking()}>
+                <div data-slot="session-turn-thinking">
+                  <TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
+                  <Show when={!showReasoningSummaries()}>
+                    <TextReveal
+                      text={reasoningHeading()}
+                      class="session-turn-thinking-heading"
+                      travel={25}
+                      duration={700}
+                    />
                   </Show>
-                  <GrowBox
-                    animate={live()}
-                    animateToggle={live()}
-                    open={handoffOpen()}
-                    fade
-                    slot="session-turn-handoff-wrap"
-                  >
-                    <div data-slot="session-turn-handoff">
-                      <div data-slot="session-turn-thinking" data-visible={thinkingVisible() ? "true" : "false"}>
-                        <TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
-                        <TextReveal
-                          text={!showReasoningSummaries() ? (reasoningHeading() ?? "") : ""}
-                          class="session-turn-thinking-heading"
-                          travel={25}
-                          duration={900}
-                        />
+                </div>
+              </Show>
+              <SessionRetry status={status()} show={active()} />
+              <Show when={edited() > 0 && !working()}>
+                <div data-slot="session-turn-diffs">
+                  <Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
+                    <Collapsible.Trigger>
+                      <div data-component="session-turn-diffs-trigger">
+                        <div data-slot="session-turn-diffs-title">
+                          <span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span>
+                          <span data-slot="session-turn-diffs-count">
+                            {edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
+                          </span>
+                          <div data-slot="session-turn-diffs-meta">
+                            <DiffChanges changes={diffs()} variant="bars" />
+                            <Collapsible.Arrow />
+                          </div>
+                        </div>
                       </div>
-                      <Show when={metaOpen()}>
-                        <div
-                          data-slot="session-turn-meta"
-                          data-visible={thinkingVisible() ? "false" : "true"}
-                          data-interrupted={interrupted() ? "" : undefined}
-                        >
-                          <Tooltip
-                            value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
-                            placement="top"
-                            gutter={4}
+                    </Collapsible.Trigger>
+                    <Collapsible.Content>
+                      <Show when={open()}>
+                        <div data-component="session-turn-diffs-content">
+                          <Accordion
+                            multiple
+                            style={{ "--sticky-accordion-offset": "40px" }}
+                            value={expanded()}
+                            onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
                           >
-                            <IconButton
-                              icon={copied() ? "check" : "copy"}
-                              size="normal"
-                              variant="ghost"
-                              onMouseDown={(event) => event.preventDefault()}
-                              onClick={() => void copyAssistant()}
-                              aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
-                            />
-                          </Tooltip>
-                          <Show when={meta()}>
-                            <span
-                              data-slot="session-turn-meta-label"
-                              class="text-12-regular text-text-weak cursor-default"
-                            >
-                              {meta()}
-                            </span>
-                          </Show>
+                            <For each={diffs()}>
+                              {(diff) => {
+                                const active = createMemo(() => expanded().includes(diff.file))
+                                const [visible, setVisible] = createSignal(false)
+
+                                createEffect(
+                                  on(
+                                    active,
+                                    (value) => {
+                                      if (!value) {
+                                        setVisible(false)
+                                        return
+                                      }
+
+                                      requestAnimationFrame(() => {
+                                        if (!active()) return
+                                        setVisible(true)
+                                      })
+                                    },
+                                    { defer: true },
+                                  ),
+                                )
+
+                                return (
+                                  <Accordion.Item value={diff.file}>
+                                    <StickyAccordionHeader>
+                                      <Accordion.Trigger>
+                                        <div data-slot="session-turn-diff-trigger">
+                                          <span data-slot="session-turn-diff-path">
+                                            <Show when={diff.file.includes("/")}>
+                                              <span data-slot="session-turn-diff-directory">
+                                                {`\u202A${getDirectory(diff.file)}\u202C`}
+                                              </span>
+                                            </Show>
+                                            <span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
+                                          </span>
+                                          <div data-slot="session-turn-diff-meta">
+                                            <span data-slot="session-turn-diff-changes">
+                                              <DiffChanges changes={diff} />
+                                            </span>
+                                            <span data-slot="session-turn-diff-chevron">
+                                              <Icon name="chevron-down" size="small" />
+                                            </span>
+                                          </div>
+                                        </div>
+                                      </Accordion.Trigger>
+                                    </StickyAccordionHeader>
+                                    <Accordion.Content>
+                                      <Show when={visible()}>
+                                        <div data-slot="session-turn-diff-view" data-scrollable>
+                                          <Dynamic
+                                            component={fileComponent}
+                                            mode="diff"
+                                            before={{ name: diff.file, contents: diff.before }}
+                                            after={{ name: diff.file, contents: diff.after }}
+                                          />
+                                        </div>
+                                      </Show>
+                                    </Accordion.Content>
+                                  </Accordion.Item>
+                                )
+                              }}
+                            </For>
+                          </Accordion>
                         </div>
                       </Show>
-                    </div>
-                  </GrowBox>
+                    </Collapsible.Content>
+                  </Collapsible>
                 </div>
-                <GrowBox animate={props.animate !== false} fade gap={0} open={interrupted()} class="w-full min-w-0">
-                  {divider(i18n.t("ui.message.interrupted"))}
-                </GrowBox>
-                <SessionRetry status={status()} show={active()} />
-                <GrowBox
-                  animate={props.animate !== false}
-                  fade
-                  gap={0}
-                  open={showDiffSummary() && !assistantCopyPartID()}
-                >
-                  {turnDiffSummary()}
-                </GrowBox>
-                <Show when={error()}>
-                  <Card variant="error" class="error-card">
-                    {errorText()}
-                  </Card>
-                </Show>
-              </div>
-            )}
+              </Show>
+              <Show when={error()}>
+                <Card variant="error" class="error-card">
+                  {errorText()}
+                </Card>
+              </Show>
+            </div>
           </Show>
           {props.children}
         </div>

+ 0 - 291
packages/ui/src/components/shell-rolling-results.tsx

@@ -1,291 +0,0 @@
-import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
-import stripAnsi from "strip-ansi"
-import type { ToolPart } from "@opencode-ai/sdk/v2"
-import { useReducedMotion } from "../hooks/use-reduced-motion"
-import { useI18n } from "../context/i18n"
-import { RollingResults } from "./rolling-results"
-import { Icon } from "./icon"
-import { IconButton } from "./icon-button"
-import { TextShimmer } from "./text-shimmer"
-import { Tooltip } from "./tooltip"
-import { GROW_SPRING } from "./motion"
-import { useSpring } from "./motion-spring"
-import { busy, createThrottledValue, updateScrollMask, useCollapsible, useRowWipe, useToolFade } from "./tool-utils"
-
-function ShellRollingSubtitle(props: { text: string; animate?: boolean }) {
-  let ref: HTMLSpanElement | undefined
-  useToolFade(() => ref, { wipe: true, animate: props.animate })
-
-  return (
-    <span data-slot="shell-rolling-subtitle">
-      <span ref={ref}>{props.text}</span>
-    </span>
-  )
-}
-
-function firstLine(text: string) {
-  return text
-    .split(/\r\n|\n|\r/g)
-    .map((item) => item.trim())
-    .find((item) => item.length > 0)
-}
-
-function shellRows(output: string) {
-  const rows: { id: string; text: string }[] = []
-  const lines = output
-    .split(/\r\n|\n|\r/g)
-    .map((item) => item.trimEnd())
-    .filter((item) => item.length > 0)
-  const start = Math.max(0, lines.length - 80)
-  for (let i = start; i < lines.length; i++) {
-    rows.push({ id: `line:${i}`, text: lines[i]! })
-  }
-
-  return rows
-}
-
-function ShellRollingCommand(props: { text: string; animate?: boolean }) {
-  let ref: HTMLSpanElement | undefined
-  useToolFade(() => ref, { wipe: true, animate: props.animate })
-
-  return (
-    <div data-component="shell-rolling-command">
-      <span ref={ref} data-slot="shell-rolling-text">
-        <span data-slot="shell-rolling-prompt">$</span> {props.text}
-      </span>
-    </div>
-  )
-}
-
-function ShellExpanded(props: { cmd: string; out: string; open: boolean }) {
-  const i18n = useI18n()
-  const rows = 10
-  const rowHeight = 22
-  const max = rows * rowHeight
-
-  let contentRef: HTMLDivElement | undefined
-  let bodyRef: HTMLDivElement | undefined
-  let scrollRef: HTMLDivElement | undefined
-  let topRef: HTMLDivElement | undefined
-  const [copied, setCopied] = createSignal(false)
-  const [cap, setCap] = createSignal(max)
-
-  const updateMask = () => {
-    if (scrollRef) updateScrollMask(scrollRef)
-  }
-
-  const resize = () => {
-    const top = Math.ceil(topRef?.getBoundingClientRect().height ?? 0)
-    setCap(Math.max(rowHeight * 2, max - top - (props.out ? 1 : 0)))
-  }
-
-  const measure = () => {
-    resize()
-    return Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0)
-  }
-
-  onMount(() => {
-    resize()
-    if (!topRef) return
-    const obs = new ResizeObserver(resize)
-    obs.observe(topRef)
-    onCleanup(() => obs.disconnect())
-  })
-
-  createEffect(() => {
-    props.cmd
-    props.out
-    queueMicrotask(() => {
-      resize()
-      updateMask()
-    })
-  })
-
-  useCollapsible({
-    content: () => contentRef,
-    body: () => bodyRef,
-    open: () => props.open,
-    measure,
-    onOpen: updateMask,
-  })
-
-  const handleCopy = async (e: MouseEvent) => {
-    e.stopPropagation()
-    const cmd = props.cmd ? `$ ${props.cmd}` : ""
-    const text = `${cmd}${props.out ? `${cmd ? "\n\n" : ""}${props.out}` : ""}`
-    if (!text) return
-    await navigator.clipboard.writeText(text)
-    setCopied(true)
-    setTimeout(() => setCopied(false), 2000)
-  }
-
-  return (
-    <div ref={contentRef} style={{ overflow: "clip", height: "0px", display: "none" }}>
-      <div ref={bodyRef} data-component="shell-expanded-shell">
-        <div data-slot="shell-expanded-body">
-          <div ref={topRef} data-slot="shell-expanded-top">
-            <div data-slot="shell-expanded-command">
-              <span data-slot="shell-expanded-prompt">$</span>
-              <span data-slot="shell-expanded-input">{props.cmd}</span>
-            </div>
-            <div data-slot="shell-expanded-actions">
-              <Tooltip
-                value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
-                placement="top"
-                gutter={4}
-              >
-                <IconButton
-                  icon={copied() ? "check" : "copy"}
-                  size="small"
-                  variant="ghost"
-                  class="shell-expanded-copy"
-                  onMouseDown={(e: MouseEvent) => e.preventDefault()}
-                  onClick={handleCopy}
-                  aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
-                />
-              </Tooltip>
-            </div>
-          </div>
-          <Show when={props.out}>
-            <>
-              <div data-slot="shell-expanded-divider" />
-              <div
-                ref={scrollRef}
-                data-component="shell-expanded-output"
-                data-scrollable
-                onScroll={updateMask}
-                style={{ "max-height": `${cap()}px` }}
-              >
-                <pre data-slot="shell-expanded-pre">
-                  <code>{props.out}</code>
-                </pre>
-              </div>
-            </>
-          </Show>
-        </div>
-      </div>
-    </div>
-  )
-}
-
-export function ShellRollingResults(props: { part: ToolPart; animate?: boolean; defaultOpen?: boolean }) {
-  const i18n = useI18n()
-  const reduce = useReducedMotion()
-  const wiped = new Set<string>()
-  const [mounted, setMounted] = createSignal(false)
-  const [open, setOpen] = createSignal(props.defaultOpen ?? true)
-  onMount(() => setMounted(true))
-  const state = createMemo(() => props.part.state as Record<string, any>)
-  const pending = createMemo(() => busy(props.part.state.status))
-  const expanded = createMemo(() => open() && !pending())
-  const previewOpen = createMemo(() => open() && pending())
-  const command = createMemo(() => {
-    const value = state().input?.command ?? state().metadata?.command
-    if (typeof value === "string") return value
-    return ""
-  })
-  const subtitle = createMemo(() => {
-    const value = state().input?.description ?? state().metadata?.description
-    if (typeof value === "string" && value.trim().length > 0) return value
-    return firstLine(command()) ?? ""
-  })
-  const output = createMemo(() => {
-    const value = state().output ?? state().metadata?.output
-    if (typeof value === "string") return value
-    return ""
-  })
-  const skip = () => reduce() || props.animate === false
-  const opacity = useSpring(() => (mounted() ? 1 : 0), GROW_SPRING)
-  const blur = useSpring(() => (mounted() ? 0 : 2), GROW_SPRING)
-  const previewOpacity = useSpring(() => (previewOpen() ? 1 : 0), GROW_SPRING)
-  const previewBlur = useSpring(() => (previewOpen() ? 0 : 2), GROW_SPRING)
-  const headerHeight = useSpring(() => (mounted() ? 37 : 0), GROW_SPRING)
-  let headerClipRef: HTMLDivElement | undefined
-  const handleHeaderClick = () => {
-    const el = headerClipRef
-    const viewport = el?.closest(".scroll-view__viewport") as HTMLElement | null
-    const beforeY = el?.getBoundingClientRect().top ?? 0
-    setOpen((prev) => !prev)
-    if (viewport && el) {
-      requestAnimationFrame(() => {
-        const afterY = el.getBoundingClientRect().top
-        const delta = afterY - beforeY
-        if (delta !== 0) viewport.scrollTop += delta
-      })
-    }
-  }
-  const line = createMemo(() => firstLine(command()))
-  const fixed = createMemo(() => {
-    const value = line()
-    if (!value) return
-    return <ShellRollingCommand text={value} animate={props.animate} />
-  })
-  const text = createThrottledValue(() => stripAnsi(output()))
-  const rows = createMemo(() => shellRows(text()))
-
-  return (
-    <div
-      data-component="shell-rolling-results"
-      style={{ opacity: skip() ? (mounted() ? 1 : 0) : opacity(), filter: `blur(${skip() ? 0 : blur()}px)` }}
-    >
-      <div
-        ref={headerClipRef}
-        data-slot="shell-rolling-header-clip"
-        data-scroll-preserve
-        data-clickable="true"
-        onClick={handleHeaderClick}
-        style={{ height: `${skip() ? (mounted() ? 37 : 0) : headerHeight()}px`, overflow: "clip" }}
-      >
-        <div data-slot="shell-rolling-header">
-          <span data-slot="shell-rolling-title">
-            <TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} />
-          </span>
-          <Show when={subtitle()}>{(text) => <ShellRollingSubtitle text={text()} animate={props.animate} />}</Show>
-          <span data-slot="shell-rolling-actions">
-            <span data-slot="shell-rolling-arrow" data-open={open() ? "true" : "false"}>
-              <Icon name="chevron-down" size="small" />
-            </span>
-          </span>
-        </div>
-      </div>
-      <div
-        data-slot="shell-rolling-preview"
-        style={{
-          opacity: skip() ? (previewOpen() ? 1 : 0) : previewOpacity(),
-          filter: `blur(${skip() ? 0 : previewBlur()}px)`,
-        }}
-      >
-        <RollingResults
-          class="shell-rolling-output"
-          noFadeOnCollapse
-          items={rows()}
-          fixed={fixed()}
-          fixedHeight={22}
-          rows={5}
-          rowHeight={22}
-          rowGap={0}
-          open={previewOpen()}
-          animate={props.animate !== false}
-          getKey={(row) => row.id}
-          render={(row) => {
-            const [textRef, setTextRef] = createSignal<HTMLSpanElement>()
-            useRowWipe({
-              id: () => row.id,
-              text: () => row.text,
-              ref: textRef,
-              seen: wiped,
-            })
-            return (
-              <div data-component="shell-rolling-row">
-                <span ref={setTextRef} data-slot="shell-rolling-text">
-                  {row.text}
-                </span>
-              </div>
-            )
-          }}
-        />
-      </div>
-      <ShellExpanded cmd={command()} out={text()} open={expanded()} />
-    </div>
-  )
-}

+ 11 - 1
packages/ui/src/components/shell-submessage.css

@@ -1,13 +1,23 @@
 [data-component="shell-submessage"] {
   min-width: 0;
   max-width: 100%;
-  display: inline-block;
+  display: inline-flex;
+  align-items: baseline;
   vertical-align: baseline;
 }
 
+[data-component="shell-submessage"] [data-slot="shell-submessage-width"] {
+  min-width: 0;
+  max-width: 100%;
+  display: inline-flex;
+  align-items: baseline;
+  overflow: hidden;
+}
+
 [data-component="shell-submessage"] [data-slot="shell-submessage-value"] {
   display: inline-block;
   vertical-align: baseline;
   min-width: 0;
+  line-height: inherit;
   white-space: nowrap;
 }

+ 31 - 30
packages/ui/src/components/text-reveal.css

@@ -4,14 +4,14 @@
  * Instead of sliding text through a fixed mask (odometer style),
  * the mask itself sweeps across each span to reveal/hide text.
  *
- * Direction: bottom-to-top. New text rises in from below, old text exits upward.
+ * Direction: top-to-bottom. New text drops in from above, old text exits downward.
  *
- * Entering: gradient reveals bottom-to-top (bottom of text appears first).
+ * Entering: gradient reveals top-to-bottom (top of text appears first).
  *   gradient(to bottom, white 33%, transparent 33%+edge)
  *   pos 0 100% = transparent covers element = hidden
  *   pos 0 0%   = white covers element       = visible
  *
- * Leaving: gradient hides bottom-to-top (bottom of text disappears first).
+ * Leaving: gradient hides top-to-bottom (top of text disappears first).
  *   gradient(to top, white 33%, transparent 33%+edge)
  *   pos 0 100% = white covers element       = visible
  *   pos 0 0%   = transparent covers element = hidden
@@ -56,17 +56,17 @@
     transition-timing-function: var(--_spring);
   }
 
-  /* ── entering: reveal bottom-to-top ──
-   * Gradient(to bottom): white at top, transparent at bottom of mask.
-   * Settled pos 0 0%   = white covers element   = visible
-   * Swap    pos 0 100% = transparent covers      = hidden
-   * Rises from below: translateY(travel) → translateY(0)
+  /* ── entering: reveal top-to-bottom ──
+   * Gradient(to top): white at bottom, transparent at top of mask.
+   * Settled pos 0 100% = white covers element   = visible
+   * Swap    pos 0 0%   = transparent covers      = hidden
+   * Slides from above: translateY(-travel) → translateY(0)
    */
   [data-slot="text-reveal-entering"] {
-    mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
-    -webkit-mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
-    mask-position: 0 0%;
-    -webkit-mask-position: 0 0%;
+    mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
+    -webkit-mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
+    mask-position: 0 100%;
+    -webkit-mask-position: 0 100%;
     transition-property:
       mask-position,
       -webkit-mask-position,
@@ -74,37 +74,37 @@
     transform: translateY(0);
   }
 
-  /* ── leaving: hide bottom-to-top + slide upward ──
-   * Gradient(to top): white at bottom, transparent at top of mask.
-   * Swap    pos 0 100% = white covers element   = visible
-   * Settled pos 0 0%   = transparent covers      = hidden
-   * Slides up: translateY(0) → translateY(-travel)
+  /* ── leaving: hide top-to-bottom + slide downward ──
+   * Gradient(to bottom): white at top, transparent at bottom of mask.
+   * Swap    pos 0 0%   = white covers element   = visible
+   * Settled pos 0 100% = transparent covers      = hidden
+   * Slides down: translateY(0) → translateY(travel)
    */
   [data-slot="text-reveal-leaving"] {
-    mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
-    -webkit-mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
-    mask-position: 0 0%;
-    -webkit-mask-position: 0 0%;
+    mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
+    -webkit-mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
+    mask-position: 0 100%;
+    -webkit-mask-position: 0 100%;
     transition-property:
       mask-position,
       -webkit-mask-position,
       transform;
-    transform: translateY(calc(var(--_travel) * -1));
+    transform: translateY(var(--_travel));
   }
 
   /* ── swapping: instant reset ──
-   * Snap entering to hidden (below), leaving to visible (center).
+   * Snap entering to hidden (above), leaving to visible (center).
    */
   &[data-swapping="true"] [data-slot="text-reveal-entering"] {
-    mask-position: 0 100%;
-    -webkit-mask-position: 0 100%;
-    transform: translateY(var(--_travel));
+    mask-position: 0 0%;
+    -webkit-mask-position: 0 0%;
+    transform: translateY(calc(var(--_travel) * -1));
     transition-duration: 0ms !important;
   }
 
   &[data-swapping="true"] [data-slot="text-reveal-leaving"] {
-    mask-position: 0 100%;
-    -webkit-mask-position: 0 100%;
+    mask-position: 0 0%;
+    -webkit-mask-position: 0 0%;
     transform: translateY(0);
     transition-duration: 0ms !important;
   }
@@ -126,14 +126,15 @@
   &[data-truncate="true"] [data-slot="text-reveal-track"] {
     width: 100%;
     min-width: 0;
-    overflow: clip;
+    overflow: hidden;
   }
 
   &[data-truncate="true"] [data-slot="text-reveal-entering"],
   &[data-truncate="true"] [data-slot="text-reveal-leaving"] {
     min-width: 0;
     width: 100%;
-    overflow: clip;
+    overflow: hidden;
+    text-overflow: ellipsis;
   }
 }
 

+ 9 - 106
packages/ui/src/components/text-reveal.tsx

@@ -1,13 +1,4 @@
 import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js"
-import { useReducedMotion } from "../hooks/use-reduced-motion"
-import {
-  animate,
-  type AnimationPlaybackControls,
-  clearFadeStyles,
-  clearMaskStyles,
-  GROW_SPRING,
-  WIPE_MASK,
-} from "./motion"
 
 const px = (value: number | string | undefined, fallback: number) => {
   if (typeof value === "number") return `${value}px`
@@ -26,11 +17,6 @@ const pct = (value: number | undefined, fallback: number) => {
   return `${v}%`
 }
 
-const clearWipe = (el: HTMLElement) => {
-  clearFadeStyles(el)
-  clearMaskStyles(el)
-}
-
 export function TextReveal(props: {
   text?: string
   class?: string
@@ -53,8 +39,10 @@ export function TextReveal(props: {
   let outRef: HTMLSpanElement | undefined
   let rootRef: HTMLSpanElement | undefined
   let frame: number | undefined
+
   const win = () => inRef?.scrollWidth ?? 0
   const wout = () => outRef?.scrollWidth ?? 0
+
   const widen = (next: number) => {
     if (next <= 0) return
     if (props.growOnly ?? true) {
@@ -63,14 +51,21 @@ export function TextReveal(props: {
     }
     setWidth(`${next}px`)
   }
+
   createEffect(
     on(
       () => props.text,
       (next, prev) => {
         if (next === prev) return
+        if (typeof next === "string" && typeof prev === "string" && next.startsWith(prev)) {
+          setCur(next)
+          widen(win())
+          return
+        }
         setSwapping(true)
         setOld(prev)
         setCur(next)
+
         if (typeof requestAnimationFrame !== "function") {
           widen(Math.max(win(), wout()))
           rootRef?.offsetHeight
@@ -138,95 +133,3 @@ export function TextReveal(props: {
     </span>
   )
 }
-
-export function TextWipe(props: { text?: string; class?: string; delay?: number; animate?: boolean }) {
-  let ref: HTMLSpanElement | undefined
-  let frame: number | undefined
-  let anim: AnimationPlaybackControls | undefined
-  const reduce = useReducedMotion()
-
-  const run = () => {
-    if (props.animate === false) return
-    const el = ref
-    if (!el || !props.text || typeof window === "undefined") return
-    if (reduce()) return
-
-    const mask =
-      typeof CSS !== "undefined" &&
-      (CSS.supports("mask-image", "linear-gradient(to right, black, transparent)") ||
-        CSS.supports("-webkit-mask-image", "linear-gradient(to right, black, transparent)"))
-
-    anim?.stop()
-    if (frame !== undefined && typeof cancelAnimationFrame === "function") {
-      cancelAnimationFrame(frame)
-      frame = undefined
-    }
-
-    el.style.opacity = "0"
-    el.style.filter = "blur(3px)"
-    el.style.transform = "translateX(-0.06em)"
-
-    if (mask) {
-      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%"
-    }
-
-    if (typeof requestAnimationFrame !== "function") {
-      clearWipe(el)
-      return
-    }
-
-    frame = requestAnimationFrame(() => {
-      frame = undefined
-      const node = ref
-      if (!node) return
-      anim = mask
-        ? animate(
-            node,
-            { opacity: 1, filter: "blur(0px)", transform: "translateX(0)", maskPosition: "0% 0%" },
-            { ...GROW_SPRING, delay: props.delay ?? 0 },
-          )
-        : animate(
-            node,
-            { opacity: 1, filter: "blur(0px)", transform: "translateX(0)" },
-            { ...GROW_SPRING, delay: props.delay ?? 0 },
-          )
-
-      anim?.finished.then(() => {
-        const value = ref
-        if (!value) return
-        clearWipe(value)
-      })
-    })
-  }
-
-  createEffect(
-    on(
-      () => [props.text, props.animate] as const,
-      ([text, enabled]) => {
-        if (!text || enabled === false) {
-          if (ref) clearWipe(ref)
-          return
-        }
-        run()
-      },
-    ),
-  )
-
-  onCleanup(() => {
-    if (frame !== undefined && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame)
-    anim?.stop()
-  })
-
-  return (
-    <span ref={ref} class={props.class} aria-label={props.text ?? ""}>
-      {props.text ?? "\u00A0"}
-    </span>
-  )
-}

+ 6 - 11
packages/ui/src/components/text-shimmer.css

@@ -1,11 +1,11 @@
 [data-component="text-shimmer"] {
   --text-shimmer-step: 45ms;
-  --text-shimmer-duration: 2000ms;
+  --text-shimmer-duration: 1200ms;
   --text-shimmer-swap: 220ms;
   --text-shimmer-index: 0;
   --text-shimmer-angle: 90deg;
   --text-shimmer-spread: 5.2ch;
-  --text-shimmer-size: 600%;
+  --text-shimmer-size: 360%;
   --text-shimmer-base-color: var(--text-weak);
   --text-shimmer-peak-color: var(--text-strong);
   --text-shimmer-sweep: linear-gradient(
@@ -16,17 +16,15 @@
   );
   --text-shimmer-base: linear-gradient(var(--text-shimmer-base-color), var(--text-shimmer-base-color));
 
-  display: inline-block;
-  vertical-align: baseline;
+  display: inline-flex;
+  align-items: baseline;
   font: inherit;
   letter-spacing: inherit;
   line-height: inherit;
 }
 
 [data-component="text-shimmer"] [data-slot="text-shimmer-char"] {
-  display: inline-block;
-  position: relative;
-  vertical-align: baseline;
+  display: inline-grid;
   white-space: pre;
   font: inherit;
   letter-spacing: inherit;
@@ -35,7 +33,7 @@
 
 [data-component="text-shimmer"] [data-slot="text-shimmer-char-base"],
 [data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
-  display: inline-block;
+  grid-area: 1 / 1;
   white-space: pre;
   transition: opacity var(--text-shimmer-swap) ease-out;
   font: inherit;
@@ -44,14 +42,11 @@
 }
 
 [data-component="text-shimmer"] [data-slot="text-shimmer-char-base"] {
-  position: relative;
   color: inherit;
   opacity: 1;
 }
 
 [data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
-  position: absolute;
-  inset: 0;
   color: var(--text-weaker);
   opacity: 0;
 }

+ 0 - 12
packages/ui/src/components/text-shimmer.tsx

@@ -37,16 +37,6 @@ export const TextShimmer = <T extends ValidComponent = "span">(props: {
     clearTimeout(timer)
   })
 
-  const len = createMemo(() => Math.max(text().length, 1))
-  const shimmerSize = createMemo(() => Math.max(300, Math.round(200 + 1400 / len())))
-
-  // duration = len × (size - 1) / velocity → uniform perceived sweep speed
-  const VELOCITY = 0.01375 // ch per ms, ~10% faster than original 0.0125 baseline
-  const shimmerDuration = createMemo(() => {
-    const s = shimmerSize() / 100
-    return Math.max(1000, Math.min(2500, Math.round((len() * (s - 1)) / VELOCITY)))
-  })
-
   return (
     <Dynamic
       component={props.as ?? "span"}
@@ -57,8 +47,6 @@ export const TextShimmer = <T extends ValidComponent = "span">(props: {
       style={{
         "--text-shimmer-swap": `${swap}ms`,
         "--text-shimmer-index": `${offset()}`,
-        "--text-shimmer-size": `${shimmerSize()}%`,
-        "--text-shimmer-duration": `${shimmerDuration()}ms`,
       }}
     >
       <span data-slot="text-shimmer-char">

+ 0 - 17
packages/ui/src/components/text-utils.ts

@@ -1,17 +0,0 @@
-/** Find the longest common character prefix between two strings. */
-export function commonPrefix(a: string, b: string) {
-  const ac = Array.from(a)
-  const bc = Array.from(b)
-  let i = 0
-  while (i < ac.length && i < bc.length && ac[i] === bc[i]) i++
-  return {
-    prefix: ac.slice(0, i).join(""),
-    aSuffix: ac.slice(i).join(""),
-    bSuffix: bc.slice(i).join(""),
-  }
-}
-
-export function list<T>(value: T[] | undefined | null, fallback: T[]): T[] {
-  if (Array.isArray(value)) return value
-  return fallback
-}

+ 3 - 3
packages/ui/src/components/tool-count-label.css

@@ -27,10 +27,10 @@
     grid-template-columns: 0fr;
     opacity: 0;
     filter: blur(calc(var(--tool-motion-blur, 2px) * 0.42));
-    overflow: clip;
+    overflow: hidden;
     transform: translateX(-0.04em);
     transition-property: grid-template-columns, opacity, filter, transform;
-    transition-duration: 800ms, 400ms, 400ms, 800ms;
+    transition-duration: 250ms, 250ms, 250ms, 250ms;
     transition-timing-function:
       var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
       var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
@@ -45,7 +45,7 @@
 
   [data-slot="tool-count-label-suffix-inner"] {
     min-width: 0;
-    overflow: clip;
+    overflow: hidden;
     white-space: pre;
   }
 }

+ 16 - 5
packages/ui/src/components/tool-count-label.tsx

@@ -1,6 +1,5 @@
 import { createMemo } from "solid-js"
 import { AnimatedNumber } from "./animated-number"
-import { commonPrefix } from "./text-utils"
 
 function split(text: string) {
   const match = /{{\s*count\s*}}/.exec(text)
@@ -12,23 +11,35 @@ function split(text: string) {
   }
 }
 
+function common(one: string, other: string) {
+  const a = Array.from(one)
+  const b = Array.from(other)
+  let i = 0
+  while (i < a.length && i < b.length && a[i] === b[i]) i++
+  return {
+    stem: a.slice(0, i).join(""),
+    one: a.slice(i).join(""),
+    other: b.slice(i).join(""),
+  }
+}
+
 export function AnimatedCountLabel(props: { count: number; one: string; other: string; class?: string }) {
   const one = createMemo(() => split(props.one))
   const other = createMemo(() => split(props.other))
   const singular = createMemo(() => Math.round(props.count) === 1)
   const active = createMemo(() => (singular() ? one() : other()))
-  const suffix = createMemo(() => commonPrefix(one().after, other().after))
+  const suffix = createMemo(() => common(one().after, other().after))
   const splitSuffix = createMemo(
     () =>
       one().before === other().before &&
       (one().after.startsWith(other().after) || other().after.startsWith(one().after)),
   )
   const before = createMemo(() => (splitSuffix() ? one().before : active().before))
-  const stem = createMemo(() => (splitSuffix() ? suffix().prefix : active().after))
+  const stem = createMemo(() => (splitSuffix() ? suffix().stem : active().after))
   const tail = createMemo(() => {
     if (!splitSuffix()) return ""
-    if (singular()) return suffix().aSuffix
-    return suffix().bSuffix
+    if (singular()) return suffix().one
+    return suffix().other
   })
   const showTail = createMemo(() => splitSuffix() && tail().length > 0)
 

+ 11 - 10
packages/ui/src/components/tool-count-summary.css

@@ -10,12 +10,12 @@
     opacity: 1;
     filter: blur(0);
     transform: translateY(0) scale(1);
-    overflow: clip;
+    overflow: hidden;
     transform-origin: left center;
     transition-property: grid-template-columns, opacity, filter, transform;
     transition-duration:
-      var(--tool-motion-spring-ms, 800ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms),
-      var(--tool-motion-spring-ms, 800ms);
+      var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 240ms), var(--tool-motion-fade-ms, 280ms),
+      var(--tool-motion-spring-ms, 480ms);
     transition-timing-function:
       var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
       var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
@@ -35,12 +35,12 @@
     opacity: 0;
     filter: blur(var(--tool-motion-blur, 2px));
     transform: translateY(0.06em) scale(0.985);
-    overflow: clip;
+    overflow: hidden;
     transform-origin: left center;
     transition-property: grid-template-columns, opacity, filter, transform;
     transition-duration:
-      var(--tool-motion-spring-ms, 800ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms),
-      var(--tool-motion-spring-ms, 800ms);
+      var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 280ms), var(--tool-motion-fade-ms, 320ms),
+      var(--tool-motion-spring-ms, 480ms);
     transition-timing-function:
       var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
       var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
@@ -55,7 +55,7 @@
 
   [data-slot="tool-count-summary-empty-inner"] {
     min-width: 0;
-    overflow: clip;
+    overflow: hidden;
     white-space: nowrap;
   }
 
@@ -63,7 +63,7 @@
     display: inline-flex;
     align-items: baseline;
     min-width: 0;
-    overflow: clip;
+    overflow: hidden;
     white-space: nowrap;
   }
 
@@ -75,11 +75,12 @@
     margin-right: 0;
     opacity: 0;
     filter: blur(calc(var(--tool-motion-blur, 2px) * 0.55));
-    overflow: clip;
+    overflow: hidden;
     transform: translateX(-0.08em);
     transition-property: opacity, filter, transform;
     transition-duration:
-      var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms);
+      calc(var(--tool-motion-fade-ms, 200ms) * 0.75), calc(var(--tool-motion-fade-ms, 220ms) * 0.75),
+      calc(var(--tool-motion-fade-ms, 220ms) * 0.6);
     transition-timing-function: ease-out, ease-out, ease-out;
   }
 

+ 4 - 3
packages/ui/src/components/tool-status-title.css

@@ -18,8 +18,9 @@
   [data-slot="tool-status-swap"],
   [data-slot="tool-status-tail"] {
     display: inline-grid;
-    overflow: clip;
+    overflow: hidden;
     justify-items: start;
+    transition: width var(--tool-motion-spring-ms, 480ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
   }
 
   [data-slot="tool-status-active"],
@@ -30,8 +31,8 @@
     text-align: start;
     transition-property: opacity, filter, transform;
     transition-duration:
-      var(--tool-motion-fade-ms, 400ms), calc(var(--tool-motion-fade-ms, 400ms) * 0.8),
-      calc(var(--tool-motion-fade-ms, 400ms) * 0.8);
+      var(--tool-motion-fade-ms, 240ms), calc(var(--tool-motion-fade-ms, 240ms) * 0.8),
+      calc(var(--tool-motion-fade-ms, 240ms) * 0.8);
     transition-timing-function: ease-out, ease-out, ease-out;
   }
 

+ 21 - 46
packages/ui/src/components/tool-status-title.tsx

@@ -1,8 +1,17 @@
 import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
-import { useReducedMotion } from "../hooks/use-reduced-motion"
-import { animate, type AnimationPlaybackControls, GROW_SPRING } from "./motion"
 import { TextShimmer } from "./text-shimmer"
-import { commonPrefix } from "./text-utils"
+
+function common(active: string, done: string) {
+  const a = Array.from(active)
+  const b = Array.from(done)
+  let i = 0
+  while (i < a.length && i < b.length && a[i] === b[i]) i++
+  return {
+    prefix: a.slice(0, i).join(""),
+    active: a.slice(i).join(""),
+    done: b.slice(i).join(""),
+  }
+}
 
 function contentWidth(el: HTMLSpanElement | undefined) {
   if (!el) return 0
@@ -18,58 +27,25 @@ export function ToolStatusTitle(props: {
   class?: string
   split?: boolean
 }) {
-  const reduce = useReducedMotion()
-  const split = createMemo(() => commonPrefix(props.activeText, props.doneText))
+  const split = createMemo(() => common(props.activeText, props.doneText))
   const suffix = createMemo(
-    () =>
-      (props.split ?? true) && split().prefix.length >= 2 && split().aSuffix.length > 0 && split().bSuffix.length > 0,
+    () => (props.split ?? true) && split().prefix.length >= 2 && split().active.length > 0 && split().done.length > 0,
   )
   const prefixLen = createMemo(() => Array.from(split().prefix).length)
-  const activeTail = createMemo(() => (suffix() ? split().aSuffix : props.activeText))
-  const doneTail = createMemo(() => (suffix() ? split().bSuffix : props.doneText))
+  const activeTail = createMemo(() => (suffix() ? split().active : props.activeText))
+  const doneTail = createMemo(() => (suffix() ? split().done : props.doneText))
 
+  const [width, setWidth] = createSignal("auto")
   const [ready, setReady] = createSignal(false)
   let activeRef: HTMLSpanElement | undefined
   let doneRef: HTMLSpanElement | undefined
-  let swapRef: HTMLSpanElement | undefined
-  let tailRef: HTMLSpanElement | undefined
   let frame: number | undefined
   let readyFrame: number | undefined
-  let widthAnim: AnimationPlaybackControls | undefined
-
-  const node = () => (suffix() ? tailRef : swapRef)
-
-  const setNodeWidth = (width: string) => {
-    if (swapRef) swapRef.style.width = width
-    if (tailRef) tailRef.style.width = width
-  }
 
   const measure = () => {
     const target = props.active ? activeRef : doneRef
-    const next = contentWidth(target)
-    if (next <= 0) return
-
-    const ref = node()
-    if (!ref || !ready() || reduce()) {
-      widthAnim?.stop()
-      setNodeWidth(`${next}px`)
-      return
-    }
-
-    const prev = Math.max(0, Math.ceil(ref.getBoundingClientRect().width))
-    if (Math.abs(next - prev) < 1) {
-      ref.style.width = `${next}px`
-      return
-    }
-
-    ref.style.width = `${prev}px`
-    widthAnim?.stop()
-    widthAnim = animate(ref, { width: `${next}px` }, GROW_SPRING)
-    widthAnim.finished.then(() => {
-      const el = node()
-      if (!el) return
-      el.style.width = `${next}px`
-    })
+    const px = contentWidth(target)
+    if (px > 0) setWidth(`${px}px`)
   }
 
   const schedule = () => {
@@ -114,7 +90,6 @@ export function ToolStatusTitle(props: {
   onCleanup(() => {
     if (frame !== undefined) cancelAnimationFrame(frame)
     if (readyFrame !== undefined) cancelAnimationFrame(readyFrame)
-    widthAnim?.stop()
   })
 
   return (
@@ -129,7 +104,7 @@ export function ToolStatusTitle(props: {
       <Show
         when={suffix()}
         fallback={
-          <span data-slot="tool-status-swap" ref={swapRef}>
+          <span data-slot="tool-status-swap" style={{ width: width() }}>
             <span data-slot="tool-status-active" ref={activeRef}>
               <TextShimmer text={activeTail()} active={props.active} offset={0} />
             </span>
@@ -143,7 +118,7 @@ export function ToolStatusTitle(props: {
           <span data-slot="tool-status-prefix">
             <TextShimmer text={split().prefix} active={props.active} offset={0} />
           </span>
-          <span data-slot="tool-status-tail" ref={tailRef}>
+          <span data-slot="tool-status-tail" style={{ width: width() }}>
             <span data-slot="tool-status-active" ref={activeRef}>
               <TextShimmer text={activeTail()} active={props.active} offset={prefixLen()} />
             </span>

+ 0 - 336
packages/ui/src/components/tool-utils.ts

@@ -1,336 +0,0 @@
-import type { ToolPart } from "@opencode-ai/sdk/v2"
-import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
-import { useReducedMotion } from "../hooks/use-reduced-motion"
-import {
-  animate,
-  type AnimationPlaybackControls,
-  clearFadeStyles,
-  clearMaskStyles,
-  COLLAPSIBLE_SPRING,
-  GROW_SPRING,
-  WIPE_MASK,
-} from "./motion"
-
-export const TEXT_RENDER_THROTTLE_MS = 100
-
-export function createThrottledValue(getValue: () => string) {
-  const [value, setValue] = createSignal(getValue())
-  let timeout: ReturnType<typeof setTimeout> | undefined
-  let last = 0
-
-  createEffect(() => {
-    const next = getValue()
-    const now = Date.now()
-
-    const remaining = TEXT_RENDER_THROTTLE_MS - (now - last)
-    if (remaining <= 0) {
-      if (timeout) {
-        clearTimeout(timeout)
-        timeout = undefined
-      }
-      last = now
-      setValue(next)
-      return
-    }
-    if (timeout) clearTimeout(timeout)
-    timeout = setTimeout(() => {
-      last = Date.now()
-      setValue(next)
-      timeout = undefined
-    }, remaining)
-  })
-
-  onCleanup(() => {
-    if (timeout) clearTimeout(timeout)
-  })
-
-  return value
-}
-
-export function busy(status: string | undefined) {
-  return status === "pending" || status === "running"
-}
-
-export function hold(state: () => boolean, wait = 2000) {
-  const [live, setLive] = createSignal(state())
-  let timer: ReturnType<typeof setTimeout> | undefined
-
-  createEffect(() => {
-    if (state()) {
-      if (timer) clearTimeout(timer)
-      timer = undefined
-      setLive(true)
-      return
-    }
-
-    if (timer) clearTimeout(timer)
-    timer = setTimeout(() => {
-      timer = undefined
-      setLive(false)
-    }, wait)
-  })
-
-  onCleanup(() => {
-    if (timer) clearTimeout(timer)
-  })
-
-  return live
-}
-
-export function updateScrollMask(el: HTMLElement, fade = 12) {
-  const { scrollTop, scrollHeight, clientHeight } = el
-  const overflow = scrollHeight - clientHeight
-  if (overflow <= 1) {
-    el.style.maskImage = ""
-    el.style.webkitMaskImage = ""
-    return
-  }
-  const top = scrollTop > 1
-  const bottom = scrollTop < overflow - 1
-  const mask =
-    top && bottom
-      ? `linear-gradient(to bottom, transparent 0, black ${fade}px, black calc(100% - ${fade}px), transparent 100%)`
-      : top
-        ? `linear-gradient(to bottom, transparent 0, black ${fade}px)`
-        : bottom
-          ? `linear-gradient(to bottom, black calc(100% - ${fade}px), transparent 100%)`
-          : ""
-  el.style.maskImage = mask
-  el.style.webkitMaskImage = mask
-}
-
-export function useCollapsible(options: {
-  content: () => HTMLElement | undefined
-  body: () => HTMLElement | undefined
-  open: () => boolean
-  measure?: () => number
-  onOpen?: () => void
-}) {
-  const reduce = useReducedMotion()
-  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 (reduce()) {
-        body.style.opacity = ""
-        body.style.filter = ""
-        if (isOpen) {
-          content.style.display = ""
-          content.style.height = "auto"
-          options.onOpen?.()
-          return
-        }
-        content.style.height = "0px"
-        content.style.display = "none"
-        return
-      }
-      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.then(
-        () => {
-          if (gen !== id) return
-          content.style.display = "none"
-        },
-        () => {},
-      )
-    }),
-  )
-
-  onCleanup(() => {
-    ++gen
-    heightAnim?.stop()
-    fadeAnim?.stop()
-  })
-}
-
-export function useContextToolPending(parts: () => ToolPart[], working?: () => boolean) {
-  const anyRunning = createMemo(() => parts().some((part) => busy(part.state.status)))
-  const [settled, setSettled] = createSignal(false)
-  createEffect(() => {
-    if (!anyRunning() && !working?.()) setSettled(true)
-  })
-  return createMemo(() => !settled() && (!!working?.() || anyRunning()))
-}
-
-export function useRowWipe(opts: {
-  id: () => string
-  text: () => string | undefined
-  ref: () => HTMLElement | undefined
-  seen: Set<string>
-}) {
-  const reduce = useReducedMotion()
-
-  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)
-        clear()
-      }
-    })
-  })
-}
-
-export function useToolFade(
-  ref: () => HTMLElement | undefined,
-  options?: { delay?: number; wipe?: boolean; animate?: boolean },
-) {
-  let anim: AnimationPlaybackControls | undefined
-  let frame: number | undefined
-  const delay = options?.delay ?? 0
-  const wipe = options?.wipe ?? false
-  const active = options?.animate !== false
-  const reduce = useReducedMotion()
-
-  onMount(() => {
-    if (!active) return
-
-    const el = ref()
-    if (!el || typeof window === "undefined") return
-    if (reduce()) return
-
-    const mask =
-      wipe &&
-      typeof CSS !== "undefined" &&
-      (CSS.supports("mask-image", "linear-gradient(to right, black, transparent)") ||
-        CSS.supports("-webkit-mask-image", "linear-gradient(to right, black, transparent)"))
-
-    el.style.opacity = "0"
-    el.style.filter = wipe ? "blur(3px)" : "blur(2px)"
-    el.style.transform = wipe ? "translateX(-0.06em)" : "translateY(0.04em)"
-
-    if (mask) {
-      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%"
-    }
-
-    frame = requestAnimationFrame(() => {
-      frame = undefined
-      const node = ref()
-      if (!node) return
-
-      anim = wipe
-        ? mask
-          ? animate(
-              node,
-              { opacity: 1, filter: "blur(0px)", transform: "translateX(0)", maskPosition: "0% 0%" },
-              { ...GROW_SPRING, delay },
-            )
-          : animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateX(0)" }, { ...GROW_SPRING, delay })
-        : animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateY(0)" }, { ...GROW_SPRING, delay })
-
-      anim?.finished.then(() => {
-        const value = ref()
-        if (!value) return
-        clearFadeStyles(value)
-        if (mask) clearMaskStyles(value)
-      })
-    })
-  })
-
-  onCleanup(() => {
-    if (frame !== undefined) cancelAnimationFrame(frame)
-    anim?.stop()
-  })
-}

+ 58 - 187
packages/ui/src/hooks/create-auto-scroll.tsx

@@ -1,8 +1,6 @@
 import { createEffect, on, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createResizeObserver } from "@solid-primitives/resize-observer"
-import { animate, type AnimationPlaybackControls } from "motion"
-import { FAST_SPRING } from "../components/motion"
 
 export interface AutoScrollOptions {
   working: () => boolean
@@ -11,28 +9,13 @@ export interface AutoScrollOptions {
   bottomThreshold?: number
 }
 
-const SETTLE_MS = 500
-const AUTO_SCROLL_GRACE_MS = 120
-const AUTO_SCROLL_EPSILON = 0.5
-const MANUAL_ANCHOR_MS = 3000
-const MANUAL_ANCHOR_QUIET_FRAMES = 24
-
 export function createAutoScroll(options: AutoScrollOptions) {
   let scroll: HTMLElement | undefined
   let settling = false
   let settleTimer: ReturnType<typeof setTimeout> | undefined
+  let autoTimer: ReturnType<typeof setTimeout> | undefined
   let cleanup: (() => void) | undefined
-  let programmaticUntil = 0
-  let scrollAnim: AnimationPlaybackControls | undefined
-  let hold:
-    | {
-        el: HTMLElement
-        top: number
-        until: number
-        quiet: number
-        frame: number | undefined
-      }
-    | undefined
+  let auto: { top: number; time: number } | undefined
 
   const threshold = () => options.bottomThreshold ?? 10
 
@@ -44,160 +27,77 @@ export function createAutoScroll(options: AutoScrollOptions) {
   const active = () => options.working() || settling
 
   const distanceFromBottom = (el: HTMLElement) => {
-    // With column-reverse, scrollTop=0 is at the bottom, negative = scrolled up
-    return Math.abs(el.scrollTop)
+    return el.scrollHeight - el.clientHeight - el.scrollTop
   }
 
   const canScroll = (el: HTMLElement) => {
     return el.scrollHeight - el.clientHeight > 1
   }
 
-  const markProgrammatic = () => {
-    programmaticUntil = Date.now() + AUTO_SCROLL_GRACE_MS
-  }
+  // Browsers can dispatch scroll events asynchronously. If new content arrives
+  // between us calling `scrollTo()` and the subsequent `scroll` event firing,
+  // the handler can see a non-zero `distanceFromBottom` and incorrectly assume
+  // the user scrolled.
+  const markAuto = (el: HTMLElement) => {
+    auto = {
+      top: Math.max(0, el.scrollHeight - el.clientHeight),
+      time: Date.now(),
+    }
 
-  const clearHold = () => {
-    const next = hold
-    if (!next) return
-    if (next.frame !== undefined) cancelAnimationFrame(next.frame)
-    hold = undefined
+    if (autoTimer) clearTimeout(autoTimer)
+    autoTimer = setTimeout(() => {
+      auto = undefined
+      autoTimer = undefined
+    }, 1500)
   }
 
-  const tickHold = () => {
-    const next = hold
-    const el = scroll
-    if (!next || !el) return false
-    if (Date.now() > next.until) {
-      clearHold()
-      return false
-    }
-    if (!next.el.isConnected) {
-      clearHold()
-      return false
-    }
+  const isAuto = (el: HTMLElement) => {
+    const a = auto
+    if (!a) return false
 
-    const current = next.el.getBoundingClientRect().top
-    if (!Number.isFinite(current)) {
-      clearHold()
+    if (Date.now() - a.time > 1500) {
+      auto = undefined
       return false
     }
 
-    const delta = current - next.top
-    if (Math.abs(delta) <= AUTO_SCROLL_EPSILON) {
-      next.quiet += 1
-      if (next.quiet > MANUAL_ANCHOR_QUIET_FRAMES) {
-        clearHold()
-        return false
-      }
-      return true
-    }
-
-    next.quiet = 0
-    if (!store.userScrolled) {
-      setStore("userScrolled", true)
-      options.onUserInteracted?.()
-    }
-    el.scrollTop += delta
-    markProgrammatic()
-    return true
-  }
-
-  const scheduleHold = () => {
-    const next = hold
-    if (!next) return
-    if (next.frame !== undefined) return
-
-    next.frame = requestAnimationFrame(() => {
-      const value = hold
-      if (!value) return
-      value.frame = undefined
-      if (!tickHold()) return
-      scheduleHold()
-    })
+    return Math.abs(el.scrollTop - a.top) < 2
   }
 
-  const preserve = (target: HTMLElement) => {
+  const scrollToBottomNow = (behavior: ScrollBehavior) => {
     const el = scroll
     if (!el) return
-
-    if (!store.userScrolled) {
-      setStore("userScrolled", true)
-      options.onUserInteracted?.()
+    markAuto(el)
+    if (behavior === "smooth") {
+      el.scrollTo({ top: el.scrollHeight, behavior })
+      return
     }
 
-    const top = target.getBoundingClientRect().top
-    if (!Number.isFinite(top)) return
-
-    clearHold()
-    hold = {
-      el: target,
-      top,
-      until: Date.now() + MANUAL_ANCHOR_MS,
-      quiet: 0,
-      frame: undefined,
-    }
-    scheduleHold()
+    // `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`.
+    el.scrollTop = el.scrollHeight
   }
 
   const scrollToBottom = (force: boolean) => {
     if (!force && !active()) return
 
-    clearHold()
-
     if (force && store.userScrolled) setStore("userScrolled", false)
 
     const el = scroll
     if (!el) return
 
-    if (scrollAnim) cancelSmooth()
     if (!force && store.userScrolled) return
 
-    // With column-reverse, scrollTop=0 is at the bottom
-    if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) {
-      markProgrammatic()
+    const distance = distanceFromBottom(el)
+    if (distance < 2) {
+      markAuto(el)
       return
     }
 
-    el.scrollTop = 0
-    markProgrammatic()
-  }
-
-  const cancelSmooth = () => {
-    if (scrollAnim) {
-      scrollAnim.stop()
-      scrollAnim = undefined
-    }
+    // For auto-following content we prefer immediate updates to avoid
+    // visible "catch up" animations while content is still settling.
+    scrollToBottomNow("auto")
   }
 
-  const smoothScrollToBottom = () => {
-    const el = scroll
-    if (!el) return
-
-    cancelSmooth()
-    if (store.userScrolled) setStore("userScrolled", false)
-
-    // With column-reverse, scrollTop=0 is at the bottom
-    if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) {
-      markProgrammatic()
-      return
-    }
-
-    scrollAnim = animate(el.scrollTop, 0, {
-      ...FAST_SPRING,
-      onUpdate: (v) => {
-        markProgrammatic()
-        el.scrollTop = v
-      },
-      onComplete: () => {
-        scrollAnim = undefined
-        markProgrammatic()
-      },
-    })
-  }
-
-  const stop = (input?: { hold?: boolean }) => {
-    if (input?.hold !== false) clearHold()
-
+  const stop = () => {
     const el = scroll
     if (!el) return
     if (!canScroll(el)) {
@@ -206,25 +106,15 @@ export function createAutoScroll(options: AutoScrollOptions) {
     }
     if (store.userScrolled) return
 
-    markProgrammatic()
     setStore("userScrolled", true)
     options.onUserInteracted?.()
   }
 
   const handleWheel = (e: WheelEvent) => {
-    if (e.deltaY !== 0) clearHold()
-
-    if (e.deltaY > 0) {
-      const el = scroll
-      if (!el) return
-      if (distanceFromBottom(el) >= threshold()) return
-      if (store.userScrolled) setStore("userScrolled", false)
-      markProgrammatic()
-      return
-    }
-
     if (e.deltaY >= 0) return
-    cancelSmooth()
+    // If the user is scrolling within a nested scrollable region (tool output,
+    // code block, etc), don't treat it as leaving the "follow bottom" mode.
+    // Those regions opt in via `data-scrollable`.
     const el = scroll
     const target = e.target instanceof Element ? e.target : undefined
     const nested = target?.closest("[data-scrollable]")
@@ -236,27 +126,23 @@ export function createAutoScroll(options: AutoScrollOptions) {
     const el = scroll
     if (!el) return
 
-    if (hold) {
-      if (Date.now() < programmaticUntil) return
-      clearHold()
-    }
-
     if (!canScroll(el)) {
       if (store.userScrolled) setStore("userScrolled", false)
-      markProgrammatic()
       return
     }
 
     if (distanceFromBottom(el) < threshold()) {
-      if (Date.now() < programmaticUntil) return
       if (store.userScrolled) setStore("userScrolled", false)
-      markProgrammatic()
       return
     }
 
-    if (!store.userScrolled && Date.now() < programmaticUntil) return
+    // Ignore scroll events triggered by our own scrollToBottom calls.
+    if (!store.userScrolled && isAuto(el)) {
+      scrollToBottom(false)
+      return
+    }
 
-    stop({ hold: false })
+    stop()
   }
 
   const handleInteraction = () => {
@@ -268,11 +154,6 @@ export function createAutoScroll(options: AutoScrollOptions) {
   }
 
   const updateOverflowAnchor = (el: HTMLElement) => {
-    if (hold) {
-      el.style.overflowAnchor = "none"
-      return
-    }
-
     const mode = options.overflowAnchor ?? "dynamic"
 
     if (mode === "none") {
@@ -292,17 +173,15 @@ export function createAutoScroll(options: AutoScrollOptions) {
     () => store.contentRef,
     () => {
       const el = scroll
-      if (hold) {
-        scheduleHold()
-        return
-      }
       if (el && !canScroll(el)) {
         if (store.userScrolled) setStore("userScrolled", false)
-        markProgrammatic()
         return
       }
       if (!active()) return
       if (store.userScrolled) return
+      // ResizeObserver fires after layout, before paint.
+      // Keep the bottom locked in the same frame to avoid visible
+      // "jump up then catch up" artifacts while streaming content.
       scrollToBottom(false)
     },
   )
@@ -321,11 +200,13 @@ export function createAutoScroll(options: AutoScrollOptions) {
       settling = true
       settleTimer = setTimeout(() => {
         settling = false
-      }, SETTLE_MS)
+      }, 300)
     }),
   )
 
   createEffect(() => {
+    // Track `userScrolled` even before `scrollRef` is attached, so we can
+    // update overflow anchoring once the element exists.
     store.userScrolled
     const el = scroll
     if (!el) return
@@ -334,8 +215,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
 
   onCleanup(() => {
     if (settleTimer) clearTimeout(settleTimer)
-    clearHold()
-    cancelSmooth()
+    if (autoTimer) clearTimeout(autoTimer)
     if (cleanup) cleanup()
   })
 
@@ -348,12 +228,8 @@ export function createAutoScroll(options: AutoScrollOptions) {
 
       scroll = el
 
-      if (!el) {
-        clearHold()
-        return
-      }
+      if (!el) return
 
-      markProgrammatic()
       updateOverflowAnchor(el)
       el.addEventListener("wheel", handleWheel, { passive: true })
 
@@ -364,18 +240,13 @@ export function createAutoScroll(options: AutoScrollOptions) {
     contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
     handleScroll,
     handleInteraction,
-    preserve,
     pause: stop,
-    forceScrollToBottom: () => scrollToBottom(true),
-    smoothScrollToBottom,
-    snapToBottom: () => {
-      const el = scroll
-      if (!el) return
+    resume: () => {
       if (store.userScrolled) setStore("userScrolled", false)
-      // With column-reverse, scrollTop=0 is at the bottom
-      el.scrollTop = 0
-      markProgrammatic()
+      scrollToBottom(true)
     },
+    scrollToBottom: () => scrollToBottom(false),
+    forceScrollToBottom: () => scrollToBottom(true),
     userScrolled: () => store.userScrolled,
   }
 }

+ 0 - 1
packages/ui/src/hooks/index.ts

@@ -1,3 +1,2 @@
 export * from "./use-filtered-list"
 export * from "./create-auto-scroll"
-export * from "./use-reduced-motion"

+ 0 - 10
packages/ui/src/hooks/use-reduced-motion.ts

@@ -1,10 +0,0 @@
-import { isHydrated } from "@solid-primitives/lifecycle"
-import { createMediaQuery } from "@solid-primitives/media"
-import { createHydratableSingletonRoot } from "@solid-primitives/rootless"
-
-const query = "(prefers-reduced-motion: reduce)"
-
-export const useReducedMotion = createHydratableSingletonRoot(() => {
-  const value = createMediaQuery(query)
-  return () => !isHydrated() || value()
-})

+ 0 - 1
packages/ui/src/styles/index.css

@@ -40,7 +40,6 @@
 @import "../components/progress-circle.css" layer(components);
 @import "../components/radio-group.css" layer(components);
 @import "../components/resize-handle.css" layer(components);
-@import "../components/rolling-results.css" layer(components);
 @import "../components/select.css" layer(components);
 @import "../components/spinner.css" layer(components);
 @import "../components/switch.css" layer(components);

+ 0 - 7
packages/util/src/array.ts

@@ -1,10 +1,3 @@
-export function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
-  if (a === b) return true
-  if (!a || !b) return false
-  if (a.length !== b.length) return false
-  return a.every((x, i) => x === b[i])
-}
-
 export function findLast<T>(
   items: readonly T[],
   predicate: (item: T, index: number, items: readonly T[]) => boolean,

Vissa filer visades inte eftersom för många filer har ändrats