Jelajahi Sumber

feat(ui): tool call abstraction, turn diff summary, and composer improvements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Kit Langton 5 bulan lalu
induk
melakukan
4795806b13
100 mengubah file dengan 10805 tambahan dan 3103 penghapusan
  1. 2 1
      bun.lock
  2. 6 9
      packages/app/e2e/actions.ts
  3. 2 0
      packages/app/e2e/selectors.ts
  4. 2 2
      packages/app/e2e/session/session.spec.ts
  5. 29 51
      packages/app/src/components/prompt-input.tsx
  6. 15 16
      packages/app/src/components/prompt-input/submit.ts
  7. 1 2
      packages/app/src/components/session/session-context-tab.tsx
  8. 0 23
      packages/app/src/components/settings-general.tsx
  9. 1 8
      packages/app/src/context/command.tsx
  10. 0 1
      packages/app/src/context/global-sync.tsx
  11. 0 2
      packages/app/src/context/language.tsx
  12. 1 1
      packages/app/src/context/layout.tsx
  13. 1 40
      packages/app/src/context/permission-auto-respond.test.ts
  14. 1 11
      packages/app/src/context/permission-auto-respond.ts
  15. 2 73
      packages/app/src/context/permission.tsx
  16. 0 18
      packages/app/src/context/settings.tsx
  17. 0 6
      packages/app/src/i18n/ar.ts
  18. 0 6
      packages/app/src/i18n/br.ts
  19. 0 6
      packages/app/src/i18n/bs.ts
  20. 0 5
      packages/app/src/i18n/da.ts
  21. 0 6
      packages/app/src/i18n/de.ts
  22. 0 7
      packages/app/src/i18n/en.ts
  23. 0 6
      packages/app/src/i18n/es.ts
  24. 0 6
      packages/app/src/i18n/fr.ts
  25. 0 6
      packages/app/src/i18n/ja.ts
  26. 0 6
      packages/app/src/i18n/ko.ts
  27. 0 5
      packages/app/src/i18n/no.ts
  28. 0 6
      packages/app/src/i18n/pl.ts
  29. 0 6
      packages/app/src/i18n/ru.ts
  30. 0 5
      packages/app/src/i18n/th.ts
  31. 0 7
      packages/app/src/i18n/tr.ts
  32. 0 4
      packages/app/src/i18n/zh.ts
  33. 0 4
      packages/app/src/i18n/zht.ts
  34. 47 30
      packages/app/src/pages/layout.tsx
  35. 121 144
      packages/app/src/pages/session.tsx
  36. 26 0
      packages/app/src/pages/session/composer/index.ts
  37. 16 103
      packages/app/src/pages/session/composer/session-composer-region.tsx
  38. 5 1
      packages/app/src/pages/session/composer/session-composer-state.ts
  39. 29 63
      packages/app/src/pages/session/composer/session-todo-dock.tsx
  40. 2 2
      packages/app/src/pages/session/message-gesture.test.ts
  41. 4 4
      packages/app/src/pages/session/message-gesture.ts
  42. 132 373
      packages/app/src/pages/session/message-timeline.tsx
  43. 3 1
      packages/app/src/pages/session/session-side-panel.tsx
  44. 522 0
      packages/app/src/pages/session/session-timeline-header.tsx
  45. 8 8
      packages/app/src/pages/session/terminal-panel.tsx
  46. 9 20
      packages/app/src/pages/session/use-session-commands.tsx
  47. 26 26
      packages/app/src/pages/session/use-session-hash-scroll.ts
  48. 9 0
      packages/app/src/utils/dom.ts
  49. 0 6
      packages/app/src/utils/same.ts
  50. 2 2
      packages/storybook/.storybook/mocks/app/context/command.ts
  51. 3 0
      packages/ui/happydom.ts
  52. 4 0
      packages/ui/package.json
  53. 6 5
      packages/ui/src/components/animated-number.css
  54. 27 2
      packages/ui/src/components/animated-number.tsx
  55. 122 0
      packages/ui/src/components/animation-debug-panel.tsx
  56. 13 46
      packages/ui/src/components/basic-tool.css
  57. 0 133
      packages/ui/src/components/basic-tool.stories.tsx
  58. 259 97
      packages/ui/src/components/basic-tool.tsx
  59. 23 32
      packages/ui/src/components/collapsible.css
  60. 311 0
      packages/ui/src/components/composer-island.stories.tsx
  61. 28 0
      packages/ui/src/components/composer-island.tsx
  62. 290 0
      packages/ui/src/components/context-tool-results.tsx
  63. 426 0
      packages/ui/src/components/grow-box.tsx
  64. 2 15
      packages/ui/src/components/markdown.tsx
  65. 316 84
      packages/ui/src/components/message-part.css
  66. 402 619
      packages/ui/src/components/message-part.tsx
  67. 25 7
      packages/ui/src/components/motion-spring.tsx
  68. 77 0
      packages/ui/src/components/motion.tsx
  69. 451 0
      packages/ui/src/components/new-composer.stories.tsx
  70. 1097 0
      packages/ui/src/components/new-composer.tsx
  71. 64 0
      packages/ui/src/components/new-composer/defaults.ts
  72. 23 0
      packages/ui/src/components/new-composer/drag-overlay.tsx
  73. 100 0
      packages/ui/src/components/new-composer/editor-utils.test.ts
  74. 206 0
      packages/ui/src/components/new-composer/editor-utils.ts
  75. 128 0
      packages/ui/src/components/new-composer/history.test.ts
  76. 189 0
      packages/ui/src/components/new-composer/history.ts
  77. 223 0
      packages/ui/src/components/new-composer/input-layer.tsx
  78. 242 0
      packages/ui/src/components/new-composer/model-picker.tsx
  79. 41 0
      packages/ui/src/components/new-composer/permission-body.tsx
  80. 111 0
      packages/ui/src/components/new-composer/popover.tsx
  81. 218 0
      packages/ui/src/components/new-composer/question-body.tsx
  82. 302 0
      packages/ui/src/components/new-composer/todo-tray.tsx
  83. 410 0
      packages/ui/src/components/new-composer/tray.tsx
  84. 157 0
      packages/ui/src/components/new-composer/types.ts
  85. 722 0
      packages/ui/src/components/new-composer/use-editor.ts
  86. 87 0
      packages/ui/src/components/new-composer/use-layout.ts
  87. 76 0
      packages/ui/src/components/new-composer/use-todo.ts
  88. 92 0
      packages/ui/src/components/rolling-results.css
  89. 316 0
      packages/ui/src/components/rolling-results.tsx
  90. 3 12
      packages/ui/src/components/scroll-view.css
  91. 52 14
      packages/ui/src/components/scroll-view.tsx
  92. 1 1
      packages/ui/src/components/session-review.tsx
  93. 1392 0
      packages/ui/src/components/session-timeline-simulator.stories.tsx
  94. 116 11
      packages/ui/src/components/session-turn.css
  95. 348 206
      packages/ui/src/components/session-turn.tsx
  96. 249 0
      packages/ui/src/components/shell-rolling-results.tsx
  97. 0 329
      packages/ui/src/components/shell-submessage-motion.stories.tsx
  98. 1 11
      packages/ui/src/components/shell-submessage.css
  99. 30 31
      packages/ui/src/components/text-reveal.css
  100. 0 310
      packages/ui/src/components/text-reveal.stories.tsx

+ 2 - 1
bun.lock

@@ -506,6 +506,7 @@
         "virtua": "catalog:",
       },
       "devDependencies": {
+        "@happy-dom/global-registrator": "20.0.11",
         "@tailwindcss/vite": "catalog:",
         "@tsconfig/node22": "catalog:",
         "@types/bun": "catalog:",
@@ -2962,7 +2963,7 @@
 
     "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
 
-    "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="],
+    "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d"],
 
     "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
 

+ 6 - 9
packages/app/e2e/actions.ts

@@ -8,6 +8,7 @@ import {
   sessionItemSelector,
   dropdownMenuTriggerSelector,
   dropdownMenuContentSelector,
+  sessionHeaderSelector,
   projectMenuTriggerSelector,
   projectWorkspacesToggleSelector,
   titlebarRightSelector,
@@ -197,7 +198,6 @@ export async function createTestProject() {
   await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
 
   execSync("git init", { cwd: root, stdio: "ignore" })
-  execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
   execSync("git add -A", { cwd: root, stdio: "ignore" })
   execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
     cwd: root,
@@ -208,10 +208,7 @@ export async function createTestProject() {
 }
 
 export async function cleanupTestProject(directory: string) {
-  try {
-    execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" })
-  } catch {}
-  await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
+  await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
 }
 
 export function sessionIDFromUrl(url: string) {
@@ -229,9 +226,9 @@ export async function hoverSessionItem(page: Page, sessionID: string) {
 export async function openSessionMoreMenu(page: Page, sessionID: string) {
   await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
 
-  const scroller = page.locator(".scroll-view__viewport").first()
-  await expect(scroller).toBeVisible()
-  await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
+  const header = page.locator(sessionHeaderSelector).first()
+  await expect(header).toBeVisible()
+  await expect(header.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
 
   const menu = page
     .locator(dropdownMenuContentSelector)
@@ -247,7 +244,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
 
   if (opened) return menu
 
-  const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
+  const menuTrigger = header.getByRole("button", { name: /more options/i }).first()
   await expect(menuTrigger).toBeVisible()
   await menuTrigger.click()
 

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

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

+ 2 - 2
packages/app/e2e/session/session.spec.ts

@@ -7,7 +7,7 @@ import {
   openSharePopover,
   withSession,
 } from "../actions"
-import { sessionItemSelector, inlineInputSelector } from "../selectors"
+import { sessionHeaderSelector, sessionItemSelector, inlineInputSelector } from "../selectors"
 
 const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
 
@@ -44,7 +44,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
     const menu = await openSessionMoreMenu(page, session.id)
     await clickMenuItem(menu, /rename/i)
 
-    const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
+    const input = page.locator(sessionHeaderSelector).locator(inlineInputSelector).first()
     await expect(input).toBeVisible()
     await expect(input).toBeFocused()
     await input.fill(renamedTitle)

+ 29 - 51
packages/app/src/components/prompt-input.tsx

@@ -244,6 +244,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     draggingType: "image" | "@mention" | null
     mode: "normal" | "shell"
     applyingHistory: boolean
+    pendingAutoAccept: boolean
   }>({
     popover: null,
     historyIndex: -1,
@@ -252,9 +253,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     draggingType: null,
     mode: "normal",
     applyingHistory: false,
+    pendingAutoAccept: false,
   })
 
-  const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
+  const buttonsSpring = useSpring(
+    () => (store.mode === "normal" ? 1 : 0),
+    { visualDuration: 0.2, bounce: 0 },
+  )
+
+  const springFade = (t: number): Record<string, string> => ({
+    opacity: `${t}`,
+    transform: `scale(${0.95 + t * 0.05})`,
+    filter: `blur(${(1 - t) * 2}px)`,
+    "pointer-events": t > 0.5 ? "auto" : "none",
+  })
 
   const commentCount = createMemo(() => {
     if (store.mode === "shell") return 0
@@ -304,6 +316,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }),
   )
 
+  createEffect(
+    on(sessionKey, () => {
+      setStore("pendingAutoAccept", false)
+    }),
+  )
+
   const historyComments = () => {
     const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
     return prompt.context.items().flatMap((item) => {
@@ -953,7 +971,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const variants = createMemo(() => ["default", ...local.model.variant.list()])
   const accepting = createMemo(() => {
     const id = params.id
-    if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
+    if (!id) return store.pendingAutoAccept
     return permission.isAutoAccepting(id, sdk.directory)
   })
 
@@ -1246,9 +1264,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
             <div
               aria-hidden={store.mode !== "normal"}
               class="flex items-center gap-1"
-              style={{
-                "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
-              }}
+              style={{ "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none" }}
             >
               <TooltipKeybind
                 placement="top"
@@ -1260,11 +1276,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                   type="button"
                   variant="ghost"
                   class="size-8 p-0"
-                  style={{
-                    opacity: buttonsSpring(),
-                    transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
-                    filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
-                  }}
+                  style={springFade(buttonsSpring())}
                   onClick={pick}
                   disabled={store.mode !== "normal"}
                   tabIndex={store.mode === "normal" ? undefined : -1}
@@ -1302,11 +1314,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                   icon={working() ? "stop" : "arrow-up"}
                   variant="primary"
                   class="size-8"
-                  style={{
-                    opacity: buttonsSpring(),
-                    transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
-                    filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
-                  }}
+                  style={springFade(buttonsSpring())}
                   aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
                 />
               </Tooltip>
@@ -1328,7 +1336,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                   variant="ghost"
                   onClick={() => {
                     if (!params.id) {
-                      permission.toggleAutoAcceptDirectory(sdk.directory)
+                      setStore("pendingAutoAccept", (value) => !value)
                       return
                     }
                     permission.toggleAutoAccept(params.id, sdk.directory)
@@ -1362,13 +1370,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
             <div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
               <div
                 class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
-                style={{
-                  padding: "0 4px 0 8px",
-                  opacity: 1 - buttonsSpring(),
-                  transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`,
-                  filter: `blur(${buttonsSpring() * 2}px)`,
-                  "pointer-events": buttonsSpring() < 0.5 ? "auto" : "none",
-                }}
+                style={{ padding: "0 4px 0 8px", ...springFade(1 - buttonsSpring()) }}
               >
                 <span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
                 <div class="size-4 shrink-0" />
@@ -1387,13 +1389,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                     onSelect={local.agent.set}
                     class="capitalize max-w-[160px]"
                     valueClass="truncate text-13-regular"
-                    triggerStyle={{
-                      height: "28px",
-                      opacity: buttonsSpring(),
-                      transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
-                      filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
-                      "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
-                    }}
+                    triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }}
                     variant="ghost"
                   />
                 </TooltipKeybind>
@@ -1411,13 +1407,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                         variant="ghost"
                         size="normal"
                         class="min-w-0 max-w-[320px] text-13-regular group"
-                        style={{
-                          height: "28px",
-                          opacity: buttonsSpring(),
-                          transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
-                          filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
-                          "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
-                        }}
+                        style={{ height: "28px", ...springFade(buttonsSpring()) }}
                         onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
                       >
                         <Show when={local.model.current()?.provider?.id}>
@@ -1446,13 +1436,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                       triggerProps={{
                         variant: "ghost",
                         size: "normal",
-                        style: {
-                          height: "28px",
-                          opacity: buttonsSpring(),
-                          transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
-                          filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
-                          "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
-                        },
+                        style: { height: "28px", ...springFade(buttonsSpring()) },
                         class: "min-w-0 max-w-[320px] text-13-regular group",
                       }}
                     >
@@ -1484,13 +1468,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                     onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
                     class="capitalize max-w-[160px]"
                     valueClass="truncate text-13-regular"
-                    triggerStyle={{
-                      height: "28px",
-                      opacity: buttonsSpring(),
-                      transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
-                      filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
-                      "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
-                    }}
+                    triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }}
                     variant="ghost"
                   />
                 </TooltipKeybind>

+ 15 - 16
packages/app/src/components/prompt-input/submit.ts

@@ -1,8 +1,9 @@
 import type { Message } from "@opencode-ai/sdk/v2/client"
 import { showToast } from "@opencode-ai/ui/toast"
 import { base64Encode } from "@opencode-ai/util/encode"
+import { errorMessage } from "@/pages/layout/helpers"
 import { useNavigate, useParams } from "@solidjs/router"
-import type { Accessor } from "solid-js"
+import { batch, type Accessor } from "solid-js"
 import type { FileSelection } from "@/context/file"
 import { useGlobalSync } from "@/context/global-sync"
 import { useLanguage } from "@/context/language"
@@ -65,14 +66,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
   const language = useLanguage()
   const params = useParams()
 
-  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")
-  }
+  const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed"))
 
   const abort = async () => {
     const sessionID = params.id
@@ -158,7 +152,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
           .catch((err) => {
             showToast({
               title: language.t("prompt.toast.worktreeCreateFailed.title"),
-              description: errorMessage(err),
+              description: toastError(err),
             })
             return undefined
           })
@@ -197,7 +191,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
         .catch((err) => {
           showToast({
             title: language.t("prompt.toast.sessionCreateFailed.title"),
-            description: errorMessage(err),
+            description: toastError(err),
           })
           return undefined
         })
@@ -255,7 +249,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
         .catch((err) => {
           showToast({
             title: language.t("prompt.toast.shellSendFailed.title"),
-            description: errorMessage(err),
+            description: toastError(err),
           })
           restoreInput()
         })
@@ -333,9 +327,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
         messageID,
       })
 
-    removeCommentItems(commentItems)
-    clearInput()
-    addOptimisticMessage()
+    batch(() => {
+      removeCommentItems(commentItems)
+      clearInput()
+      if (sessionDirectory === projectDirectory) {
+        sync.set("session_status", session.id, { type: "busy" })
+      }
+      addOptimisticMessage()
+    })
 
     const waitForWorktree = async () => {
       const worktree = WorktreeState.get(sessionDirectory)
@@ -412,7 +411,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       }
       showToast({
         title: language.t("prompt.toast.promptSendFailed.title"),
-        description: errorMessage(err),
+        description: toastError(err),
       })
       removeOptimisticMessage()
       restoreCommentItems(commentItems)

+ 1 - 2
packages/app/src/components/session/session-context-tab.tsx

@@ -4,8 +4,7 @@ import { useParams } from "@solidjs/router"
 import { useSync } from "@/context/sync"
 import { useLayout } from "@/context/layout"
 import { checksum } from "@opencode-ai/util/encode"
-import { findLast } from "@opencode-ai/util/array"
-import { same } from "@/utils/same"
+import { findLast, same } from "@opencode-ai/util/array"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Accordion } from "@opencode-ai/ui/accordion"
 import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"

+ 0 - 23
packages/app/src/components/settings-general.tsx

@@ -288,29 +288,6 @@ export const SettingsGeneral: Component = () => {
           </div>
         </SettingsRow>
 
-        <SettingsRow
-          title={language.t("settings.general.row.shellToolPartsExpanded.title")}
-          description={language.t("settings.general.row.shellToolPartsExpanded.description")}
-        >
-          <div data-action="settings-feed-shell-tool-parts-expanded">
-            <Switch
-              checked={settings.general.shellToolPartsExpanded()}
-              onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
-            />
-          </div>
-        </SettingsRow>
-
-        <SettingsRow
-          title={language.t("settings.general.row.editToolPartsExpanded.title")}
-          description={language.t("settings.general.row.editToolPartsExpanded.description")}
-        >
-          <div data-action="settings-feed-edit-tool-parts-expanded">
-            <Switch
-              checked={settings.general.editToolPartsExpanded()}
-              onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
-            />
-          </div>
-        </SettingsRow>
       </div>
     </div>
   )

+ 1 - 8
packages/app/src/context/command.tsx

@@ -4,6 +4,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useLanguage } from "@/context/language"
 import { useSettings } from "@/context/settings"
+import { isEditableTarget } from "@/utils/dom"
 import { Persist, persisted } from "@/utils/persist"
 
 const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
@@ -177,14 +178,6 @@ export function formatKeybind(config: string): string {
   return IS_MAC ? parts.join("") : parts.join("+")
 }
 
-function isEditableTarget(target: EventTarget | null) {
-  if (!(target instanceof HTMLElement)) return false
-  if (target.isContentEditable) return true
-  if (target.closest("[contenteditable='true']")) return true
-  if (target.closest("input, textarea, select")) return true
-  return false
-}
-
 export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
   name: "Command",
   init: () => {

+ 0 - 1
packages/app/src/context/global-sync.tsx

@@ -353,7 +353,6 @@ function createGlobalSync() {
       .update({ config })
       .then(bootstrap)
       .then(() => {
-        queue.refresh()
         setGlobalStore("reload", undefined)
         queue.refresh()
       })

+ 0 - 2
packages/app/src/context/language.tsx

@@ -146,7 +146,6 @@ const DICT: Record<Locale, Dictionary> = {
 }
 
 const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
-  { locale: "en", match: (language) => language.startsWith("en") },
   { locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
   { locale: "zh", match: (language) => language.startsWith("zh") },
   { locale: "ko", match: (language) => language.startsWith("ko") },
@@ -218,7 +217,6 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
     )
 
     const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
-    console.log("locale", locale())
     const intl = createMemo(() => INTL[locale()])
 
     const dict = createMemo<Dictionary>(() => DICT[locale()])

+ 1 - 1
packages/app/src/context/layout.tsx

@@ -8,7 +8,7 @@ import { usePlatform } from "./platform"
 import { Project } from "@opencode-ai/sdk/v2"
 import { Persist, persisted, removePersisted } from "@/utils/persist"
 import { decode64 } from "@/utils/base64"
-import { same } from "@/utils/same"
+import { same } from "@opencode-ai/util/array"
 import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
 import { createPathHelpers } from "./file/path"
 

+ 1 - 40
packages/app/src/context/permission-auto-respond.test.ts

@@ -1,7 +1,7 @@
 import { describe, expect, test } from "bun:test"
 import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
 import { base64Encode } from "@opencode-ai/util/encode"
-import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
+import { autoRespondsPermission } from "./permission-auto-respond"
 
 const session = (input: { id: string; parentID?: string }) =>
   ({
@@ -60,43 +60,4 @@ describe("autoRespondsPermission", () => {
 
     expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
   })
-
-  test("falls back to directory-level auto-accept", () => {
-    const directory = "/tmp/project"
-    const sessions = [session({ id: "root" })]
-    const autoAccept = {
-      [`${base64Encode(directory)}/*`]: true,
-    }
-
-    expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
-  })
-
-  test("session-level override takes precedence over directory-level", () => {
-    const directory = "/tmp/project"
-    const sessions = [session({ id: "root" })]
-    const autoAccept = {
-      [`${base64Encode(directory)}/*`]: true,
-      [`${base64Encode(directory)}/root`]: false,
-    }
-
-    expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
-  })
-})
-
-describe("isDirectoryAutoAccepting", () => {
-  test("returns true when directory key is set", () => {
-    const directory = "/tmp/project"
-    const autoAccept = { [`${base64Encode(directory)}/*`]: true }
-    expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(true)
-  })
-
-  test("returns false when directory key is not set", () => {
-    expect(isDirectoryAutoAccepting({}, "/tmp/project")).toBe(false)
-  })
-
-  test("returns false when directory key is explicitly false", () => {
-    const directory = "/tmp/project"
-    const autoAccept = { [`${base64Encode(directory)}/*`]: false }
-    expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(false)
-  })
 })

+ 1 - 11
packages/app/src/context/permission-auto-respond.ts

@@ -5,19 +5,9 @@ export function acceptKey(sessionID: string, directory?: string) {
   return `${base64Encode(directory)}/${sessionID}`
 }
 
-export function directoryAcceptKey(directory: string) {
-  return `${base64Encode(directory)}/*`
-}
-
 function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
   const key = acceptKey(sessionID, directory)
-  const directoryKey = directory ? directoryAcceptKey(directory) : undefined
-  return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
-}
-
-export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
-  const key = directoryAcceptKey(directory)
-  return autoAccept[key] ?? false
+  return autoAccept[key] ?? autoAccept[sessionID]
 }
 
 function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) {

+ 2 - 73
packages/app/src/context/permission.tsx

@@ -1,4 +1,4 @@
-import { createEffect, createMemo, onCleanup } from "solid-js"
+import { createMemo, onCleanup } from "solid-js"
 import { createStore, produce } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
@@ -7,12 +7,7 @@ import { useGlobalSDK } from "@/context/global-sdk"
 import { useGlobalSync } from "./global-sync"
 import { useParams } from "@solidjs/router"
 import { decode64 } from "@/utils/base64"
-import {
-  acceptKey,
-  directoryAcceptKey,
-  isDirectoryAutoAccepting,
-  autoRespondsPermission,
-} from "./permission-auto-respond"
+import { acceptKey, autoRespondsPermission } from "./permission-auto-respond"
 
 type PermissionRespondFn = (input: {
   sessionID: string
@@ -81,25 +76,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
       }),
     )
 
-    // When config has permission: "allow", auto-enable directory-level auto-accept
-    createEffect(() => {
-      if (!ready()) return
-      const directory = decode64(params.dir)
-      if (!directory) return
-      const [childStore] = globalSync.child(directory)
-      const perm = childStore.config.permission
-      if (typeof perm === "string" && perm === "allow") {
-        const key = directoryAcceptKey(directory)
-        if (store.autoAccept[key] === undefined) {
-          setStore(
-            produce((draft) => {
-              draft.autoAccept[key] = true
-            }),
-          )
-        }
-      }
-    })
-
     const MAX_RESPONDED = 1000
     const RESPONDED_TTL_MS = 60 * 60 * 1000
     const responded = new Map<string, number>()
@@ -143,10 +119,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
       return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
     }
 
-    function isAutoAcceptingDirectory(directory: string) {
-      return isDirectoryAutoAccepting(store.autoAccept, directory)
-    }
-
     function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
       const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : []
       return autoRespondsPermission(store.autoAccept, session, permission, directory)
@@ -170,36 +142,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
     })
     onCleanup(unsubscribe)
 
-    function enableDirectory(directory: string) {
-      const key = directoryAcceptKey(directory)
-      setStore(
-        produce((draft) => {
-          draft.autoAccept[key] = true
-        }),
-      )
-
-      globalSDK.client.permission
-        .list({ directory })
-        .then((x) => {
-          if (!isAutoAcceptingDirectory(directory)) return
-          for (const perm of x.data ?? []) {
-            if (!perm?.id) continue
-            if (!shouldAutoRespond(perm, directory)) continue
-            respondOnce(perm, directory)
-          }
-        })
-        .catch(() => undefined)
-    }
-
-    function disableDirectory(directory: string) {
-      const key = directoryAcceptKey(directory)
-      setStore(
-        produce((draft) => {
-          draft.autoAccept[key] = false
-        }),
-      )
-    }
-
     function enable(sessionID: string, directory: string) {
       const key = acceptKey(sessionID, directory)
       const version = bumpEnableVersion(sessionID, directory)
@@ -243,7 +185,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
         return shouldAutoRespond(permission, directory)
       },
       isAutoAccepting,
-      isAutoAcceptingDirectory,
       toggleAutoAccept(sessionID: string, directory: string) {
         if (isAutoAccepting(sessionID, directory)) {
           disable(sessionID, directory)
@@ -252,13 +193,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
 
         enable(sessionID, directory)
       },
-      toggleAutoAcceptDirectory(directory: string) {
-        if (isAutoAcceptingDirectory(directory)) {
-          disableDirectory(directory)
-          return
-        }
-        enableDirectory(directory)
-      },
       enableAutoAccept(sessionID: string, directory: string) {
         if (isAutoAccepting(sessionID, directory)) return
         enable(sessionID, directory)
@@ -267,11 +201,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
         disable(sessionID, directory)
       },
       permissionsEnabled,
-      isPermissionAllowAll(directory: string) {
-        const [childStore] = globalSync.child(directory)
-        const perm = childStore.config.permission
-        return typeof perm === "string" && perm === "allow"
-      },
     }
   },
 })

+ 0 - 18
packages/app/src/context/settings.tsx

@@ -23,8 +23,6 @@ export interface Settings {
     autoSave: boolean
     releaseNotes: boolean
     showReasoningSummaries: boolean
-    shellToolPartsExpanded: boolean
-    editToolPartsExpanded: boolean
   }
   updates: {
     startup: boolean
@@ -46,8 +44,6 @@ const defaultSettings: Settings = {
     autoSave: true,
     releaseNotes: true,
     showReasoningSummaries: false,
-    shellToolPartsExpanded: true,
-    editToolPartsExpanded: false,
   },
   updates: {
     startup: true,
@@ -133,20 +129,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
         setShowReasoningSummaries(value: boolean) {
           setStore("general", "showReasoningSummaries", value)
         },
-        shellToolPartsExpanded: withFallback(
-          () => store.general?.shellToolPartsExpanded,
-          defaultSettings.general.shellToolPartsExpanded,
-        ),
-        setShellToolPartsExpanded(value: boolean) {
-          setStore("general", "shellToolPartsExpanded", value)
-        },
-        editToolPartsExpanded: withFallback(
-          () => store.general?.editToolPartsExpanded,
-          defaultSettings.general.editToolPartsExpanded,
-        ),
-        setEditToolPartsExpanded(value: boolean) {
-          setStore("general", "editToolPartsExpanded", value)
-        },
       },
       updates: {
         startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),

+ 0 - 6
packages/app/src/i18n/ar.ts

@@ -541,12 +541,6 @@ export const dict = {
   "settings.general.row.theme.description": "تخصيص سمة OpenCode.",
   "settings.general.row.font.title": "الخط",
   "settings.general.row.font.description": "تخصيص الخط الأحادي المستخدم في كتل التعليمات البرمجية",
-  "settings.general.row.shellToolPartsExpanded.title": "توسيع أجزاء أداة shell",
-  "settings.general.row.shellToolPartsExpanded.description":
-    "إظهار أجزاء أداة shell موسعة بشكل افتراضي في الشريط الزمني",
-  "settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit",
-  "settings.general.row.editToolPartsExpanded.description":
-    "إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني",
   "settings.general.row.wayland.title": "استخدام Wayland الأصلي",
   "settings.general.row.wayland.description": "تعطيل التراجع إلى X11 على Wayland. يتطلب إعادة التشغيل.",
   "settings.general.row.wayland.tooltip":

+ 0 - 6
packages/app/src/i18n/br.ts

@@ -547,12 +547,6 @@ export const dict = {
   "settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.",
   "settings.general.row.font.title": "Fonte",
   "settings.general.row.font.description": "Personalize a fonte monoespaçada usada em blocos de código",
-  "settings.general.row.shellToolPartsExpanded.title": "Expandir partes da ferramenta shell",
-  "settings.general.row.shellToolPartsExpanded.description":
-    "Mostrar partes da ferramenta shell expandidas por padrão na linha do tempo",
-  "settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição",
-  "settings.general.row.editToolPartsExpanded.description":
-    "Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo",
   "settings.general.row.wayland.title": "Usar Wayland nativo",
   "settings.general.row.wayland.description": "Desabilitar fallback X11 no Wayland. Requer reinicialização.",
   "settings.general.row.wayland.tooltip":

+ 0 - 6
packages/app/src/i18n/bs.ts

@@ -613,12 +613,6 @@ export const dict = {
   "settings.general.row.font.title": "Font",
   "settings.general.row.font.description": "Prilagodi monospace font koji se koristi u blokovima koda",
 
-  "settings.general.row.shellToolPartsExpanded.title": "Proširi dijelove shell alata",
-  "settings.general.row.shellToolPartsExpanded.description":
-    "Prikaži dijelove shell alata podrazumijevano proširene na vremenskoj traci",
-  "settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje",
-  "settings.general.row.editToolPartsExpanded.description":
-    "Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci",
   "settings.general.row.wayland.title": "Koristi nativni Wayland",
   "settings.general.row.wayland.description": "Onemogući X11 fallback na Waylandu. Zahtijeva restart.",
   "settings.general.row.wayland.tooltip":

+ 0 - 5
packages/app/src/i18n/da.ts

@@ -608,11 +608,6 @@ export const dict = {
   "settings.general.row.font.title": "Skrifttype",
   "settings.general.row.font.description": "Tilpas mono-skrifttypen brugt i kodeblokke",
 
-  "settings.general.row.shellToolPartsExpanded.title": "Udvid shell-værktøjsdele",
-  "settings.general.row.shellToolPartsExpanded.description": "Vis shell-værktøjsdele udvidet som standard i tidslinjen",
-  "settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele",
-  "settings.general.row.editToolPartsExpanded.description":
-    "Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen",
   "settings.general.row.wayland.title": "Brug native Wayland",
   "settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Kræver genstart.",
   "settings.general.row.wayland.tooltip":

+ 0 - 6
packages/app/src/i18n/de.ts

@@ -556,12 +556,6 @@ export const dict = {
   "settings.general.row.theme.description": "Das Thema von OpenCode anpassen.",
   "settings.general.row.font.title": "Schriftart",
   "settings.general.row.font.description": "Die in Codeblöcken verwendete Monospace-Schriftart anpassen",
-  "settings.general.row.shellToolPartsExpanded.title": "Shell-Tool-Abschnitte ausklappen",
-  "settings.general.row.shellToolPartsExpanded.description":
-    "Shell-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
-  "settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen",
-  "settings.general.row.editToolPartsExpanded.description":
-    "Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
   "settings.general.row.wayland.title": "Natives Wayland verwenden",
   "settings.general.row.wayland.description": "X11-Fallback unter Wayland deaktivieren. Erfordert Neustart.",
   "settings.general.row.wayland.tooltip":

+ 0 - 7
packages/app/src/i18n/en.ts

@@ -636,13 +636,6 @@ export const dict = {
   "settings.general.row.font.description": "Customise the mono font used in code blocks",
   "settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
   "settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
-  "settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
-  "settings.general.row.shellToolPartsExpanded.description":
-    "Show shell tool parts expanded by default in the timeline",
-  "settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts",
-  "settings.general.row.editToolPartsExpanded.description":
-    "Show edit, write, and patch tool parts expanded by default in the timeline",
-
   "settings.general.row.wayland.title": "Use native Wayland",
   "settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",
   "settings.general.row.wayland.tooltip":

+ 0 - 6
packages/app/src/i18n/es.ts

@@ -616,12 +616,6 @@ export const dict = {
   "settings.general.row.font.title": "Fuente",
   "settings.general.row.font.description": "Personaliza la fuente monoespaciada usada en bloques de código",
 
-  "settings.general.row.shellToolPartsExpanded.title": "Expandir partes de la herramienta shell",
-  "settings.general.row.shellToolPartsExpanded.description":
-    "Mostrar las partes de la herramienta shell expandidas por defecto en la línea de tiempo",
-  "settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición",
-  "settings.general.row.editToolPartsExpanded.description":
-    "Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo",
   "settings.general.row.wayland.title": "Usar Wayland nativo",
   "settings.general.row.wayland.description": "Deshabilitar fallback a X11 en Wayland. Requiere reinicio.",
   "settings.general.row.wayland.tooltip":

+ 0 - 6
packages/app/src/i18n/fr.ts

@@ -553,12 +553,6 @@ export const dict = {
   "settings.general.row.theme.description": "Personnaliser le thème d'OpenCode.",
   "settings.general.row.font.title": "Police",
   "settings.general.row.font.description": "Personnaliser la police mono utilisée dans les blocs de code",
-  "settings.general.row.shellToolPartsExpanded.title": "Développer les parties de l'outil shell",
-  "settings.general.row.shellToolPartsExpanded.description":
-    "Afficher les parties de l'outil shell développées par défaut dans la chronologie",
-  "settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit",
-  "settings.general.row.editToolPartsExpanded.description":
-    "Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie",
   "settings.general.row.wayland.title": "Utiliser Wayland natif",
   "settings.general.row.wayland.description": "Désactiver le repli X11 sur Wayland. Nécessite un redémarrage.",
   "settings.general.row.wayland.tooltip":

+ 0 - 6
packages/app/src/i18n/ja.ts

@@ -545,12 +545,6 @@ export const dict = {
   "settings.general.row.theme.description": "OpenCodeのテーマをカスタマイズします。",
   "settings.general.row.font.title": "フォント",
   "settings.general.row.font.description": "コードブロックで使用する等幅フォントをカスタマイズします",
-  "settings.general.row.shellToolPartsExpanded.title": "shell ツールパーツを展開",
-  "settings.general.row.shellToolPartsExpanded.description":
-    "タイムラインで shell ツールパーツをデフォルトで展開して表示します",
-  "settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開",
-  "settings.general.row.editToolPartsExpanded.description":
-    "タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します",
   "settings.general.row.wayland.title": "ネイティブWaylandを使用",
   "settings.general.row.wayland.description": "WaylandでのX11フォールバックを無効にします。再起動が必要です。",
   "settings.general.row.wayland.tooltip":

+ 0 - 6
packages/app/src/i18n/ko.ts

@@ -546,12 +546,6 @@ export const dict = {
   "settings.general.row.theme.description": "OpenCode 테마 사용자 지정",
   "settings.general.row.font.title": "글꼴",
   "settings.general.row.font.description": "코드 블록에 사용되는 고정폭 글꼴 사용자 지정",
-  "settings.general.row.shellToolPartsExpanded.title": "shell 도구 파트 펼치기",
-  "settings.general.row.shellToolPartsExpanded.description":
-    "타임라인에서 기본적으로 shell 도구 파트를 펼친 상태로 표시합니다",
-  "settings.general.row.editToolPartsExpanded.title": "edit 도구 파트 펼치기",
-  "settings.general.row.editToolPartsExpanded.description":
-    "타임라인에서 기본적으로 edit, write, patch 도구 파트를 펼친 상태로 표시합니다",
   "settings.general.row.wayland.title": "네이티브 Wayland 사용",
   "settings.general.row.wayland.description": "Wayland에서 X11 폴백을 비활성화합니다. 다시 시작해야 합니다.",
   "settings.general.row.wayland.tooltip":

+ 0 - 5
packages/app/src/i18n/no.ts

@@ -616,11 +616,6 @@ export const dict = {
   "settings.general.row.font.title": "Skrift",
   "settings.general.row.font.description": "Tilpass mono-skriften som brukes i kodeblokker",
 
-  "settings.general.row.shellToolPartsExpanded.title": "Utvid shell-verktøydeler",
-  "settings.general.row.shellToolPartsExpanded.description": "Vis shell-verktøydeler utvidet som standard i tidslinjen",
-  "settings.general.row.editToolPartsExpanded.title": "Utvid edit-verktøydeler",
-  "settings.general.row.editToolPartsExpanded.description":
-    "Vis edit-, write- og patch-verktøydeler utvidet som standard i tidslinjen",
   "settings.general.row.wayland.title": "Bruk innebygd Wayland",
   "settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Krever omstart.",
   "settings.general.row.wayland.tooltip":

+ 0 - 6
packages/app/src/i18n/pl.ts

@@ -546,12 +546,6 @@ export const dict = {
   "settings.general.row.theme.description": "Dostosuj motyw OpenCode.",
   "settings.general.row.font.title": "Czcionka",
   "settings.general.row.font.description": "Dostosuj czcionkę mono używaną w blokach kodu",
-  "settings.general.row.shellToolPartsExpanded.title": "Rozwijaj elementy narzędzia shell",
-  "settings.general.row.shellToolPartsExpanded.description":
-    "Domyślnie pokazuj rozwinięte elementy narzędzia shell na osi czasu",
-  "settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit",
-  "settings.general.row.editToolPartsExpanded.description":
-    "Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu",
   "settings.general.row.wayland.title": "Użyj natywnego Wayland",
   "settings.general.row.wayland.description": "Wyłącz fallback X11 na Wayland. Wymaga restartu.",
   "settings.general.row.wayland.tooltip":

+ 0 - 6
packages/app/src/i18n/ru.ts

@@ -614,12 +614,6 @@ export const dict = {
   "settings.general.row.font.title": "Шрифт",
   "settings.general.row.font.description": "Настройте моноширинный шрифт для блоков кода",
 
-  "settings.general.row.shellToolPartsExpanded.title": "Разворачивать элементы инструмента shell",
-  "settings.general.row.shellToolPartsExpanded.description":
-    "Показывать элементы инструмента shell в ленте развернутыми по умолчанию",
-  "settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit",
-  "settings.general.row.editToolPartsExpanded.description":
-    "Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию",
   "settings.general.row.wayland.title": "Использовать нативный Wayland",
   "settings.general.row.wayland.description": "Отключить X11 fallback на Wayland. Требуется перезапуск.",
   "settings.general.row.wayland.tooltip":

+ 0 - 5
packages/app/src/i18n/th.ts

@@ -608,11 +608,6 @@ export const dict = {
   "settings.general.row.font.title": "ฟอนต์",
   "settings.general.row.font.description": "ปรับแต่งฟอนต์โมโนที่ใช้ในบล็อกโค้ด",
 
-  "settings.general.row.shellToolPartsExpanded.title": "ขยายส่วนเครื่องมือ shell",
-  "settings.general.row.shellToolPartsExpanded.description": "แสดงส่วนเครื่องมือ shell แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
-  "settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit",
-  "settings.general.row.editToolPartsExpanded.description":
-    "แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
   "settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ",
   "settings.general.row.wayland.description": "ปิดใช้งาน X11 fallback บน Wayland ต้องรีสตาร์ท",
   "settings.general.row.wayland.tooltip": "บน Linux ที่มีจอภาพรีเฟรชเรตแบบผสม Wayland แบบเนทีฟอาจเสถียรกว่า",

+ 0 - 7
packages/app/src/i18n/tr.ts

@@ -623,13 +623,6 @@ export const dict = {
   "settings.general.row.font.description": "Kod bloklarında kullanılan monospace yazı tipini özelleştirin",
   "settings.general.row.reasoningSummaries.title": "Akıl yürütme özetlerini göster",
   "settings.general.row.reasoningSummaries.description": "Zaman çizelgesinde model akıl yürütme özetlerini görüntüle",
-  "settings.general.row.shellToolPartsExpanded.title": "Kabuk araç bileşenlerini genişlet",
-  "settings.general.row.shellToolPartsExpanded.description":
-    "Zaman çizelgesinde kabuk araç bileşenlerini varsayılan olarak genişletilmiş göster",
-  "settings.general.row.editToolPartsExpanded.title": "Düzenleme araç bileşenlerini genişlet",
-  "settings.general.row.editToolPartsExpanded.description":
-    "Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster",
-
   "settings.general.row.wayland.title": "Yerel Wayland kullan",
   "settings.general.row.wayland.description":
     "Wayland'da X11 geri dönüşünü devre dışı bırak. Yeniden başlatma gerektirir.",

+ 0 - 4
packages/app/src/i18n/zh.ts

@@ -607,10 +607,6 @@ export const dict = {
   "settings.general.row.theme.description": "自定义 OpenCode 的主题。",
   "settings.general.row.font.title": "字体",
   "settings.general.row.font.description": "自定义代码块使用的等宽字体",
-  "settings.general.row.shellToolPartsExpanded.title": "展开 shell 工具部分",
-  "settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分",
-  "settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分",
-  "settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分",
   "settings.general.row.wayland.title": "使用原生 Wayland",
   "settings.general.row.wayland.description": "在 Wayland 上禁用 X11 回退。需要重启。",
   "settings.general.row.wayland.tooltip": "在混合刷新率显示器的 Linux 系统上,原生 Wayland 可能更稳定。",

+ 0 - 4
packages/app/src/i18n/zht.ts

@@ -603,10 +603,6 @@ export const dict = {
   "settings.general.row.font.title": "字型",
   "settings.general.row.font.description": "自訂程式碼區塊使用的等寬字型",
 
-  "settings.general.row.shellToolPartsExpanded.title": "展開 shell 工具區塊",
-  "settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊",
-  "settings.general.row.editToolPartsExpanded.title": "展開 edit 工具區塊",
-  "settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊",
   "settings.general.row.wayland.title": "使用原生 Wayland",
   "settings.general.row.wayland.description": "在 Wayland 上停用 X11 後備模式。需要重新啟動。",
   "settings.general.row.wayland.tooltip": "在混合更新率螢幕的 Linux 系統上,原生 Wayland 可能更穩定。",

+ 47 - 30
packages/app/src/pages/layout.tsx

@@ -10,8 +10,9 @@ import {
   ParentProps,
   Show,
   untrack,
+  type JSX,
 } from "solid-js"
-import { useNavigate, useParams } from "@solidjs/router"
+import { A, useNavigate, useParams } from "@solidjs/router"
 import { useLayout, LocalProject } from "@/context/layout"
 import { useGlobalSync } from "@/context/global-sync"
 import { Persist, persisted } from "@/utils/persist"
@@ -19,8 +20,9 @@ import { base64Encode } from "@opencode-ai/util/encode"
 import { decode64 } from "@/utils/base64"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { Button } from "@opencode-ai/ui/button"
+import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
-import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { getFilename } from "@opencode-ai/util/path"
@@ -57,6 +59,7 @@ import { Titlebar } from "@/components/titlebar"
 import { useServer } from "@/context/server"
 import { useLanguage, type Locale } from "@/context/language"
 import {
+  childMapByParent,
   displayName,
   effectiveWorkspaceOrder,
   errorMessage,
@@ -1843,7 +1846,7 @@ export default function Layout(props: ParentProps) {
         }}
         style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
       >
-        <Show when={panelProps.project}>
+        <Show when={panelProps.project} keyed>
           {(p) => (
             <>
               <div class="shrink-0 px-2 py-1">
@@ -1852,7 +1855,7 @@ export default function Layout(props: ParentProps) {
                     <InlineEditor
                       id={`project:${projectId()}`}
                       value={projectName}
-                      onSave={(next) => renameProject(p(), next)}
+                      onSave={(next) => renameProject(p, next)}
                       class="text-14-medium text-text-strong truncate"
                       displayClass="text-14-medium text-text-strong truncate"
                       stopPropagation
@@ -1861,7 +1864,7 @@ export default function Layout(props: ParentProps) {
                     <Tooltip
                       placement="bottom"
                       gutter={2}
-                      value={p().worktree}
+                      value={p.worktree}
                       class="shrink-0"
                       contentStyle={{
                         "max-width": "640px",
@@ -1869,7 +1872,7 @@ export default function Layout(props: ParentProps) {
                       }}
                     >
                       <span class="text-12-regular text-text-base truncate select-text">
-                        {p().worktree.replace(homedir(), "~")}
+                        {p.worktree.replace(homedir(), "~")}
                       </span>
                     </Tooltip>
                   </div>
@@ -1880,7 +1883,7 @@ export default function Layout(props: ParentProps) {
                       icon="dot-grid"
                       variant="ghost"
                       data-action="project-menu"
-                      data-project={base64Encode(p().worktree)}
+                      data-project={base64Encode(p.worktree)}
                       class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
                       classList={{
                         "opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
@@ -1889,24 +1892,24 @@ export default function Layout(props: ParentProps) {
                     />
                     <DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}>
                       <DropdownMenu.Content class="mt-1">
-                        <DropdownMenu.Item onSelect={() => showEditProjectDialog(p())}>
+                        <DropdownMenu.Item onSelect={() => showEditProjectDialog(p)}>
                           <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
                         </DropdownMenu.Item>
                         <DropdownMenu.Item
                           data-action="project-workspaces-toggle"
-                          data-project={base64Encode(p().worktree)}
-                          disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()}
-                          onSelect={() => toggleProjectWorkspaces(p())}
+                          data-project={base64Encode(p.worktree)}
+                          disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()}
+                          onSelect={() => toggleProjectWorkspaces(p)}
                         >
                           <DropdownMenu.ItemLabel>
-                            {layout.sidebar.workspaces(p().worktree)()
+                            {layout.sidebar.workspaces(p.worktree)()
                               ? language.t("sidebar.workspaces.disable")
                               : language.t("sidebar.workspaces.enable")}
                           </DropdownMenu.ItemLabel>
                         </DropdownMenu.Item>
                         <DropdownMenu.Item
                           data-action="project-clear-notifications"
-                          data-project={base64Encode(p().worktree)}
+                          data-project={base64Encode(p.worktree)}
                           disabled={unseenCount() === 0}
                           onSelect={clearNotifications}
                         >
@@ -1917,8 +1920,8 @@ export default function Layout(props: ParentProps) {
                         <DropdownMenu.Separator />
                         <DropdownMenu.Item
                           data-action="project-close-menu"
-                          data-project={base64Encode(p().worktree)}
-                          onSelect={() => closeProject(p().worktree)}
+                          data-project={base64Encode(p.worktree)}
+                          onSelect={() => closeProject(p.worktree)}
                         >
                           <DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
                         </DropdownMenu.Item>
@@ -1934,19 +1937,25 @@ export default function Layout(props: ParentProps) {
                   fallback={
                     <>
                       <div class="shrink-0 py-4 px-3">
-                        <Button
-                          size="large"
-                          icon="plus-small"
-                          class="w-full"
-                          onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
+                        <TooltipKeybind
+                          title={language.t("command.session.new")}
+                          keybind={command.keybind("session.new")}
+                          placement="top"
                         >
-                          {language.t("command.session.new")}
-                        </Button>
+                          <Button
+                            size="large"
+                            icon="plus-small"
+                            class="w-full"
+                            onClick={() => navigateWithSidebarReset(`/${base64Encode(p.worktree)}/session`)}
+                          >
+                            {language.t("command.session.new")}
+                          </Button>
+                        </TooltipKeybind>
                       </div>
                       <div class="flex-1 min-h-0">
                         <LocalWorkspace
                           ctx={workspaceSidebarCtx}
-                          project={p()}
+                          project={p}
                           sortNow={sortNow}
                           mobile={panelProps.mobile}
                         />
@@ -1956,9 +1965,15 @@ export default function Layout(props: ParentProps) {
                 >
                   <>
                     <div class="shrink-0 py-4 px-3">
-                      <Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
-                        {language.t("workspace.new")}
-                      </Button>
+                      <TooltipKeybind
+                        title={language.t("workspace.new")}
+                        keybind={command.keybind("workspace.new")}
+                        placement="top"
+                      >
+                        <Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
+                          {language.t("workspace.new")}
+                        </Button>
+                      </TooltipKeybind>
                     </div>
                     <div class="relative flex-1 min-h-0">
                       <DragDropProvider
@@ -1981,7 +1996,7 @@ export default function Layout(props: ParentProps) {
                                 <SortableWorkspace
                                   ctx={workspaceSidebarCtx}
                                   directory={directory}
-                                  project={p()}
+                                  project={p}
                                   sortNow={sortNow}
                                   mobile={panelProps.mobile}
                                 />
@@ -2093,9 +2108,11 @@ export default function Layout(props: ParentProps) {
             />
           </div>
           <Show when={!layout.sidebar.opened() ? hoverProjectData()?.worktree : undefined} keyed>
-            <div class="absolute inset-y-0 left-16 z-50 flex" onMouseEnter={aim.reset}>
-              <SidebarPanel project={hoverProjectData()} />
-            </div>
+            {(worktree) => (
+              <div class="absolute inset-y-0 left-16 z-50 flex" onMouseEnter={aim.reset}>
+                <SidebarPanel project={hoverProjectData()} />
+              </div>
+            )}
           </Show>
           <Show when={layout.sidebar.opened()}>
             <ResizeHandle

+ 121 - 144
packages/app/src/pages/session.tsx

@@ -31,16 +31,17 @@ import { usePrompt } from "@/context/prompt"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
 import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
+import { same } from "@opencode-ai/util/array"
+import { isEditableTarget } from "@/utils/dom"
 import { createOpenReviewFile } from "@/pages/session/helpers"
 import { MessageTimeline } from "@/pages/session/message-timeline"
 import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
 import { createScrollSpy } from "@/pages/session/scroll-spy"
+import { useSessionCommands } from "@/pages/session/use-session-commands"
 import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
 import { SessionSidePanel } from "@/pages/session/session-side-panel"
 import { TerminalPanel } from "@/pages/session/terminal-panel"
-import { useSessionCommands } from "@/pages/session/use-session-commands"
 import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
-import { same } from "@/utils/same"
 
 const emptyUserMessages: UserMessage[] = []
 
@@ -118,13 +119,13 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
       return
     }
     const beforeTop = el.scrollTop
-    const beforeHeight = el.scrollHeight
     fn()
-    requestAnimationFrame(() => {
-      const delta = el.scrollHeight - beforeHeight
-      if (!delta) return
-      el.scrollTop = beforeTop + delta
-    })
+    // SolidJS updates the DOM synchronously. Force reflow so the browser
+    // processes the new layout, then restore scrollTop before paint.
+    // With column-reverse + overflow-anchor:none the same scrollTop value
+    // keeps the same distance from the bottom — no delta math needed.
+    void el.scrollHeight
+    el.scrollTop = beforeTop
   }
 
   const backfillTurns = () => {
@@ -207,7 +208,8 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
     if (!input.userScrolled()) return
     const el = input.scroller()
     if (!el) return
-    if (el.scrollTop >= turnScrollThreshold) return
+    // With column-reverse, distance from top = scrollHeight - clientHeight + scrollTop
+    if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
 
     const start = turnStart()
     if (start > 0) {
@@ -285,7 +287,6 @@ export default function Page() {
       bottom: true,
     },
   })
-
   const composer = createSessionComposerState()
 
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
@@ -430,20 +431,8 @@ export default function Page() {
     mobileTab: "session" as "session" | "changes",
     changes: "session" as "session" | "turn",
     newSessionWorktree: "main",
-    deferRender: false,
   })
 
-  createComputed((prev) => {
-    const key = sessionKey()
-    if (key !== prev) {
-      setStore("deferRender", true)
-      requestAnimationFrame(() => {
-        setTimeout(() => setStore("deferRender", false), 0)
-      })
-    }
-    return key
-  }, sessionKey())
-
   const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
   const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
 
@@ -454,11 +443,6 @@ export default function Page() {
     return "main"
   })
 
-  const activeMessage = createMemo(() => {
-    if (!store.messageId) return lastUserMessage()
-    const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
-    return found ?? lastUserMessage()
-  })
   const setActiveMessage = (message: UserMessage | undefined) => {
     setStore("messageId", message?.id)
   }
@@ -620,11 +604,6 @@ export default function Page() {
     saveLabel: language.t("common.save"),
   }))
 
-  const isEditableTarget = (target: EventTarget | null | undefined) => {
-    if (!(target instanceof HTMLElement)) return false
-    return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
-  }
-
   const deepActiveElement = () => {
     let current: Element | null = document.activeElement
     while (current instanceof HTMLElement && current.shadowRoot?.activeElement) {
@@ -755,67 +734,35 @@ export default function Page() {
     loadingClass: string
     emptyClass: string
   }) => (
-    <Show when={!store.deferRender}>
-      <Switch>
-        <Match when={store.changes === "turn" && !!params.id}>
-          <SessionReviewTab
-            title={changesTitle()}
-            empty={emptyTurn()}
-            diffs={reviewDiffs}
-            view={view}
-            diffStyle={input.diffStyle}
-            onDiffStyleChange={input.onDiffStyleChange}
-            onScrollRef={(el) => setTree("reviewScroll", el)}
-            focusedFile={tree.activeDiff}
-            onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
-            onLineCommentUpdate={updateCommentInContext}
-            onLineCommentDelete={removeCommentFromContext}
-            lineCommentActions={reviewCommentActions()}
-            comments={comments.all()}
-            focusedComment={comments.focus()}
-            onFocusedCommentChange={comments.setFocus}
-            onViewFile={openReviewFile}
-            classes={input.classes}
-          />
-        </Match>
-        <Match when={hasReview()}>
-          <Show
-            when={diffsReady()}
-            fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
-          >
-            <SessionReviewTab
-              title={changesTitle()}
-              diffs={reviewDiffs}
-              view={view}
-              diffStyle={input.diffStyle}
-              onDiffStyleChange={input.onDiffStyleChange}
-              onScrollRef={(el) => setTree("reviewScroll", el)}
-              focusedFile={tree.activeDiff}
-              onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
-              onLineCommentUpdate={updateCommentInContext}
-              onLineCommentDelete={removeCommentFromContext}
-              lineCommentActions={reviewCommentActions()}
-              comments={comments.all()}
-              focusedComment={comments.focus()}
-              onFocusedCommentChange={comments.setFocus}
-              onViewFile={openReviewFile}
-              classes={input.classes}
-            />
-          </Show>
-        </Match>
-        <Match when={true}>
+    <Switch>
+      <Match when={store.changes === "turn" && !!params.id}>
+        <SessionReviewTab
+          title={changesTitle()}
+          empty={emptyTurn()}
+          diffs={reviewDiffs}
+          view={view}
+          diffStyle={input.diffStyle}
+          onDiffStyleChange={input.onDiffStyleChange}
+          onScrollRef={(el) => setTree("reviewScroll", el)}
+          focusedFile={tree.activeDiff}
+          onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
+          onLineCommentUpdate={updateCommentInContext}
+          onLineCommentDelete={removeCommentFromContext}
+          lineCommentActions={reviewCommentActions()}
+          comments={comments.all()}
+          focusedComment={comments.focus()}
+          onFocusedCommentChange={comments.setFocus}
+          onViewFile={openReviewFile}
+          classes={input.classes}
+        />
+      </Match>
+      <Match when={hasReview()}>
+        <Show
+          when={diffsReady()}
+          fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
+        >
           <SessionReviewTab
             title={changesTitle()}
-            empty={
-              store.changes === "turn" ? (
-                emptyTurn()
-              ) : (
-                <div class={input.emptyClass}>
-                  <Mark class="w-14 opacity-10" />
-                  <div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
-                </div>
-              )
-            }
             diffs={reviewDiffs}
             view={view}
             diffStyle={input.diffStyle}
@@ -832,9 +779,39 @@ export default function Page() {
             onViewFile={openReviewFile}
             classes={input.classes}
           />
-        </Match>
-      </Switch>
-    </Show>
+        </Show>
+      </Match>
+      <Match when={true}>
+        <SessionReviewTab
+          title={changesTitle()}
+          empty={
+            store.changes === "turn" ? (
+              emptyTurn()
+            ) : (
+              <div class={input.emptyClass}>
+                <Mark class="w-14 opacity-10" />
+                <div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
+              </div>
+            )
+          }
+          diffs={reviewDiffs}
+          view={view}
+          diffStyle={input.diffStyle}
+          onDiffStyleChange={input.onDiffStyleChange}
+          onScrollRef={(el) => setTree("reviewScroll", el)}
+          focusedFile={tree.activeDiff}
+          onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
+          onLineCommentUpdate={updateCommentInContext}
+          onLineCommentDelete={removeCommentFromContext}
+          lineCommentActions={reviewCommentActions()}
+          comments={comments.all()}
+          focusedComment={comments.focus()}
+          onFocusedCommentChange={comments.setFocus}
+          onViewFile={openReviewFile}
+          classes={input.classes}
+        />
+      </Match>
+    </Switch>
   )
 
   const reviewPanel = () => (
@@ -1045,7 +1022,10 @@ export default function Page() {
   const updateScrollState = (el: HTMLDivElement) => {
     const max = el.scrollHeight - el.clientHeight
     const overflow = max > 1
-    const bottom = !overflow || el.scrollTop >= max - 2
+    // If auto-scroll is tracking the bottom, always report bottom: true
+    // to prevent the scroll-down arrow from flashing during height animations
+    // With column-reverse, scrollTop=0 is at the bottom
+    const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled()
 
     if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
     setUi("scroll", { overflow, bottom })
@@ -1068,7 +1048,7 @@ export default function Page() {
 
   const resumeScroll = () => {
     setStore("messageId", undefined)
-    autoScroll.forceScrollToBottom()
+    autoScroll.smoothScrollToBottom()
     clearMessageHash()
 
     const el = scroller
@@ -1136,9 +1116,8 @@ export default function Page() {
 
       const el = scroller
       const delta = next - dockHeight
-      const stick = el
-        ? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta)
-        : false
+      // With column-reverse, near bottom = scrollTop near 0
+      const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false
 
       dockHeight = next
 
@@ -1204,50 +1183,49 @@ export default function Page() {
           <div class="flex-1 min-h-0 overflow-hidden">
             <Switch>
               <Match when={params.id}>
-                <Show when={activeMessage()}>
-                  <MessageTimeline
-                    mobileChanges={mobileChanges()}
-                    mobileFallback={reviewContent({
-                      diffStyle: "unified",
-                      classes: {
-                        root: "pb-8",
-                        header: "px-4",
-                        container: "px-4",
-                      },
-                      loadingClass: "px-4 py-4 text-text-weak",
-                      emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
-                    })}
-                    scroll={ui.scroll}
-                    onResumeScroll={resumeScroll}
-                    setScrollRef={setScrollRef}
-                    onScheduleScrollState={scheduleScrollState}
-                    onAutoScrollHandleScroll={autoScroll.handleScroll}
-                    onMarkScrollGesture={markScrollGesture}
-                    hasScrollGesture={hasScrollGesture}
-                    isDesktop={isDesktop()}
-                    onScrollSpyScroll={scrollSpy.onScroll}
-                    onTurnBackfillScroll={historyWindow.onScrollerScroll}
-                    onAutoScrollInteraction={autoScroll.handleInteraction}
-                    centered={centered()}
-                    setContentRef={(el) => {
-                      content = el
-                      autoScroll.contentRef(el)
-
-                      const root = scroller
-                      if (root) scheduleScrollState(root)
-                    }}
-                    turnStart={historyWindow.turnStart()}
-                    historyMore={historyMore()}
-                    historyLoading={historyLoading()}
-                    onLoadEarlier={() => {
-                      void historyWindow.loadAndReveal()
-                    }}
-                    renderedUserMessages={historyWindow.renderedUserMessages()}
-                    anchor={anchor}
-                    onRegisterMessage={scrollSpy.register}
-                    onUnregisterMessage={scrollSpy.unregister}
-                  />
-                </Show>
+                <MessageTimeline
+                  mobileChanges={mobileChanges()}
+                  mobileFallback={reviewContent({
+                    diffStyle: "unified",
+                    classes: {
+                      root: "pb-8",
+                      header: "px-4",
+                      container: "px-4",
+                    },
+                    loadingClass: "px-4 py-4 text-text-weak",
+                    emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
+                  })}
+                  scroll={ui.scroll}
+                  onResumeScroll={resumeScroll}
+                  setScrollRef={setScrollRef}
+                  onScheduleScrollState={scheduleScrollState}
+                  onAutoScrollHandleScroll={autoScroll.handleScroll}
+                  onMarkScrollGesture={markScrollGesture}
+                  hasScrollGesture={hasScrollGesture}
+                  isDesktop={isDesktop()}
+                  onScrollSpyScroll={scrollSpy.onScroll}
+                  onTurnBackfillScroll={historyWindow.onScrollerScroll}
+                  onAutoScrollInteraction={autoScroll.handleInteraction}
+                  onPreserveScrollAnchor={autoScroll.preserve}
+                  centered={centered()}
+                  setContentRef={(el) => {
+                    content = el
+                    autoScroll.contentRef(el)
+
+                    const root = scroller
+                    if (root) scheduleScrollState(root)
+                  }}
+                  turnStart={historyWindow.turnStart()}
+                  historyMore={historyMore()}
+                  historyLoading={historyLoading()}
+                  onLoadEarlier={() => {
+                    void historyWindow.loadAndReveal()
+                  }}
+                  renderedUserMessages={historyWindow.renderedUserMessages()}
+                  anchor={anchor}
+                  onRegisterMessage={scrollSpy.register}
+                  onUnregisterMessage={scrollSpy.unregister}
+                />
               </Match>
               <Match when={true}>
                 <NewSessionView
@@ -1273,7 +1251,6 @@ export default function Page() {
 
           <SessionComposerRegion
             state={composer}
-            ready={!store.deferRender && messagesReady()}
             centered={centered()}
             inputRef={(el) => {
               inputRef = el

+ 26 - 0
packages/app/src/pages/session/composer/index.ts

@@ -1,3 +1,29 @@
+/**
+ * Composer Island migration tracker
+ *
+ * Goal
+ * - Replace the split composer stack (PromptInput + question/permission/todo docks)
+ *   with a single morphing ComposerIsland + app runtime adapter.
+ *
+ * Current status
+ * - [x] Storybook prototype with morphing surfaces exists (`packages/ui/src/components/composer-island.tsx`).
+ * - [ ] App still renders the existing production stack (`session-composer-region` + `prompt-input`).
+ *
+ * Feature parity checklist
+ * - [ ] Submit pipeline parity (session/worktree/create, optimistic user message, abort, restore on error).
+ * - [x] Runtime adapter API boundary in island (`runtime.submit`, `runtime.abort`, lookup, permission/question handlers).
+ * - [x] Shell mode wiring parity (single mode source for tray + editor).
+ * - [x] Cursor scroll-into-view behavior in island editor.
+ * - [x] Attachment parity in island UI: image + PDF support and file picker wiring.
+ * - [x] Drag/drop parity in island UI: attachment drop + `file:` text drop into @mention.
+ * - [x] Keyboard parity in island UI: mod+u, ctrl+g, ctrl+n/ctrl+p, popover navigation.
+ * - [x] Auto-accept + stop/send state parity in island UI.
+ * - [x] Async @mention/slash sourcing parity in island via runtime search hooks.
+ * - [ ] Context item parity (comment chips/open behavior is in progress; app comment wiring still pending).
+ * - [ ] Question flow parity (island submit/reject hooks + sending locks done; app SDK/cache wiring pending).
+ * - [ ] Permission flow parity (island responding lock done; app tool-description wiring pending).
+ * - [x] Todo parity in island UI (in-progress pulse + auto-scroll active item).
+ */
 export { SessionComposerRegion } from "./session-composer-region"
 export { createSessionComposerBlocked, createSessionComposerState } from "./session-composer-state"
 export type { SessionComposerState } from "./session-composer-state"

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

@@ -1,7 +1,7 @@
-import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
-import { createStore } from "solid-js/store"
+import { Show, createMemo, createSignal, createEffect } from "solid-js"
 import { useParams } from "@solidjs/router"
 import { useSpring } from "@opencode-ai/ui/motion-spring"
+import { useElementHeight } from "@opencode-ai/ui/hooks"
 import { PromptInput } from "@/components/prompt-input"
 import { useLanguage } from "@/context/language"
 import { usePrompt } from "@/context/prompt"
@@ -9,11 +9,12 @@ import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
 import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
 import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
 import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
-import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
+import { SessionTodoDock, COLLAPSED_HEIGHT } from "@/pages/session/composer/session-todo-dock"
+
+const DOCK_SPRING = { visualDuration: 0.3, bounce: 0 }
 
 export function SessionComposerRegion(props: {
   state: SessionComposerState
-  ready: boolean
   centered: boolean
   inputRef: (el: HTMLDivElement) => void
   newSessionWorktree: string
@@ -21,23 +22,6 @@ export function SessionComposerRegion(props: {
   onSubmit: () => void
   onResponseSubmit: () => void
   setPromptDockRef: (el: HTMLDivElement) => void
-  visualDuration?: number
-  bounce?: number
-  dockOpenVisualDuration?: number
-  dockOpenBounce?: number
-  dockCloseVisualDuration?: number
-  dockCloseBounce?: number
-  drawerExpandVisualDuration?: number
-  drawerExpandBounce?: number
-  drawerCollapseVisualDuration?: number
-  drawerCollapseBounce?: number
-  subtitleDuration?: number
-  subtitleTravel?: number
-  subtitleEdge?: number
-  countDuration?: number
-  countMask?: number
-  countMaskHeight?: number
-  countWidthDuration?: number
 }) {
   const params = useParams()
   const prompt = usePrompt()
@@ -63,73 +47,15 @@ export function SessionComposerRegion(props: {
     setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
   })
 
-  const [gate, setGate] = createStore({
-    ready: false,
-  })
-  let timer: number | undefined
-  let frame: number | undefined
-
-  const clear = () => {
-    if (timer !== undefined) {
-      window.clearTimeout(timer)
-      timer = undefined
-    }
-    if (frame !== undefined) {
-      cancelAnimationFrame(frame)
-      frame = undefined
-    }
-  }
-
-  createEffect(() => {
-    sessionKey()
-    const ready = props.ready
-    const delay = 140
-
-    clear()
-    setGate("ready", false)
-    if (!ready) return
-
-    frame = requestAnimationFrame(() => {
-      frame = undefined
-      timer = window.setTimeout(() => {
-        setGate("ready", true)
-        timer = undefined
-      }, delay)
-    })
-  })
-
-  onCleanup(clear)
-
-  const open = createMemo(() => gate.ready && props.state.dock() && !props.state.closing())
-  const config = createMemo(() =>
-    open()
-      ? {
-          visualDuration: props.dockOpenVisualDuration ?? props.visualDuration ?? 0.3,
-          bounce: props.dockOpenBounce ?? props.bounce ?? 0,
-        }
-      : {
-          visualDuration: props.dockCloseVisualDuration ?? props.visualDuration ?? 0.3,
-          bounce: props.dockCloseBounce ?? props.bounce ?? 0,
-        },
+  const open = createMemo(() => props.state.dock() && !props.state.closing())
+  const progress = useSpring(
+    () => (open() ? 1 : 0),
+    DOCK_SPRING,
   )
-  const progress = useSpring(() => (open() ? 1 : 0), config)
-  const value = createMemo(() => Math.max(0, Math.min(1, progress())))
-  const [height, setHeight] = createSignal(320)
-  const dock = createMemo(() => (gate.ready && props.state.dock()) || value() > 0.001)
-  const full = createMemo(() => Math.max(78, height()))
+  const dock = createMemo(() => props.state.dock() || progress() > 0.001)
   const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
-
-  createEffect(() => {
-    const el = contentRef()
-    if (!el) return
-    const update = () => {
-      setHeight(el.getBoundingClientRect().height)
-    }
-    update()
-    const observer = new ResizeObserver(update)
-    observer.observe(el)
-    onCleanup(() => observer.disconnect())
-  })
+  const height = useElementHeight(contentRef, 320)
+  const full = createMemo(() => Math.max(COLLAPSED_HEIGHT, height()))
 
   return (
     <div
@@ -179,10 +105,10 @@ export function SessionComposerRegion(props: {
               <div
                 classList={{
                   "overflow-hidden": true,
-                  "pointer-events-none": value() < 0.98,
+                  "pointer-events-none": progress() < 0.98,
                 }}
                 style={{
-                  "max-height": `${full() * value()}px`,
+                  "max-height": `${full() * progress()}px`,
                 }}
               >
                 <div ref={setContentRef}>
@@ -191,20 +117,7 @@ export function SessionComposerRegion(props: {
                     title={language.t("session.todo.title")}
                     collapseLabel={language.t("session.todo.collapse")}
                     expandLabel={language.t("session.todo.expand")}
-                    dockProgress={value()}
-                    visualDuration={props.visualDuration}
-                    bounce={props.bounce}
-                    expandVisualDuration={props.drawerExpandVisualDuration}
-                    expandBounce={props.drawerExpandBounce}
-                    collapseVisualDuration={props.drawerCollapseVisualDuration}
-                    collapseBounce={props.drawerCollapseBounce}
-                    subtitleDuration={props.subtitleDuration}
-                    subtitleTravel={props.subtitleTravel}
-                    subtitleEdge={props.subtitleEdge}
-                    countDuration={props.countDuration}
-                    countMask={props.countMask}
-                    countMaskHeight={props.countMaskHeight}
-                    countWidthDuration={props.countWidthDuration}
+                    dockProgress={progress()}
                   />
                 </div>
               </div>
@@ -214,7 +127,7 @@ export function SessionComposerRegion(props: {
                 "relative z-10": true,
               }}
               style={{
-                "margin-top": `${-36 * value()}px`,
+                "margin-top": `${-36 * progress()}px`,
               }}
             >
               <PromptInput

+ 5 - 1
packages/app/src/pages/session/composer/session-composer-state.ts

@@ -29,7 +29,11 @@ export function createSessionComposerBlocked() {
   })
 }
 
-export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
+export function createSessionComposerState(
+  options?: {
+    closeMs?: number | (() => number)
+  },
+) {
   const params = useParams()
   const sdk = useSDK()
   const sync = useSync()

+ 29 - 63
packages/app/src/pages/session/composer/session-todo-dock.tsx

@@ -6,9 +6,15 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
 import { useSpring } from "@opencode-ai/ui/motion-spring"
 import { TextReveal } from "@opencode-ai/ui/text-reveal"
 import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
+import { useElementHeight } from "@opencode-ai/ui/hooks"
 import { Index, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
 
+const COLLAPSE_SPRING = { visualDuration: 0.3, bounce: 0 }
+export const COLLAPSED_HEIGHT = 78
+const SUBTITLE = { duration: 600, travel: 25, edge: 17 }
+const COUNT = { duration: 600, mask: 18, maskHeight: 0, widthDuration: 560 }
+
 function dot(status: Todo["status"]) {
   if (status !== "in_progress") return undefined
   return (
@@ -40,19 +46,6 @@ export function SessionTodoDock(props: {
   collapseLabel: string
   expandLabel: string
   dockProgress?: number
-  visualDuration?: number
-  bounce?: number
-  expandVisualDuration?: number
-  expandBounce?: number
-  collapseVisualDuration?: number
-  collapseBounce?: number
-  subtitleDuration?: number
-  subtitleTravel?: number
-  subtitleEdge?: number
-  countDuration?: number
-  countMask?: number
-  countMaskHeight?: number
-  countWidthDuration?: number
 }) {
   const [store, setStore] = createStore({
     collapsed: false,
@@ -73,39 +66,12 @@ export function SessionTodoDock(props: {
   )
 
   const preview = createMemo(() => active()?.content ?? "")
-  const config = createMemo(() =>
-    store.collapsed
-      ? {
-          visualDuration: props.collapseVisualDuration ?? props.visualDuration ?? 0.3,
-          bounce: props.collapseBounce ?? props.bounce ?? 0,
-        }
-      : {
-          visualDuration: props.expandVisualDuration ?? props.visualDuration ?? 0.3,
-          bounce: props.expandBounce ?? props.bounce ?? 0,
-        },
-  )
-  const collapse = useSpring(() => (store.collapsed ? 1 : 0), config)
-  const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress ?? 1)))
-  const shut = createMemo(() => 1 - dock())
-  const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
-  const hide = createMemo(() => Math.max(value(), shut()))
-  const off = createMemo(() => hide() > 0.98)
-  const turn = createMemo(() => Math.max(0, Math.min(1, value())))
-  const [height, setHeight] = createSignal(320)
-  const full = createMemo(() => Math.max(78, height()))
+  const collapse = useSpring(() => (store.collapsed ? 1 : 0), COLLAPSE_SPRING)
+  const shut = createMemo(() => 1 - (props.dockProgress ?? 1))
+  const hide = createMemo(() => Math.max(collapse(), shut()))
   let contentRef: HTMLDivElement | undefined
-
-  createEffect(() => {
-    const el = contentRef
-    if (!el) return
-    const update = () => {
-      setHeight(el.getBoundingClientRect().height)
-    }
-    update()
-    const observer = new ResizeObserver(update)
-    observer.observe(el)
-    onCleanup(() => observer.disconnect())
-  })
+  const height = useElementHeight(() => contentRef, 320)
+  const full = createMemo(() => Math.max(COLLAPSED_HEIGHT, height()))
 
   return (
     <DockTray
@@ -113,7 +79,7 @@ export function SessionTodoDock(props: {
       style={{
         "overflow-x": "visible",
         "overflow-y": "hidden",
-        "max-height": `${Math.max(78, full() - value() * (full() - 78))}px`,
+        "max-height": `${Math.max(COLLAPSED_HEIGHT, full() - collapse() * (full() - COLLAPSED_HEIGHT))}px`,
       }}
     >
       <div ref={contentRef}>
@@ -133,12 +99,12 @@ export function SessionTodoDock(props: {
             class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 whitespace-nowrap overflow-visible"
             aria-label={label()}
             style={{
-              "--tool-motion-odometer-ms": `${props.countDuration ?? 600}ms`,
-              "--tool-motion-mask": `${props.countMask ?? 18}%`,
-              "--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`,
-              "--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`,
-              opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
-              filter: `blur(${Math.max(0, Math.min(1, shut())) * 2}px)`,
+              "--tool-motion-odometer-ms": `${COUNT.duration}ms`,
+              "--tool-motion-mask": `${COUNT.mask}%`,
+              "--tool-motion-mask-height": `${COUNT.maskHeight}px`,
+              "--tool-motion-spring-ms": `${COUNT.widthDuration}ms`,
+              opacity: `${1 - shut()}`,
+              filter: shut() > 0.01 ? `blur(${shut() * 2}px)` : "none",
             }}
           >
             <AnimatedNumber value={done()} />
@@ -157,9 +123,9 @@ export function SessionTodoDock(props: {
             <TextReveal
               class="text-14-regular text-text-base cursor-default"
               text={store.collapsed ? preview() : undefined}
-              duration={props.subtitleDuration ?? 600}
-              travel={props.subtitleTravel ?? 25}
-              edge={props.subtitleEdge ?? 17}
+              duration={SUBTITLE.duration}
+              travel={SUBTITLE.travel}
+              edge={SUBTITLE.edge}
               spring="cubic-bezier(0.34, 1, 0.64, 1)"
               springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
               growOnly
@@ -173,7 +139,7 @@ export function SessionTodoDock(props: {
               icon="chevron-down"
               size="normal"
               variant="ghost"
-              style={{ transform: `rotate(${turn() * 180}deg)` }}
+              style={{ transform: `rotate(${collapse() * 180}deg)` }}
               onMouseDown={(event) => {
                 event.preventDefault()
                 event.stopPropagation()
@@ -189,14 +155,15 @@ export function SessionTodoDock(props: {
 
         <div
           data-slot="session-todo-list"
-          aria-hidden={store.collapsed || off()}
+          class="pb-2"
+          aria-hidden={store.collapsed}
           classList={{
             "pointer-events-none": hide() > 0.1,
           }}
           style={{
-            visibility: off() ? "hidden" : "visible",
-            opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
-            filter: `blur(${Math.max(0, Math.min(1, hide())) * 2}px)`,
+            opacity: `${1 - hide()}`,
+            filter: hide() > 0.01 ? `blur(${hide() * 2}px)` : "none",
+            visibility: hide() > 0.98 ? "hidden" : "visible",
           }}
         >
           <TodoList todos={props.todos} open={!store.collapsed} />
@@ -282,7 +249,7 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
                 "--checkbox-align": "flex-start",
                 "--checkbox-offset": "1px",
                 transition: "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
-                opacity: todo().status === "pending" ? "0.94" : "1",
+                opacity: todo().status === "pending" ? "0.5" : "1",
               }}
             >
               <TextStrikethrough
@@ -292,12 +259,11 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
                 style={{
                   "line-height": "var(--line-height-normal)",
                   transition:
-                    "color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
+                    "color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
                   color:
                     todo().status === "completed" || todo().status === "cancelled"
                       ? "var(--text-weak)"
                       : "var(--text-strong)",
-                  opacity: todo().status === "pending" ? "0.92" : "1",
                 }}
               />
             </Checkbox>

+ 2 - 2
packages/app/src/pages/session/message-gesture.test.ts

@@ -49,11 +49,11 @@ describe("shouldMarkBoundaryGesture", () => {
     ).toBe(true)
   })
 
-  test("does not mark when nested scroller can consume movement", () => {
+  test("does not mark when scroller can consume movement", () => {
     expect(
       shouldMarkBoundaryGesture({
         delta: 20,
-        scrollTop: 200,
+        scrollTop: 300,
         scrollHeight: 1000,
         clientHeight: 400,
       }),

+ 4 - 4
packages/app/src/pages/session/message-gesture.ts

@@ -14,8 +14,8 @@ export const shouldMarkBoundaryGesture = (input: {
   if (max <= 1) return true
   if (!input.delta) return false
 
-  if (input.delta < 0) return input.scrollTop + input.delta <= 0
-
-  const remaining = max - input.scrollTop
-  return input.delta > remaining
+  const top = Math.max(0, Math.min(max, input.scrollTop))
+  if (input.delta < 0) return -input.delta > top
+  const bottom = max - top
+  return input.delta > bottom
 }

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

@@ -1,27 +1,31 @@
-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 {
+  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 { 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
@@ -33,7 +37,9 @@ type MessageComment = {
 }
 
 const emptyMessages: MessageType[] = []
-const idle = { type: "idle" as const }
+
+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 messageComments = (parts: Part[]): MessageComment[] =>
   parts.flatMap((part) => {
@@ -110,6 +116,8 @@ function createTimelineStaging(input: TimelineStageInput) {
     completedSession: "",
     count: 0,
   })
+  const [readySession, setReadySession] = createSignal("")
+  let active = ""
 
   const stagedCount = createMemo(() => {
     const total = input.messages().length
@@ -134,23 +142,46 @@ 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()
-        const shouldStage =
-          isWindowed &&
-          total > input.config.init &&
-          state.completedSession !== sessionKey &&
-          state.activeSession !== sessionKey
+
+        if (shouldStage) setReadySession("")
         if (!shouldStage) {
-          setState({ activeSession: "", count: total })
+          setState({
+            activeSession: "",
+            completedSession: isWindowed ? sessionKey : state.completedSession,
+            count: total,
+          })
+          if (total <= 0) {
+            setReadySession("")
+            return
+          }
+          if (readySession() !== sessionKey) scheduleReady(sessionKey)
           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 = () => {
@@ -160,10 +191,11 @@ function createTimelineStaging(input: TimelineStageInput) {
           }
           const currentTotal = input.messages().length
           count = Math.min(currentTotal, count + input.config.batch)
-          setState("count", count)
+          startTransition(() => setState("count", count))
           if (count >= currentTotal) {
             setState({ completedSession: sessionKey, activeSession: "" })
             frame = undefined
+            scheduleReady(sessionKey)
             return
           }
           frame = requestAnimationFrame(step)
@@ -177,9 +209,12 @@ 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 }
+  onCleanup(() => {
+    cancel()
+  })
+  return { messages: stagedUserMessages, isStaging, ready }
 }
 
 export function MessageTimeline(props: {
@@ -196,6 +231,7 @@ export function MessageTimeline(props: {
   onScrollSpyScroll: () => void
   onTurnBackfillScroll: () => void
   onAutoScrollInteraction: (event: MouseEvent) => void
+  onPreserveScrollAnchor: (target: HTMLElement) => void
   centered: boolean
   setContentRef: (el: HTMLDivElement) => void
   turnStart: number
@@ -210,14 +246,19 @@ 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 rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
+  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 sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const sessionID = createMemo(() => params.id)
   const sessionMessages = createMemo(() => {
@@ -230,28 +271,20 @@ export function MessageTimeline(props: {
       (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
     ),
   )
-  const sessionStatus = createMemo(() => {
-    const id = sessionID()
-    if (!id) return idle
-    return sync.data.session_status[id] ?? idle
-  })
+  const sessionStatus = createMemo(() => sync.data.session_status[sessionID() ?? ""]?.type ?? "idle")
   const activeMessageID = createMemo(() => {
-    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
+    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 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
-      }
+    if (sessionStatus() === "idle") return undefined
+    for (let i = messages.length - 1; i >= 0; i--) {
+      if (messages[i].role === "user") return messages[i].id
     }
-
     return undefined
   })
   const info = createMemo(() => {
@@ -259,9 +292,19 @@ export function MessageTimeline(props: {
     if (!id) return
     return sync.session.get(id)
   })
-  const titleValue = createMemo(() => info()?.title)
+  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 parentID = createMemo(() => info()?.parentID)
-  const showHeader = createMemo(() => !!(titleValue() || parentID()))
+  const showHeader = createMemo(() => !!(headerTitle() || parentID()))
   const stageCfg = { init: 1, batch: 3 }
   const staging = createTimelineStaging({
     sessionKey,
@@ -269,212 +312,7 @@ export function MessageTimeline(props: {
     messages: () => props.renderedUserMessages,
     config: stageCfg,
   })
-
-  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>
-    )
-  }
+  const rendered = createMemo(() => staging.messages().map((message) => message.id))
 
   return (
     <Show
@@ -498,6 +336,16 @@ 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
           viewportRef={props.setScrollRef}
           onWheel={(e) => {
@@ -532,9 +380,18 @@ 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()
@@ -543,131 +400,21 @@ export function MessageTimeline(props: {
             props.onMarkScrollGesture(e.currentTarget)
             if (props.isDesktop) props.onScrollSpyScroll()
           }}
-          onClick={props.onAutoScrollInteraction}
+          onClick={(e) => {
+            props.onAutoScrollInteraction(e)
+          }}
           class="relative min-w-0 w-full h-full"
           style={{
-            "--session-title-height": showHeader() ? "40px" : "0px",
+            "--session-title-height": showHeader() ? "72px" : "0px",
             "--sticky-accordion-top": showHeader() ? "48px" : "0px",
           }}
         >
-          <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>
             <div
+              ref={props.setContentRef}
               role="log"
-              class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
+              class="flex flex-col gap-0 items-start justify-start pb-16 transition-[margin]"
+              style={{ "padding-top": "var(--session-title-height)" }}
               classList={{
                 "w-full": true,
                 "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
@@ -692,6 +439,15 @@ 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
@@ -700,7 +456,10 @@ export function MessageTimeline(props: {
                     return false
                   })
                   const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
-                    equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
+                    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)
+                    },
                   })
                   const commentCount = createMemo(() => comments().length)
                   return (
@@ -757,10 +516,10 @@ export function MessageTimeline(props: {
                         messageID={messageID}
                         active={active()}
                         queued={queued()}
-                        status={active() ? sessionStatus() : undefined}
+                        animate={isNew || active()}
                         showReasoningSummaries={settings.general.showReasoningSummaries()}
-                        shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
-                        editToolDefaultOpen={settings.general.editToolPartsExpanded()}
+                        shellToolDefaultOpen={false}
+                        editToolDefaultOpen={false}
                         classes={{
                           root: "min-w-0 w-full relative",
                           content: "flex flex-col justify-between !overflow-visible",

+ 3 - 1
packages/app/src/pages/session/session-side-panel.tsx

@@ -331,7 +331,9 @@ export function SessionSidePanel(props: {
                     const path = createMemo(() => file.pathFromTab(tab))
                     return (
                       <div data-component="tabs-drag-preview">
-                        <Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
+                        <Show when={path()} keyed>
+                          {(p) => <FileVisual active path={p} />}
+                        </Show>
                       </div>
                     )
                   }}

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

@@ -0,0 +1,522 @@
+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 { IconButton } from "@opencode-ai/ui/icon-button"
+import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { prefersReducedMotion } from "@opencode-ai/ui/hooks"
+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 = prefersReducedMotion
+
+  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-200 md:mx-auto 2xl:max-w-[1000px]": 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>
+  )
+}

+ 8 - 8
packages/app/src/pages/session/terminal-panel.tsx

@@ -191,8 +191,8 @@ export function TerminalPanel() {
                   <SortableProvider ids={ids()}>
                     <For each={ids()}>
                       {(id) => (
-                        <Show when={byId().get(id)}>
-                          {(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
+                        <Show when={byId().get(id)} keyed>
+                          {(pty) => <SortableTerminalTab terminal={pty} onClose={close} />}
                         </Show>
                       )}
                     </For>
@@ -217,10 +217,10 @@ export function TerminalPanel() {
               <div class="flex-1 min-h-0 relative">
                 <Show when={terminal.active()} keyed>
                   {(id) => (
-                    <Show when={byId().get(id)}>
+                    <Show when={byId().get(id)} keyed>
                       {(pty) => (
                         <div id={`terminal-wrapper-${id}`} class="absolute inset-0">
-                          <Terminal pty={pty()} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} />
+                          <Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} />
                         </div>
                       )}
                     </Show>
@@ -229,14 +229,14 @@ export function TerminalPanel() {
               </div>
             </div>
             <DragOverlay>
-              <Show when={store.activeDraggable}>
+              <Show when={store.activeDraggable} keyed>
                 {(draggedId) => (
-                  <Show when={byId().get(draggedId())}>
+                  <Show when={byId().get(draggedId)} keyed>
                     {(t) => (
                       <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
                         {terminalTabLabel({
-                          title: t().title,
-                          titleNumber: t().titleNumber,
+                          title: t.title,
+                          titleNumber: t.titleNumber,
                           t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
                         })}
                       </div>

+ 9 - 20
packages/app/src/pages/session/use-session-commands.tsx

@@ -261,35 +261,24 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
     }),
   ])
 
-  const isAutoAcceptActive = () => {
-    const sessionID = params.id
-    if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
-    return permission.isAutoAcceptingDirectory(sdk.directory)
-  }
-
   const permissionCommands = createMemo(() => [
     permissionsCommand({
       id: "permissions.autoaccept",
-      title: isAutoAcceptActive()
-        ? language.t("command.permissions.autoaccept.disable")
-        : language.t("command.permissions.autoaccept.enable"),
+      title:
+        params.id && permission.isAutoAccepting(params.id, sdk.directory)
+          ? language.t("command.permissions.autoaccept.disable")
+          : language.t("command.permissions.autoaccept.enable"),
       keybind: "mod+shift+a",
-      disabled: false,
+      disabled: !params.id || !permission.permissionsEnabled(),
       onSelect: () => {
         const sessionID = params.id
-        if (sessionID) {
-          permission.toggleAutoAccept(sessionID, sdk.directory)
-        } else {
-          permission.toggleAutoAcceptDirectory(sdk.directory)
-        }
-        const active = sessionID
-          ? permission.isAutoAccepting(sessionID, sdk.directory)
-          : permission.isAutoAcceptingDirectory(sdk.directory)
+        if (!sessionID) return
+        permission.toggleAutoAccept(sessionID, sdk.directory)
         showToast({
-          title: active
+          title: permission.isAutoAccepting(sessionID, sdk.directory)
             ? language.t("toast.permissions.autoaccept.on.title")
             : language.t("toast.permissions.autoaccept.off.title"),
-          description: active
+          description: permission.isAutoAccepting(sessionID, sdk.directory)
             ? language.t("toast.permissions.autoaccept.on.description")
             : language.t("toast.permissions.autoaccept.off.description"),
         })

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

@@ -1,6 +1,5 @@
 import type { UserMessage } from "@opencode-ai/sdk/v2"
-import { useLocation, useNavigate } from "@solidjs/router"
-import { createEffect, createMemo, onMount } from "solid-js"
+import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
 import { messageIdFromHash } from "./message-id-from-hash"
 
 export { messageIdFromHash } from "./message-id-from-hash"
@@ -16,7 +15,7 @@ export const useSessionHashScroll = (input: {
   setPendingMessage: (value: string | undefined) => void
   setActiveMessage: (message: UserMessage | undefined) => void
   setTurnStart: (value: number) => void
-  autoScroll: { pause: () => void; forceScrollToBottom: () => void }
+  autoScroll: { pause: () => void; snapToBottom: () => void }
   scroller: () => HTMLDivElement | undefined
   anchor: (id: string) => string
   scheduleScrollState: (el: HTMLDivElement) => void
@@ -27,18 +26,13 @@ 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 (!location.hash) return
-    navigate(location.pathname + location.search, { replace: true })
+    if (!window.location.hash) return
+    window.history.replaceState(null, "", window.location.pathname + window.location.search)
   }
 
   const updateHash = (id: string) => {
-    navigate(location.pathname + location.search + `#${input.anchor(id)}`, {
-      replace: true,
-    })
+    window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${input.anchor(id)}`)
   }
 
   const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
@@ -47,15 +41,15 @@ export const useSessionHashScroll = (input: {
 
     const a = el.getBoundingClientRect()
     const b = root.getBoundingClientRect()
-    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)
+    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
     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
@@ -103,9 +97,9 @@ export const useSessionHashScroll = (input: {
   }
 
   const applyHash = (behavior: ScrollBehavior) => {
-    const hash = location.hash.slice(1)
+    const hash = window.location.hash.slice(1)
     if (!hash) {
-      input.autoScroll.forceScrollToBottom()
+      input.autoScroll.snapToBottom()
       const el = input.scroller()
       if (el) input.scheduleScrollState(el)
       return
@@ -129,13 +123,26 @@ export const useSessionHashScroll = (input: {
       return
     }
 
-    input.autoScroll.forceScrollToBottom()
+    input.autoScroll.snapToBottom()
     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"))
   })
@@ -159,7 +166,6 @@ export const useSessionHashScroll = (input: {
       }
     }
 
-    if (!targetId) targetId = messageIdFromHash(location.hash)
     if (!targetId) return
     if (input.currentMessageId() === targetId) return
 
@@ -171,12 +177,6 @@ export const useSessionHashScroll = (input: {
     requestAnimationFrame(() => scrollToMessage(msg, "auto"))
   })
 
-  onMount(() => {
-    if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
-      window.history.scrollRestoration = "manual"
-    }
-  })
-
   return {
     clearMessageHash,
     scrollToMessage,

+ 9 - 0
packages/app/src/utils/dom.ts

@@ -1,3 +1,12 @@
+export function isEditableTarget(target: EventTarget | null | undefined) {
+  if (!(target instanceof HTMLElement)) return false
+  if (/^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName)) return true
+  if (target.isContentEditable) return true
+  if (target.closest("[contenteditable='true']")) return true
+  if (target.closest("input, textarea, select")) return true
+  return false
+}
+
 export function getCharacterOffsetInLine(lineElement: Element, targetNode: Node, offset: number): number {
   const r = document.createRange()
   r.selectNodeContents(lineElement)

+ 0 - 6
packages/app/src/utils/same.ts

@@ -1,6 +0,0 @@
-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])
-}

+ 2 - 2
packages/storybook/.storybook/mocks/app/context/command.ts

@@ -4,8 +4,8 @@ const keybinds: Record<string, string> = {
   "prompt.mode.normal": "mod+shift+e",
   "permissions.autoaccept": "mod+shift+a",
   "agent.cycle": "mod+.",
-  "model.choose": "mod+m",
-  "model.variant.cycle": "mod+shift+m",
+  "model.choose": "mod+'",
+  "model.variant.cycle": "shift+mod+d",
 }
 
 export function useCommand() {

+ 3 - 0
packages/ui/happydom.ts

@@ -0,0 +1,3 @@
+import { GlobalRegistrator } from "@happy-dom/global-registrator"
+
+GlobalRegistrator.register()

+ 4 - 0
packages/ui/package.json

@@ -25,10 +25,14 @@
   },
   "scripts": {
     "typecheck": "tsgo --noEmit",
+    "test": "bun run test:unit",
+    "test:unit": "bun test --preload ./happydom.ts ./src/components",
+    "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src/components",
     "dev": "vite",
     "generate:tailwind": "bun run script/tailwind.ts"
   },
   "devDependencies": {
+    "@happy-dom/global-registrator": "20.0.11",
     "@tailwindcss/vite": "catalog:",
     "@tsconfig/node22": "catalog:",
     "@types/bun": "catalog:",

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

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

+ 27 - 2
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 = 600
+const DURATION = 800
 
 function normalize(value: number) {
   return ((value % 10) + 10) % 10
@@ -90,10 +90,35 @@ 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={digits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
+        <Index each={displayDigits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
       </span>
     </span>
   )

+ 122 - 0
packages/ui/src/components/animation-debug-panel.tsx

@@ -0,0 +1,122 @@
+import { createSignal } from "solid-js"
+import {
+  getGrowDuration,
+  getCollapsibleDuration,
+  setGrowDuration,
+  setCollapsibleDuration,
+} from "./motion"
+
+export function AnimationDebugPanel() {
+  const [grow, setGrow] = createSignal(getGrowDuration())
+  const [collapsible, setCollapsible] = createSignal(getCollapsibleDuration())
+  const [collapsed, setCollapsed] = createSignal(true)
+  const [dragging, setDragging] = createSignal(false)
+  const [pos, setPos] = createSignal({ x: 16, y: 16 })
+  let dragOffset = { x: 0, y: 0 }
+
+  const onPointerDown = (e: PointerEvent) => {
+    if ((e.target as HTMLElement).closest("button, input")) return
+    setDragging(true)
+    dragOffset = { x: e.clientX + pos().x, y: e.clientY + pos().y }
+    ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
+  }
+
+  const onPointerMove = (e: PointerEvent) => {
+    if (!dragging()) return
+    setPos({ x: dragOffset.x - e.clientX, y: dragOffset.y - e.clientY })
+  }
+
+  const onPointerUp = () => setDragging(false)
+
+  return (
+    <div
+      style={{
+        position: "fixed",
+        bottom: `${pos().y}px`,
+        right: `${pos().x}px`,
+        "z-index": "99999",
+        background: "rgba(0, 0, 0, 0.85)",
+        color: "#e0e0e0",
+        "border-radius": "8px",
+        "font-family": "monospace",
+        "font-size": "12px",
+        "box-shadow": "0 4px 20px rgba(0,0,0,0.4)",
+        "user-select": "none",
+        cursor: dragging() ? "grabbing" : "grab",
+        border: "1px solid rgba(255,255,255,0.1)",
+      }}
+      onPointerDown={onPointerDown}
+      onPointerMove={onPointerMove}
+      onPointerUp={onPointerUp}
+    >
+      <div
+        style={{
+          display: "flex",
+          "align-items": "center",
+          "justify-content": "space-between",
+          padding: "6px 10px",
+          "border-bottom": collapsed() ? "none" : "1px solid rgba(255,255,255,0.1)",
+          gap: "8px",
+        }}
+      >
+        <span style={{ "font-weight": "bold", "font-size": "11px", opacity: "0.7" }}>
+          springs
+        </span>
+        <button
+          style={{
+            background: "none",
+            border: "none",
+            color: "#e0e0e0",
+            cursor: "pointer",
+            padding: "0 2px",
+            "font-size": "14px",
+            "line-height": "1",
+          }}
+          onClick={() => setCollapsed((c) => !c)}
+        >
+          {collapsed() ? "+" : "\u2013"}
+        </button>
+      </div>
+      {!collapsed() && (
+        <div style={{ padding: "8px 10px", display: "flex", "flex-direction": "column", gap: "6px" }}>
+          <SliderRow
+            label="Grow"
+            value={grow()}
+            onChange={(v) => {
+              setGrow(v)
+              setGrowDuration(v)
+            }}
+          />
+          <SliderRow
+            label="Collapsible"
+            value={collapsible()}
+            onChange={(v) => {
+              setCollapsible(v)
+              setCollapsibleDuration(v)
+            }}
+          />
+        </div>
+      )}
+    </div>
+  )
+}
+
+function SliderRow(props: { label: string; value: number; onChange: (v: number) => void }) {
+  return (
+    <div style={{ display: "flex", "align-items": "center", gap: "8px" }}>
+      <span style={{ width: "72px", "font-size": "11px" }}>{props.label}</span>
+      <input
+        type="range"
+        min="0.05"
+        max="2.0"
+        step="0.05"
+        value={props.value}
+        onInput={(e) => props.onChange(parseFloat(e.currentTarget.value))}
+        style={{ width: "100px", cursor: "pointer" }}
+      />
+      <span style={{ width: "32px", "text-align": "right", "font-size": "11px" }}>
+        {props.value.toFixed(2)}
+      </span>
+    </div>
+  )
+}

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

@@ -8,54 +8,28 @@
   justify-content: flex-start;
 
   [data-slot="basic-tool-tool-trigger-content"] {
-    width: auto;
+    width: 100%;
+    min-width: 0;
     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: 0 1 auto;
+    flex: 1 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;
@@ -63,11 +37,12 @@
   }
 
   [data-slot="basic-tool-tool-info-main"] {
+    flex: 0 1 auto;
     display: flex;
-    align-items: baseline;
+    align-items: center;
     gap: 8px;
     min-width: 0;
-    overflow: hidden;
+    overflow: clip;
   }
 
   [data-slot="basic-tool-tool-title"] {
@@ -80,21 +55,14 @@
     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"] {
-    flex-shrink: 1;
+    display: inline-block;
+    flex: 0 1 auto;
+    max-width: 100%;
     min-width: 0;
-    overflow: hidden;
-    text-overflow: ellipsis;
+    overflow: clip;
     white-space: nowrap;
     font-family: var(--font-family-sans);
     font-size: 14px;
@@ -138,8 +106,7 @@
   [data-slot="basic-tool-tool-arg"] {
     flex-shrink: 1;
     min-width: 0;
-    overflow: hidden;
-    text-overflow: ellipsis;
+    overflow: clip;
     white-space: nowrap;
     font-family: var(--font-family-sans);
     font-size: 14px;

+ 0 - 133
packages/ui/src/components/basic-tool.stories.tsx

@@ -1,133 +0,0 @@
-// @ts-nocheck
-import { createSignal } from "solid-js"
-import * as mod from "./basic-tool"
-import { create } from "../storybook/scaffold"
-
-const docs = `### Overview
-Expandable tool panel with a structured trigger and optional details.
-
-Use structured triggers for consistent layout; custom triggers allowed.
-
-### API
-- Required: \`icon\` and \`trigger\` (structured or custom JSX).
-- Optional: \`status\`, \`defaultOpen\`, \`forceOpen\`, \`defer\`, \`locked\`.
-
-### Variants and states
-- Pending/running status animates the title via TextShimmer.
-
-### Behavior
-- Uses Collapsible; can defer content rendering until open.
-- Locked state prevents closing.
-
-### Accessibility
-- TODO: confirm trigger semantics and aria labeling.
-
-### Theming/tokens
-- Uses \`data-component="tool-trigger"\` and related slots.
-
-`
-
-const story = create({
-  title: "UI/Basic Tool",
-  mod,
-  args: {
-    icon: "mcp",
-    defaultOpen: true,
-    trigger: {
-      title: "Basic Tool",
-      subtitle: "Example subtitle",
-      args: ["--flag", "value"],
-    },
-    children: "Details content",
-  },
-})
-
-export default {
-  title: "UI/Basic Tool",
-  id: "components-basic-tool",
-  component: story.meta.component,
-  tags: ["autodocs"],
-  parameters: {
-    docs: {
-      description: {
-        component: docs,
-      },
-    },
-  },
-}
-
-export const Basic = story.Basic
-
-export const Pending = {
-  args: {
-    status: "pending",
-    trigger: {
-      title: "Running tool",
-      subtitle: "Working...",
-    },
-    children: "Progress details",
-  },
-}
-
-export const Locked = {
-  args: {
-    locked: true,
-    trigger: {
-      title: "Locked tool",
-      subtitle: "Cannot close",
-    },
-    children: "Locked details",
-  },
-}
-
-export const Deferred = {
-  args: {
-    defer: true,
-    defaultOpen: false,
-    trigger: {
-      title: "Deferred tool",
-      subtitle: "Content mounts on open",
-    },
-    children: "Deferred content",
-  },
-}
-
-export const ForceOpen = {
-  args: {
-    forceOpen: true,
-    trigger: {
-      title: "Forced open",
-      subtitle: "Cannot close",
-    },
-    children: "Forced content",
-  },
-}
-
-export const HideDetails = {
-  args: {
-    hideDetails: true,
-    trigger: {
-      title: "Summary only",
-      subtitle: "Details hidden",
-    },
-    children: "Hidden content",
-  },
-}
-
-export const SubtitleAction = {
-  render: () => {
-    const [message, setMessage] = createSignal("Subtitle not clicked")
-    return (
-      <div style={{ display: "grid", gap: "8px" }}>
-        <div style={{ "font-size": "12px", color: "var(--text-weak)" }}>{message()}</div>
-        <mod.BasicTool
-          icon="mcp"
-          trigger={{ title: "Clickable subtitle", subtitle: "Click me" }}
-          onSubtitleClick={() => setMessage("Subtitle clicked")}
-        >
-          Subtitle action details
-        </mod.BasicTool>
-      </div>
-    )
-  },
-}

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

@@ -1,8 +1,20 @@
-import { createEffect, createSignal, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
-import { animate, type AnimationPlaybackControls } from "motion"
+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 { Collapsible } from "./collapsible"
-import type { IconProps } from "./icon"
 import { TextShimmer } from "./text-shimmer"
+import { hold } from "./tool-utils"
 
 export type TriggerTitle = {
   title: string
@@ -20,26 +32,99 @@ const isTriggerTitle = (val: any): val is TriggerTitle => {
   )
 }
 
-export interface BasicToolProps {
-  icon: IconProps["name"]
+interface ToolCallPanelBaseProps {
+  icon: string
   trigger: TriggerTitle | JSX.Element
   children?: JSX.Element
   status?: string
+  animate?: boolean
   hideDetails?: boolean
   defaultOpen?: boolean
   forceOpen?: boolean
   defer?: boolean
   locked?: boolean
-  animated?: boolean
+  watchDetails?: boolean
+  springContent?: boolean
   onSubtitleClick?: () => void
 }
 
-const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
+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>
+  )
+}
 
-export function BasicTool(props: BasicToolProps) {
+function ToolCallPanel(props: ToolCallPanelBaseProps) {
   const [open, setOpen] = createSignal(props.defaultOpen ?? false)
   const [ready, setReady] = createSignal(open())
-  const pending = () => props.status === "pending" || props.status === "running"
+  const pendingRaw = () => props.status === "pending" || props.status === "running"
+  const pending = hold(pendingRaw, 1000)
+  const watchDetails = () => props.watchDetails !== false
 
   let frame: number | undefined
 
@@ -59,7 +144,7 @@ export function BasicTool(props: BasicToolProps) {
     on(
       open,
       (value) => {
-        if (!props.defer) return
+        if (!props.defer || props.springContent) return
         if (!value) {
           cancel()
           setReady(false)
@@ -77,36 +162,110 @@ export function BasicTool(props: BasicToolProps) {
     ),
   )
 
-  // Animated height for collapsible open/close
+  // Animated content height — single springValue drives all height changes
   let contentRef: HTMLDivElement | undefined
-  let heightAnim: AnimationPlaybackControls | undefined
+  let bodyRef: HTMLDivElement | undefined
+  let fadeAnim: AnimationPlaybackControls | undefined
+  let observer: ResizeObserver | undefined
+  let resizeFrame: number | 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.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)
-        }
+        if (!props.springContent || props.animate === false || !contentRef) return
+        if (isOpen) doOpen()
+        else doClose()
       },
       { defer: true },
     ),
   )
 
   onCleanup(() => {
-    heightAnim?.stop()
+    if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
+    observer?.disconnect()
+    fadeAnim?.stop()
+    heightSpring.destroy()
   })
 
   const handleOpenChange = (value: boolean) => {
@@ -118,85 +277,34 @@ export function BasicTool(props: BasicToolProps) {
   return (
     <Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
       <Collapsible.Trigger>
-        <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>
+        <ToolCallTriggerBody
+          trigger={props.trigger}
+          pending={pending()}
+          onSubtitleClick={props.onSubtitleClick}
+          arrow={!!props.children && !props.hideDetails && !props.locked && !pending()}
+        />
       </Collapsible.Trigger>
-      <Show when={props.animated && props.children && !props.hideDetails}>
+      <Show when={props.springContent && props.animate !== false && props.children && !props.hideDetails}>
         <div
           ref={contentRef}
           data-slot="collapsible-content"
-          data-animated
+          data-spring-content
           style={{
             height: initialOpen ? "auto" : "0px",
-            overflow: initialOpen ? "visible" : "hidden",
+            overflow: "hidden",
+            display: initialOpen ? undefined : "none",
           }}
         >
-          {props.children}
+          <div ref={bodyRef} data-slot="basic-tool-content-inner">
+            {props.children}
+          </div>
         </div>
       </Show>
-      <Show when={!props.animated && props.children && !props.hideDetails}>
+      <Show when={(!props.springContent || props.animate === false) && props.children && !props.hideDetails}>
         <Collapsible.Content>
-          <Show when={!props.defer || ready()}>{props.children}</Show>
+          <Show when={!props.defer || ready()}>
+            <div data-slot="basic-tool-content-inner">{props.children}</div>
+          </Show>
         </Collapsible.Content>
       </Show>
     </Collapsible>
@@ -222,6 +330,60 @@ 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
@@ -229,7 +391,8 @@ export function GenericTool(props: {
   input?: Record<string, unknown>
 }) {
   return (
-    <BasicTool
+    <ToolCall
+      variant={props.hideDetails ? "row" : "panel"}
       icon="mcp"
       status={props.status}
       trigger={{
@@ -237,7 +400,6 @@ export function GenericTool(props: {
         subtitle: label(props.input),
         args: args(props.input),
       }}
-      hideDetails={props.hideDetails}
     />
   )
 }

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

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

+ 311 - 0
packages/ui/src/components/composer-island.stories.tsx

@@ -0,0 +1,311 @@
+// @ts-nocheck
+import { createMemo, createSignal, onCleanup, onMount } from "solid-js"
+import { ComposerIsland } from "./composer-island"
+
+const docs = `### Overview
+Composer island with a runtime/service API.
+
+Use the same component in Storybook and app code by swapping the \`runtime\` object:
+- Storybook: mocked async handlers
+- App: SDK-backed handlers
+
+### Runtime API
+- \`submit\` / \`abort\`
+- \`searchAt\` / \`searchSlash\`
+- \`toggleAccept\`
+- \`submitQuestion\` / \`rejectQuestion\`
+- \`decidePermission\`
+`
+
+export default {
+  title: "UI/ComposerIsland",
+  id: "components-composer-island",
+  component: ComposerIsland,
+  tags: ["autodocs"],
+  parameters: {
+    docs: {
+      description: {
+        component: docs,
+      },
+    },
+  },
+}
+
+const questions = [
+  {
+    text: "Which editor do you use most often?",
+    options: [
+      { label: "Neovim (Recommended)", description: "Fast keyboard-driven workflow" },
+      { label: "VS Code", description: "Feature-rich and extensible" },
+      { label: "Zed", description: "Lightweight modern editor" },
+    ],
+    multiple: false,
+  },
+  {
+    text: "Which testing frameworks should we add?",
+    options: [
+      { label: "Vitest", description: "Fast unit testing" },
+      { label: "Playwright", description: "E2E browser testing" },
+      { label: "Testing Library", description: "Component testing" },
+    ],
+    multiple: true,
+  },
+]
+
+const at = [
+  { type: "file", path: "src/auth.ts", display: "src/auth.ts", recent: true },
+  { type: "file", path: "src/middleware.ts", display: "src/middleware.ts" },
+  { type: "file", path: "src/routes/login.ts", display: "src/routes/login.ts" },
+  { type: "agent", name: "coder", display: "coder" },
+  { type: "agent", name: "reviewer", display: "reviewer" },
+]
+
+const slash = [
+  {
+    id: "help",
+    trigger: "help",
+    title: "Help",
+    description: "Show available commands",
+    type: "builtin",
+    source: "command",
+  },
+  {
+    id: "clear",
+    trigger: "clear",
+    title: "Clear",
+    description: "Clear conversation",
+    type: "builtin",
+    source: "command",
+  },
+  {
+    id: "review",
+    trigger: "review",
+    title: "Review",
+    description: "Review code changes",
+    type: "custom",
+    source: "skill",
+  },
+  {
+    id: "test",
+    trigger: "test",
+    title: "Test",
+    description: "Run tests",
+    type: "custom",
+    source: "mcp",
+    keybind: "mod+t",
+  },
+]
+
+const todos = [
+  { content: "Read auth module", status: "completed" as const },
+  { content: "Refactor token logic", status: "in_progress" as const },
+  { content: "Add tests", status: "pending" as const },
+  { content: "Ship changes", status: "pending" as const },
+]
+
+const contexts = [
+  { id: "c1", path: "src/auth.ts", selection: { startLine: 5, endLine: 10 }, comment: "JWT expiry looks brittle" },
+  { id: "c2", path: "src/routes/login.ts", comment: "Rate limit should be stricter" },
+]
+
+const dragCycle = [null, "image", "@mention"] as const
+
+const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms))
+
+export const Interactive = () => {
+  const [mode, setMode] = createSignal<"input" | "question" | "permission">("input")
+  const [tab, setTab] = createSignal(0)
+  const [showTodos, setShowTodos] = createSignal(true)
+  const [todoCollapsed, setTodoCollapsed] = createSignal(false)
+  const [showContext, setShowContext] = createSignal(true)
+  const [dragIndex, setDragIndex] = createSignal(0)
+  const [working, setWorking] = createSignal(false)
+  const [accepting, setAccepting] = createSignal(false)
+  const [agent, setAgent] = createSignal("ask")
+  const [model, setModel] = createSignal("OpenAI/GPT-5.3 Codex")
+  const [variant, setVariant] = createSignal("default")
+  const [history, setHistory] = createSignal<{ normal: string[]; shell: string[] }>({ normal: [], shell: [] })
+
+  const q = createMemo(() => questions[tab()] ?? questions[0])
+  const drag = createMemo(() => dragCycle[dragIndex()] ?? null)
+
+  const runtime = {
+    submit: async (input) => {
+      setWorking(true)
+      await wait(600)
+      setWorking(false)
+      console.log("submit", input)
+    },
+    abort: () => {
+      setWorking(false)
+      console.log("abort")
+    },
+    toggleAccept: () => {
+      setAccepting((value) => !value)
+    },
+    runSlash: (cmd) => {
+      if (cmd.type !== "builtin") return false
+      console.log("slash:run", cmd.id)
+      return true
+    },
+    historyRead: (mode) => history()[mode],
+    historyWrite: (mode, list) => {
+      setHistory((prev) => ({ ...prev, [mode]: list }))
+    },
+    searchAt: async (filter: string) => {
+      await wait(120)
+      return at.filter((item) => {
+        const value = item.type === "agent" ? item.name : item.path
+        return value.toLowerCase().includes(filter.toLowerCase())
+      })
+    },
+    searchSlash: async (filter: string) => {
+      await wait(120)
+      return slash.filter((item) => item.trigger.toLowerCase().includes(filter.toLowerCase()))
+    },
+    decidePermission: async (response) => {
+      console.log("permission", response)
+      await wait(300)
+      setMode("input")
+    },
+    submitQuestion: async (answers) => {
+      console.log("question answers", answers)
+      await wait(300)
+      setMode("input")
+      setTab(0)
+    },
+    rejectQuestion: async () => {
+      await wait(150)
+      setMode("input")
+      setTab(0)
+    },
+    openContext: (item) => {
+      console.log("open context", item)
+    },
+    removeContext: (item) => {
+      console.log("remove context", item)
+    },
+  }
+
+  onMount(() => {
+    const onKey = (event: KeyboardEvent) => {
+      const target = event.target
+      if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) return
+      if (target instanceof HTMLElement && target.isContentEditable) return
+
+      if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return
+
+      let hit = false
+      if (event.key === "1") {
+        setMode("input")
+        hit = true
+      }
+      if (event.key === "2") {
+        setMode("question")
+        hit = true
+      }
+      if (event.key === "3") {
+        setMode("permission")
+        hit = true
+      }
+      if (event.key === "4") {
+        setTodoCollapsed((value) => !value)
+        hit = true
+      }
+      if (event.key === "5") {
+        setShowTodos((value) => !value)
+        hit = true
+      }
+      if (event.key === "6") {
+        setShowContext((value) => !value)
+        hit = true
+      }
+      if (event.key === "7") {
+        setDragIndex((value) => (value + 1) % dragCycle.length)
+        hit = true
+      }
+
+      if (!hit) return
+      event.preventDefault()
+      event.stopPropagation()
+    }
+
+    window.addEventListener("keydown", onKey)
+    onCleanup(() => window.removeEventListener("keydown", onKey))
+  })
+
+  return (
+    <div style={{ position: "fixed", left: 0, right: 0, bottom: 0, padding: "20px" }}>
+      <div
+        style={{
+          position: "fixed",
+          top: "16px",
+          left: "16px",
+          display: "flex",
+          gap: "8px",
+          "font-family": "monospace",
+          "font-size": "12px",
+          color: "rgba(255, 255, 255, 0.7)",
+        }}
+      >
+        <button onClick={() => setMode("input")}>Ctrl+1 Input</button>
+        <button onClick={() => setMode("question")}>Ctrl+2 Question</button>
+        <button onClick={() => setMode("permission")}>Ctrl+3 Permission</button>
+        <button onClick={() => setTodoCollapsed((value) => !value)}>Ctrl+4 Collapse</button>
+        <button onClick={() => setShowTodos((value) => !value)}>Ctrl+5 Todos</button>
+        <button onClick={() => setShowContext((value) => !value)}>Ctrl+6 Context</button>
+        <button onClick={() => setDragIndex((value) => (value + 1) % dragCycle.length)}>Ctrl+7 Drag</button>
+      </div>
+
+      <ComposerIsland
+        mode={mode()}
+        runtime={runtime}
+        agentOptions={["ask", "coder", "reviewer"]}
+        modelOptions={[
+          "OpenAI/GPT-5.2",
+          "OpenAI/GPT-5.3 Codex",
+          "OpenAI/GPT-5.3 Codex Spark",
+          "Anthropic/Claude Sonnet 4",
+          "Anthropic/Claude Haiku 4.5",
+          "Google/Gemini 2.5 Pro",
+        ]}
+        variantOptions={["default", "fast", "quality"]}
+        agentCurrent={agent()}
+        modelCurrent={model()}
+        variantCurrent={variant()}
+        onAgentSelect={setAgent}
+        onModelSelect={setModel}
+        onVariantSelect={setVariant}
+        agentKeybind="mod+."
+        modelKeybind="mod+'"
+        variantKeybind="shift+mod+d"
+        working={working()}
+        accepting={accepting()}
+        placeholder="Ask anything..."
+        questionText={q().text}
+        questionOptions={q().options}
+        questionMultiple={q().multiple}
+        questionIndex={tab()}
+        questionTotal={questions.length}
+        onQuestionBack={() => setTab((value) => Math.max(0, value - 1))}
+        onQuestionNext={() => setTab((value) => Math.min(questions.length - 1, value + 1))}
+        onQuestionJump={setTab}
+        onQuestionDismiss={() => {
+          setMode("input")
+          setTab(0)
+        }}
+        agentName="Ask"
+        modelName="GPT-4"
+        variant="default"
+        todos={todos}
+        showTodos={showTodos()}
+        todoCollapsed={todoCollapsed()}
+        onTodoCollapseChange={setTodoCollapsed}
+        contextItems={showContext() ? contexts : []}
+        forceDragType={drag()}
+        permissionDescription="Modify files, including edits, writes, patches, and multi-edits"
+        permissionPatterns={["src/**/*.ts", "tests/**/*.ts"]}
+      />
+    </div>
+  )
+}

+ 28 - 0
packages/ui/src/components/composer-island.tsx

@@ -0,0 +1,28 @@
+import type { Component } from "solid-js"
+import { NewComposer } from "./new-composer"
+import type { NewComposerProps } from "./new-composer"
+
+/**
+ * Composer Island is now backed by `NewComposer`.
+ *
+ * This keeps the existing import path (`@opencode-ai/ui/composer-island`) stable
+ * while using the split/runtime-ready architecture under the hood.
+ */
+export type ComposerIslandProps = NewComposerProps
+
+export const ComposerIsland: Component<ComposerIslandProps> = (props) => {
+  return <NewComposer {...props} />
+}
+
+export type {
+  AtOption,
+  ComposerMode,
+  ComposerPart,
+  ComposerRuntime,
+  ComposerSource,
+  ComposerSubmit,
+  ContextItem,
+  ImageAttachment,
+  SlashCommand,
+  TodoItem,
+} from "./new-composer/types"

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

@@ -0,0 +1,290 @@
+import { createEffect, createMemo, createSignal, For, on, onCleanup, onMount } from "solid-js"
+import type { ToolPart } from "@opencode-ai/sdk/v2"
+import { getFilename } from "@opencode-ai/util/path"
+import { useI18n } from "../context/i18n"
+import { prefersReducedMotion } from "../hooks/use-reduced-motion"
+import { ToolCall } from "./basic-tool"
+import { ToolStatusTitle } from "./tool-status-title"
+import { AnimatedCountList } from "./tool-count-summary"
+import { RollingResults } from "./rolling-results"
+import {
+  animate,
+  type AnimationPlaybackControls,
+  clearFadeStyles,
+  clearMaskStyles,
+  COLLAPSIBLE_SPRING,
+  GROW_SPRING,
+  WIPE_MASK,
+} from "./motion"
+import { useSpring } from "./motion-spring"
+import { busy } 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.pending && props.open}
+      showArrow={!props.pending}
+      onOpenChange={(v) => {
+        if (!props.pending) props.onOpenChange(v)
+      }}
+      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
+  let heightAnim: AnimationPlaybackControls | undefined
+  let fadeAnim: AnimationPlaybackControls | undefined
+
+  const FADE = 12
+
+  const updateMask = () => {
+    if (!scrollRef) return
+    const { scrollTop, scrollHeight, clientHeight } = scrollRef
+    const overflow = scrollHeight - clientHeight
+    if (overflow <= 1) {
+      scrollRef.style.maskImage = ""
+      scrollRef.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%)`
+            : ""
+    scrollRef.style.maskImage = mask
+    scrollRef.style.webkitMaskImage = mask
+  }
+
+  createEffect(
+    on(
+      () => props.expanded,
+      (isOpen) => {
+        if (!contentRef || !bodyRef) return
+        heightAnim?.stop()
+        fadeAnim?.stop()
+        if (isOpen) {
+          contentRef.style.display = ""
+          bodyRef.style.opacity = "0"
+          bodyRef.style.filter = "blur(2px)"
+          const h = bodyRef.getBoundingClientRect().height
+          heightAnim = animate(contentRef, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING)
+          fadeAnim = animate(bodyRef, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING)
+          heightAnim.finished
+            .catch(() => {})
+            .then(() => {
+              if (!contentRef || !props.expanded) return
+              contentRef.style.height = "auto"
+              updateMask()
+            })
+        } else {
+          const h = contentRef.getBoundingClientRect().height
+          heightAnim = animate(contentRef, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING)
+          fadeAnim = animate(bodyRef, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING)
+          heightAnim.finished
+            .catch(() => {})
+            .then(() => {
+              if (!contentRef || props.expanded) return
+              contentRef.style.display = "none"
+            })
+        }
+      },
+      { defer: true },
+    ),
+  )
+
+  onCleanup(() => {
+    heightAnim?.stop()
+    fadeAnim?.stop()
+  })
+
+  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 wiped = new Set<string>()
+  const [mounted, setMounted] = createSignal(false)
+  onMount(() => setMounted(true))
+  const reduce = prefersReducedMotion
+  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>()
+                createEffect(() => {
+                  const el = detailRef()
+                  const d = label().detail
+                  if (!el || !d) return
+                  if (wiped.has(k)) return
+                  wiped.add(k)
+                  if (reduce()) return
+                  el.style.maskImage = WIPE_MASK
+                  el.style.webkitMaskImage = WIPE_MASK
+                  el.style.maskSize = "240% 100%"
+                  el.style.webkitMaskSize = "240% 100%"
+                  el.style.maskRepeat = "no-repeat"
+                  el.style.webkitMaskRepeat = "no-repeat"
+                  el.style.maskPosition = "100% 0%"
+                  el.style.webkitMaskPosition = "100% 0%"
+                  animate(
+                    el,
+                    {
+                      opacity: [0, 1],
+                      filter: ["blur(2px)", "blur(0px)"],
+                      transform: ["translateX(-0.06em)", "translateX(0)"],
+                      maskPosition: "0% 0%",
+                    },
+                    GROW_SPRING,
+                  ).finished.then(() => {
+                    if (!el) return
+                    clearFadeStyles(el)
+                    clearMaskStyles(el)
+                  })
+                })
+                return (
+                  <span
+                    ref={setDetailRef}
+                    data-slot="context-tool-rolling-detail"
+                    style={{ display: label().detail ? undefined : "none" }}
+                  >
+                    {label().detail}
+                  </span>
+                )
+              })()}
+            </div>
+          )
+        }}
+      />
+    </div>
+  )
+}

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

@@ -0,0 +1,426 @@
+import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js"
+import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion"
+import { prefersReducedMotion } from "../hooks/use-reduced-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 = prefersReducedMotion
+  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 (!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")
+      })
+    }
+    if (watch()) {
+      observer = new ResizeObserver(() => {
+        if (!open()) return
+        if (resizeFrame !== undefined) return
+        resizeFrame = requestAnimationFrame(() => {
+          resizeFrame = undefined
+          setHeight("mount")
+        })
+      })
+      observer.observe(body)
+    }
+  })
+
+  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" }}
+    >
+      <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>
+  )
+}

+ 2 - 15
packages/ui/src/components/markdown.tsx

@@ -44,19 +44,6 @@ function sanitize(html: string) {
   return DOMPurify.sanitize(html, config)
 }
 
-function escape(text: string) {
-  return text
-    .replace(/&/g, "&amp;")
-    .replace(/</g, "&lt;")
-    .replace(/>/g, "&gt;")
-    .replace(/\"/g, "&quot;")
-    .replace(/'/g, "&#39;")
-}
-
-function fallback(markdown: string) {
-  return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
-}
-
 type CopyLabels = {
   copy: string
   copied: string
@@ -250,7 +237,7 @@ export function Markdown(
   const [html] = createResource(
     () => local.text,
     async (markdown) => {
-      if (isServer) return fallback(markdown)
+      if (isServer) return ""
 
       const hash = checksum(markdown)
       const key = local.cacheKey ?? hash
@@ -268,7 +255,7 @@ export function Markdown(
       if (key && hash) touch(key, { hash, html: safe })
       return safe
     },
-    { initialValue: isServer ? fallback(local.text) : "" },
+    { initialValue: "" },
   )
 
   let copySetupTimer: ReturnType<typeof setTimeout> | undefined

+ 316 - 84
packages/ui/src/components/message-part.css

@@ -27,6 +27,14 @@
     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;
@@ -35,6 +43,7 @@
     width: fit-content;
     max-width: min(82%, 64ch);
     margin-left: auto;
+    margin-bottom: 4px;
   }
 
   [data-slot="user-message-attachment"] {
@@ -134,7 +143,7 @@
 
   [data-slot="user-message-copy-wrapper"] {
     min-height: 24px;
-    margin-top: 4px;
+    margin-top: 0;
     display: flex;
     align-items: center;
     justify-content: flex-end;
@@ -144,7 +153,6 @@
     pointer-events: none;
     transition: opacity 0.15s ease;
     will-change: opacity;
-
     [data-component="tooltip-trigger"] {
       display: inline-flex;
       width: fit-content;
@@ -187,56 +195,21 @@
     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: 24px;
+  margin-top: 0;
+  padding-bottom: 8px;
+  position: relative;
 
   [data-slot="text-part-body"] {
     margin-top: 0;
   }
 
-  [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] {
+  [data-slot="text-part-turn-summary"] {
     width: 100%;
-    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;
+    min-width: 0;
   }
 
   [data-component="markdown"] {
@@ -278,7 +251,6 @@
   line-height: var(--line-height-normal);
 
   [data-component="markdown"] {
-    margin-top: 24px;
     font-style: normal;
     font-size: inherit;
     color: var(--text-weak);
@@ -372,13 +344,16 @@
     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;
     }
@@ -448,7 +423,7 @@
 [data-component="write-trigger"] {
   display: flex;
   align-items: center;
-  justify-content: space-between;
+  justify-content: flex-start;
   gap: 8px;
   width: 100%;
 
@@ -461,7 +436,8 @@
   }
 
   [data-slot="message-part-title"] {
-    flex-shrink: 0;
+    flex-shrink: 1;
+    min-width: 0;
     display: flex;
     align-items: center;
     gap: 8px;
@@ -493,40 +469,45 @@
   [data-slot="message-part-title-text"] {
     text-transform: capitalize;
     color: var(--text-strong);
+    flex-shrink: 0;
   }
 
-  [data-slot="message-part-title-filename"] {
-    /* No text-transform - preserve original filename casing */
+  [data-slot="message-part-meta-line"],
+  .message-part-meta-line {
+    min-width: 0;
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
     font-weight: var(--font-weight-regular);
+
+    [data-component="diff-changes"] {
+      flex-shrink: 0;
+      gap: 6px;
+    }
   }
 
-  [data-slot="message-part-path"] {
-    display: flex;
-    flex-grow: 1;
-    min-width: 0;
-    font-weight: var(--font-weight-regular);
+  .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-directory"] {
+  [data-slot="message-part-directory-inline"] {
     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"] {
@@ -617,6 +598,17 @@
   }
 }
 
+[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;
@@ -639,7 +631,6 @@
 }
 
 [data-component="context-tool-group-trigger"] {
-  width: 100%;
   min-height: 24px;
   display: flex;
   align-items: center;
@@ -647,28 +638,237 @@
   gap: 0px;
   cursor: pointer;
 
+  &[data-pending] {
+    cursor: default;
+  }
+
   [data-slot="context-tool-group-title"] {
     flex-shrink: 1;
     min-width: 0;
   }
+}
 
-  [data-slot="collapsible-arrow"] {
-    color: var(--icon-weaker);
+/* 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-component="context-tool-step"] {
+  width: 100%;
+  min-width: 0;
+  padding-left: 12px;
+}
+
+[data-component="context-tool-expanded-list"] {
+  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;
+
+  &::-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;
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    font-size: var(--font-size-base);
+    color: var(--text-base);
+    opacity: 0.75;
+  }
+}
+
+[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="context-tool-group-list"] {
-  padding: 6px 0 4px 0;
+[data-component="shell-rolling-results"] {
+  width: 100%;
+  min-width: 0;
   display: flex;
   flex-direction: column;
-  gap: 2px;
 
-  [data-slot="context-tool-group-item"] {
+  [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;
-    padding: 6px 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-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;
@@ -729,6 +929,30 @@
   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;
@@ -1187,8 +1411,7 @@
     position: sticky;
     top: var(--sticky-accordion-top, 0px);
     z-index: 20;
-    height: 40px;
-    padding-bottom: 8px;
+    height: 37px;
     background-color: var(--background-stronger);
   }
 }
@@ -1199,11 +1422,12 @@
   }
 
   [data-slot="apply-patch-trigger-content"] {
-    display: flex;
+    display: inline-flex;
     align-items: center;
-    justify-content: space-between;
-    width: 100%;
-    gap: 20px;
+    justify-content: flex-start;
+    max-width: 100%;
+    min-width: 0;
+    gap: 8px;
   }
 
   [data-slot="apply-patch-file-info"] {
@@ -1237,9 +1461,9 @@
   [data-slot="apply-patch-trigger-actions"] {
     flex-shrink: 0;
     display: flex;
-    gap: 16px;
+    gap: 8px;
     align-items: center;
-    justify-content: flex-end;
+    justify-content: flex-start;
   }
 
   [data-slot="apply-patch-change"] {
@@ -1279,10 +1503,11 @@
 }
 
 [data-component="tool-loaded-file"] {
+  min-width: 0;
   display: flex;
   align-items: center;
   gap: 8px;
-  padding: 4px 0 4px 28px;
+  padding: 4px 0 4px 12px;
   font-family: var(--font-family-sans);
   font-size: var(--font-size-small);
   font-weight: var(--font-weight-regular);
@@ -1293,4 +1518,11 @@
     flex-shrink: 0;
     color: var(--icon-weak);
   }
+
+  span {
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
 }

File diff ditekan karena terlalu besar
+ 402 - 619
packages/ui/src/components/message-part.tsx


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

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

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

@@ -0,0 +1,77 @@
+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 = ""
+}

+ 451 - 0
packages/ui/src/components/new-composer.stories.tsx

@@ -0,0 +1,451 @@
+// @ts-nocheck
+import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
+import { NewComposer } from "./new-composer"
+
+const docs = `### Overview
+Runtime-ready composer story with visible canvas controls + Storybook controls.
+
+### Canvas controls
+- Input / Question / Permission mode
+- Todos, collapse, context, drag overlay
+- Keyboard: Ctrl+1..7 toggles, Ctrl+8/9 answer count (Q1), Ctrl+[/] answer count (Q2), Ctrl+;/' answer count (Q3), Ctrl+0/- todo count
+
+### Storybook controls
+- mode
+- working
+- accepting
+- showTodos
+- todoCollapsed
+- forceDragType
+- answerCount1
+- answerCount2
+- answerCount3
+- todoCount
+`
+
+export default {
+  title: "UI/NewComposer",
+  id: "components-new-composer",
+  component: NewComposer,
+  tags: ["autodocs"],
+  parameters: {
+    docs: {
+      description: {
+        component: docs,
+      },
+    },
+  },
+  argTypes: {
+    mode: {
+      control: "select",
+      options: ["input", "question", "permission"],
+    },
+    working: {
+      control: "boolean",
+    },
+    accepting: {
+      control: "boolean",
+    },
+    showTodos: {
+      control: "boolean",
+    },
+    todoCollapsed: {
+      control: "boolean",
+    },
+    forceDragType: {
+      control: "select",
+      options: [null, "image", "@mention"],
+    },
+    answerCount1: {
+      control: { type: "range", min: 1, max: 6, step: 1 },
+    },
+    answerCount2: {
+      control: { type: "range", min: 1, max: 6, step: 1 },
+    },
+    answerCount3: {
+      control: { type: "range", min: 1, max: 6, step: 1 },
+    },
+    todoCount: {
+      control: { type: "range", min: 0, max: 8, step: 1 },
+    },
+  },
+}
+
+const questions = [
+  {
+    text: "Which editor do you use most often?",
+    options: [
+      { label: "Neovim", description: "Fast keyboard-driven workflow" },
+      { label: "VS Code", description: "Feature-rich and extensible" },
+      { label: "Zed", description: "Lightweight modern editor" },
+      { label: "JetBrains IDE", description: "Deep language tooling" },
+      { label: "Sublime Text", description: "Fast, lightweight setup" },
+      { label: "Helix", description: "Modal editor with tree-sitter" },
+    ],
+    multiple: false,
+  },
+  {
+    text: "Which testing frameworks should we add?",
+    options: [
+      { label: "Vitest", description: "Fast unit testing" },
+      { label: "Playwright", description: "E2E browser testing" },
+      { label: "Testing Library", description: "Component testing" },
+      { label: "Cypress", description: "Browser integration tests" },
+      { label: "Jest", description: "Legacy compatibility" },
+      { label: "Storybook Tests", description: "Interaction smoke tests" },
+    ],
+    multiple: true,
+  },
+  {
+    text: "How strict should linting be?",
+    options: [
+      { label: "Minimal", description: "Keep only important rules" },
+      { label: "Balanced", description: "Recommended defaults" },
+      { label: "Strict", description: "Catch everything possible" },
+      { label: "Very strict", description: "Treat warnings as errors" },
+      { label: "Preset by package", description: "Different rules per surface" },
+      { label: "Experimental", description: "Try strict mode for one sprint" },
+    ],
+    multiple: false,
+  },
+]
+
+const todos = [
+  { content: "Read auth module", status: "completed" },
+  { content: "Refactor token logic", status: "in_progress" },
+  { content: "Add tests", status: "pending" },
+  { content: "Ship changes", status: "pending" },
+  { content: "Check telemetry events", status: "pending" },
+  { content: "Validate markdown rendering", status: "pending" },
+  { content: "Run regression suite", status: "pending" },
+  { content: "Update release notes", status: "pending" },
+]
+
+const ctx = [
+  { id: "a", path: "src/auth.ts", selection: { startLine: 5, endLine: 10 }, comment: "Check token refresh" },
+  { id: "b", path: "src/routes/login.ts", comment: "Review edge cases" },
+]
+
+const dragModes = [null, "image", "@mention"] as const
+
+const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms))
+
+const panel = {
+  position: "fixed",
+  top: "14px",
+  left: "14px",
+  display: "flex",
+  "flex-wrap": "wrap",
+  gap: "8px",
+  padding: "8px",
+  "border-radius": "10px",
+  background: "rgba(0, 0, 0, 0.55)",
+  border: "1px solid rgba(255, 255, 255, 0.12)",
+  color: "#fff",
+  "font-family": "monospace",
+  "font-size": "11px",
+  "z-index": 50,
+} as const
+
+const btn = (on: boolean) =>
+  ({
+    padding: "4px 8px",
+    "border-radius": "8px",
+    border: "1px solid rgba(255, 255, 255, 0.2)",
+    background: on ? "rgba(255, 255, 255, 0.2)" : "rgba(255, 255, 255, 0.07)",
+    color: "#fff",
+    cursor: "pointer",
+  }) as const
+
+export const Interactive = {
+  args: {
+    mode: "input",
+    working: false,
+    accepting: false,
+    showTodos: true,
+    todoCollapsed: false,
+    forceDragType: null,
+    answerCount1: 4,
+    answerCount2: 3,
+    answerCount3: 5,
+    todoCount: 4,
+  },
+  render: (args) => {
+    const limit = (value: number, i: number) => Math.max(1, Math.min(questions[i].options.length, value))
+
+    const [mode, setMode] = createSignal(args.mode ?? "input")
+    const [tab, setTab] = createSignal(0)
+    const [work, setWork] = createSignal(!!args.working)
+    const [accept, setAccept] = createSignal(!!args.accepting)
+    const [showTodos, setShowTodos] = createSignal(args.showTodos ?? true)
+    const [todoCollapsed, setTodoCollapsed] = createSignal(args.todoCollapsed ?? false)
+    const [drag, setDrag] = createSignal(args.forceDragType ?? null)
+    const [a1, setA1] = createSignal(limit(args.answerCount1 ?? 4, 0))
+    const [a2, setA2] = createSignal(limit(args.answerCount2 ?? 3, 1))
+    const [a3, setA3] = createSignal(limit(args.answerCount3 ?? 5, 2))
+    const [tCount, setTCount] = createSignal(Math.max(0, Math.min(todos.length, args.todoCount ?? 4)))
+    const [showCtx, setShowCtx] = createSignal(true)
+    const [agent, setAgent] = createSignal("ask")
+    const [model, setModel] = createSignal("OpenAI/GPT-5.3 Codex")
+    const [variant, setVariant] = createSignal("default")
+    const [history, setHistory] = createSignal<{ normal: string[]; shell: string[] }>({ normal: [], shell: [] })
+
+    createEffect(() => setMode(args.mode ?? "input"))
+    createEffect(() => setWork(!!args.working))
+    createEffect(() => setAccept(!!args.accepting))
+    createEffect(() => setShowTodos(args.showTodos ?? true))
+    createEffect(() => setTodoCollapsed(args.todoCollapsed ?? false))
+    createEffect(() => setDrag(args.forceDragType ?? null))
+    createEffect(() => setA1(limit(args.answerCount1 ?? 4, 0)))
+    createEffect(() => setA2(limit(args.answerCount2 ?? 3, 1)))
+    createEffect(() => setA3(limit(args.answerCount3 ?? 5, 2)))
+    createEffect(() => setTCount(Math.max(0, Math.min(todos.length, args.todoCount ?? 4))))
+
+    const qList = createMemo(() => [
+      { ...questions[0], options: questions[0].options.slice(0, a1()) },
+      { ...questions[1], options: questions[1].options.slice(0, a2()) },
+      { ...questions[2], options: questions[2].options.slice(0, a3()) },
+    ])
+    const tList = createMemo(() => todos.slice(0, tCount()))
+
+    createEffect(() => {
+      if (tab() <= qList().length - 1) return
+      setTab(qList().length - 1)
+    })
+
+    const q = createMemo(() => qList()[tab()] ?? qList()[0])
+
+    const runtime = {
+      submit: async (input) => {
+        setWork(true)
+        await wait(550)
+        setWork(false)
+        console.log("submit", input)
+      },
+      abort: () => {
+        setWork(false)
+      },
+      toggleAccept: () => setAccept((v) => !v),
+      runSlash: (cmd) => {
+        if (cmd.type !== "builtin") return false
+        console.log("slash:run", cmd.id)
+        return true
+      },
+      historyRead: (mode) => history()[mode],
+      historyWrite: (mode, list) => {
+        setHistory((prev) => ({ ...prev, [mode]: list }))
+      },
+      decidePermission: async (response) => {
+        console.log("permission", response)
+        await wait(200)
+        setMode("input")
+      },
+      submitQuestion: async (answers) => {
+        console.log("question", answers)
+        await wait(250)
+        setMode("input")
+        setTab(0)
+      },
+      rejectQuestion: async () => {
+        await wait(150)
+        setMode("input")
+        setTab(0)
+      },
+      openContext: (item) => console.log("open context", item),
+      removeContext: (item) => console.log("remove context", item),
+    }
+
+    const cycle = () => {
+      const i = dragModes.findIndex((v) => v === drag())
+      const n = (i + 1) % dragModes.length
+      setDrag(dragModes[n])
+    }
+
+    onMount(() => {
+      const onKey = (event: KeyboardEvent) => {
+        const target = event.target
+        if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) return
+        if (target instanceof HTMLElement && target.isContentEditable) return
+
+        if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return
+
+        let hit = false
+        if (event.key === "1") {
+          setMode("input")
+          hit = true
+        }
+        if (event.key === "2") {
+          setMode("question")
+          hit = true
+        }
+        if (event.key === "3") {
+          setMode("permission")
+          hit = true
+        }
+        if (event.key === "4") {
+          setTodoCollapsed((v) => !v)
+          hit = true
+        }
+        if (event.key === "5") {
+          setShowTodos((v) => !v)
+          hit = true
+        }
+        if (event.key === "6") {
+          setShowCtx((v) => !v)
+          hit = true
+        }
+        if (event.key === "7") {
+          cycle()
+          hit = true
+        }
+        if (event.key === "8") {
+          setA1((v) => limit(v - 1, 0))
+          hit = true
+        }
+        if (event.key === "9") {
+          setA1((v) => limit(v + 1, 0))
+          hit = true
+        }
+        if (event.key === "[") {
+          setA2((v) => limit(v - 1, 1))
+          hit = true
+        }
+        if (event.key === "]") {
+          setA2((v) => limit(v + 1, 1))
+          hit = true
+        }
+        if (event.key === ";") {
+          setA3((v) => limit(v - 1, 2))
+          hit = true
+        }
+        if (event.key === "'") {
+          setA3((v) => limit(v + 1, 2))
+          hit = true
+        }
+        if (event.key === "0") {
+          setTCount((v) => Math.max(0, v - 1))
+          hit = true
+        }
+        if (event.key === "-") {
+          setTCount((v) => Math.min(todos.length, v + 1))
+          hit = true
+        }
+
+        if (!hit) return
+        event.preventDefault()
+        event.stopPropagation()
+      }
+
+      window.addEventListener("keydown", onKey)
+      onCleanup(() => window.removeEventListener("keydown", onKey))
+    })
+
+    return (
+      <div style={{ position: "fixed", left: 0, right: 0, bottom: 0, padding: "20px" }}>
+        <div style={panel}>
+          <button style={btn(mode() === "input")} onClick={() => setMode("input")}>
+            Ctrl+1 Input
+          </button>
+          <button style={btn(mode() === "question")} onClick={() => setMode("question")}>
+            Ctrl+2 Question
+          </button>
+          <button style={btn(mode() === "permission")} onClick={() => setMode("permission")}>
+            Ctrl+3 Permission
+          </button>
+          <button style={btn(showTodos())} onClick={() => setShowTodos((v) => !v)}>
+            Ctrl+5 Todos
+          </button>
+          <button style={btn(todoCollapsed())} onClick={() => setTodoCollapsed((v) => !v)}>
+            Ctrl+4 Collapse
+          </button>
+          <button style={btn(showCtx())} onClick={() => setShowCtx((v) => !v)}>
+            Ctrl+6 Context
+          </button>
+          <button style={btn(!!drag())} onClick={cycle}>
+            Ctrl+7 Drag: {drag() ?? "off"}
+          </button>
+          <button style={btn(work())} onClick={() => setWork((v) => !v)}>
+            Working
+          </button>
+          <button style={btn(accept())} onClick={() => setAccept((v) => !v)}>
+            Auto-accept
+          </button>
+          <button style={btn(false)} onClick={() => setA1((v) => limit(v - 1, 0))}>
+            Ctrl+8 Q1-
+          </button>
+          <span>Q1:{a1()}</span>
+          <button style={btn(false)} onClick={() => setA1((v) => limit(v + 1, 0))}>
+            Ctrl+9 Q1+
+          </button>
+          <button style={btn(false)} onClick={() => setA2((v) => limit(v - 1, 1))}>
+            Ctrl+[ Q2-
+          </button>
+          <span>Q2:{a2()}</span>
+          <button style={btn(false)} onClick={() => setA2((v) => limit(v + 1, 1))}>
+            Ctrl+] Q2+
+          </button>
+          <button style={btn(false)} onClick={() => setA3((v) => limit(v - 1, 2))}>
+            Ctrl+; Q3-
+          </button>
+          <span>Q3:{a3()}</span>
+          <button style={btn(false)} onClick={() => setA3((v) => limit(v + 1, 2))}>
+            Ctrl+' Q3+
+          </button>
+          <button style={btn(false)} onClick={() => setTCount((v) => Math.max(0, v - 1))}>
+            Ctrl+0 T-
+          </button>
+          <span>T:{tCount()}</span>
+          <button style={btn(false)} onClick={() => setTCount((v) => Math.min(todos.length, v + 1))}>
+            Ctrl+- T+
+          </button>
+        </div>
+
+        <NewComposer
+          mode={mode()}
+          runtime={runtime}
+          agentOptions={["ask", "coder", "reviewer"]}
+          modelOptions={[
+            "OpenAI/GPT-5.2",
+            "OpenAI/GPT-5.3 Codex",
+            "OpenAI/GPT-5.3 Codex Spark",
+            "Anthropic/Claude Sonnet 4",
+            "Anthropic/Claude Haiku 4.5",
+            "Google/Gemini 2.5 Pro",
+          ]}
+          variantOptions={["default", "fast", "quality"]}
+          agentCurrent={agent()}
+          modelCurrent={model()}
+          variantCurrent={variant()}
+          onAgentSelect={setAgent}
+          onModelSelect={setModel}
+          onVariantSelect={setVariant}
+          agentKeybind="mod+."
+          modelKeybind="mod+'"
+          variantKeybind="shift+mod+d"
+          working={work()}
+          accepting={accept()}
+          questionText={q()?.text ?? "Which editor do you use most often?"}
+          questionOptions={q()?.options ?? []}
+          questionMultiple={q()?.multiple ?? false}
+          questionIndex={tab()}
+          questionTotal={qList().length}
+          onQuestionBack={() => setTab((v) => Math.max(0, v - 1))}
+          onQuestionNext={() => setTab((v) => Math.min(qList().length - 1, v + 1))}
+          onQuestionJump={setTab}
+          onQuestionDismiss={() => {
+            setMode("input")
+            setTab(0)
+          }}
+          showTodos={showTodos()}
+          todoCollapsed={todoCollapsed()}
+          onTodoCollapseChange={setTodoCollapsed}
+          todos={tList()}
+          contextItems={showCtx() ? ctx : []}
+          forceDragType={drag()}
+          permissionDescription="This action needs write access to project files."
+          permissionPatterns={["src/**/*.ts", "tests/**/*.ts"]}
+        />
+      </div>
+    )
+  },
+}

+ 1097 - 0
packages/ui/src/components/new-composer.tsx

@@ -0,0 +1,1097 @@
+import {
+  Match,
+  Show,
+  Switch,
+  createEffect,
+  createMemo,
+  createSignal,
+  on,
+  onCleanup,
+  onMount,
+  type Component,
+} from "solid-js"
+import { useElementHeight } from "@opencode-ai/ui/hooks"
+import { useI18n } from "../context/i18n"
+import { ComposerDragOverlay } from "./new-composer/drag-overlay"
+import { DEFAULT_AT_OPTIONS, DEFAULT_SLASH_COMMANDS } from "./new-composer/defaults"
+import { ComposerInputLayer } from "./new-composer/input-layer"
+import { PermissionBody } from "./new-composer/permission-body"
+import { ComposerPopover } from "./new-composer/popover"
+import { QuestionBody, QuestionSizer } from "./new-composer/question-body"
+import { ComposerTodoTray } from "./new-composer/todo-tray"
+import { ComposerTray } from "./new-composer/tray"
+import type { ComposerSource, ContextItem, ImageAttachment, NewComposerProps } from "./new-composer/types"
+import { ACCEPTED_FILE_TYPES } from "./new-composer/types"
+import { useEditor } from "./new-composer/use-editor"
+import { useLayout } from "./new-composer/use-layout"
+import { useTodo } from "./new-composer/use-todo"
+import { showToast } from "./toast"
+
+const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
+
+const normalize = (key: string, code?: string) => {
+  if (code === "Quote") return "'"
+  if (key === ",") return "comma"
+  if (key === "+") return "plus"
+  if (key === " ") return "space"
+  if (key === "Dead" && code === "Quote") return "'"
+  return key.toLowerCase()
+}
+
+const match = (config: string | undefined, event: KeyboardEvent) => {
+  if (!config || config === "none") return false
+  const key = normalize(event.key, event.code)
+
+  const combos = config.split(",")
+  for (const combo of combos) {
+    const kb = {
+      key: "",
+      ctrl: false,
+      meta: false,
+      shift: false,
+      alt: false,
+    }
+
+    for (const part of combo.trim().toLowerCase().split("+")) {
+      if (part === "ctrl" || part === "control") {
+        kb.ctrl = true
+        continue
+      }
+      if (part === "meta" || part === "cmd" || part === "command") {
+        kb.meta = true
+        continue
+      }
+      if (part === "mod") {
+        if (IS_MAC) kb.meta = true
+        else kb.ctrl = true
+        continue
+      }
+      if (part === "alt" || part === "option") {
+        kb.alt = true
+        continue
+      }
+      if (part === "shift") {
+        kb.shift = true
+        continue
+      }
+      kb.key = part
+    }
+
+    if (
+      kb.key === key &&
+      kb.ctrl === !!event.ctrlKey &&
+      kb.meta === !!event.metaKey &&
+      kb.shift === !!event.shiftKey &&
+      kb.alt === !!event.altKey
+    ) {
+      return true
+    }
+  }
+
+  return false
+}
+
+/**
+ * New Composer — split architecture version of composer island.
+ */
+export const NewComposer: Component<NewComposerProps> = (props) => {
+  const i18n = useI18n()
+  const [answers, setAnswers] = createSignal<Record<number, string[]>>(
+    (props.questionAnswers ?? []).reduce<Record<number, string[]>>((map, row, i) => {
+      map[i] = [...row]
+      return map
+    }, {}),
+  )
+  const [customs, setCustoms] = createSignal<Record<number, string>>({})
+  const [customOns, setCustomOns] = createSignal<Record<number, boolean>>({})
+  const [sending, setSending] = createSignal(false)
+  const [questionSending, setQuestionSending] = createSignal(false)
+  const [permissionSending, setPermissionSending] = createSignal(false)
+  const [accept, setAccept] = createSignal(props.accepting ?? false)
+  const [modelTick, setModelTick] = createSignal(0)
+  const [images, setImages] = createSignal<ImageAttachment[]>([])
+  const [contexts, setContexts] = createSignal<ContextItem[]>(props.contextItems ?? [])
+  const [drag, setDrag] = createSignal<"image" | "@mention" | null>(null)
+  let id = 0
+  let editor: HTMLDivElement | undefined
+  let input: HTMLInputElement | undefined
+  const pick = () => input?.click()
+
+  const isQuestion = () => props.mode === "question"
+  const isPermission = () => props.mode === "permission"
+  const isMulti = () => props.questionMultiple ?? false
+  const working = createMemo(() => props.working ?? sending())
+  const accepting = createMemo(() => props.accepting ?? accept())
+  const questionBusy = createMemo(() => props.questionBusy ?? questionSending())
+  const permissionBusy = createMemo(() => props.permissionBusy ?? permissionSending())
+  const tab = () => props.questionIndex ?? 0
+
+  const mapFromList = (list: string[][] | undefined) => {
+    const map: Record<number, string[]> = {}
+    if (!list) return map
+    list.forEach((row, i) => {
+      if (!row) return
+      map[i] = [...row]
+    })
+    return map
+  }
+
+  const listFromMap = (map: Record<number, string[]>) => {
+    const keys = Object.keys(map).map(Number)
+    const count = Math.max(props.questionTotal ?? 0, keys.length > 0 ? Math.max(...keys) + 1 : 0)
+    return Array.from({ length: count }, (_, i) => map[i] ?? [])
+  }
+
+  const setAnswerMap = (
+    next: Record<number, string[]> | ((prev: Record<number, string[]>) => Record<number, string[]>),
+  ) => {
+    setAnswers((prev) => {
+      const map = typeof next === "function" ? next(prev) : next
+      props.onQuestionAnswersChange?.(listFromMap(map))
+      return map
+    })
+  }
+
+  const selected = createMemo(() => answers()[tab()] ?? [])
+  const custom = createMemo(() => customs()[tab()] ?? "")
+  const customOn = createMemo(() => customOns()[tab()] ?? false)
+  const answered = createMemo(() => {
+    if (props.questionAnswered) return props.questionAnswered
+    const total = props.questionTotal ?? 0
+    const map = answers()
+    const text = customs()
+    const flags = customOns()
+    return Array.from({ length: total }, (_, i) => {
+      if ((map[i]?.length ?? 0) > 0) return true
+      if (flags[i] === true && (text[i] ?? "").trim().length > 0) return true
+      return false
+    })
+  })
+  const activeDrag = createMemo(() => props.forceDragType ?? drag())
+
+  const [trayRef, setTrayRef] = createSignal<HTMLDivElement>()
+  const trayHeight = useElementHeight(trayRef, 42)
+  const trayOverlap = 14
+
+  createEffect(
+    on(
+      () => props.questionAnswers,
+      (next) => {
+        if (next === undefined) return
+        setAnswers(mapFromList(next))
+      },
+      { defer: true },
+    ),
+  )
+
+  createEffect(
+    on(
+      () => props.contextItems,
+      (next) => {
+        if (next) setContexts(next)
+      },
+    ),
+  )
+
+  createEffect(
+    on(
+      () => props.accepting,
+      (next) => {
+        if (next === undefined) return
+        setAccept(next)
+      },
+      { defer: true },
+    ),
+  )
+
+  createEffect(() => {
+    const i = tab()
+    const list = answers()[i] ?? []
+    const options = new Set((props.questionOptions ?? []).map((item) => item.label.trim()))
+    const free = list.find((item) => !options.has(item.trim())) ?? ""
+
+    setCustoms((map) => {
+      if ((map[i] ?? "") === free) return map
+      return { ...map, [i]: free }
+    })
+
+    const hasOption = list.some((item) => options.has(item.trim()))
+    setCustomOns((map) => {
+      const prev = map[i] ?? false
+      const next = free ? true : hasOption ? false : prev
+      if (prev === next) return map
+      return { ...map, [i]: next }
+    })
+  })
+
+  const toggleAccept = () => {
+    if (props.runtime?.toggleAccept) {
+      void Promise.resolve()
+        .then(() => props.runtime?.toggleAccept?.())
+        .catch(() => {})
+      return
+    }
+    if (props.onAcceptToggle) {
+      void Promise.resolve()
+        .then(() => props.onAcceptToggle?.())
+        .catch(() => {})
+      return
+    }
+    setAccept((value) => !value)
+  }
+
+  const cycle = (list: string[] | undefined, curr: string | undefined, pick?: (value: string) => void) => {
+    if (!list || list.length === 0 || !pick) return
+    const i = curr ? list.findIndex((item) => item === curr) : -1
+    const next = i < 0 ? 0 : (i + 1) % list.length
+    const value = list[next]
+    if (!value) return
+    pick(value)
+  }
+
+  const openModel = () => {
+    if (props.runtime?.openModel) {
+      void Promise.resolve()
+        .then(() => props.runtime?.openModel?.())
+        .catch(() => {})
+      return
+    }
+    if (props.onModelOpen) {
+      void Promise.resolve()
+        .then(() => props.onModelOpen?.())
+        .catch(() => {})
+      return
+    }
+    setModelTick((value) => value + 1)
+  }
+
+  const cycleAgent = () => {
+    if (props.runtime?.cycleAgent) {
+      void Promise.resolve()
+        .then(() => props.runtime?.cycleAgent?.())
+        .catch(() => {})
+      return
+    }
+    if (props.onAgentCycle) {
+      void Promise.resolve()
+        .then(() => props.onAgentCycle?.())
+        .catch(() => {})
+      return
+    }
+    cycle(props.agentOptions, props.agentCurrent ?? props.agentName, props.onAgentSelect)
+  }
+
+  const cycleVariant = () => {
+    if (props.runtime?.cycleVariant) {
+      void Promise.resolve()
+        .then(() => props.runtime?.cycleVariant?.())
+        .catch(() => {})
+      return
+    }
+    if (props.onVariantCycle) {
+      void Promise.resolve()
+        .then(() => props.onVariantCycle?.())
+        .catch(() => {})
+      return
+    }
+    cycle(props.variantOptions, props.variantCurrent ?? props.variant, props.onVariantSelect)
+  }
+
+  onMount(() => {
+    const onKey = (event: KeyboardEvent) => {
+      const target = event.target
+      if (
+        target instanceof HTMLInputElement ||
+        target instanceof HTMLTextAreaElement ||
+        (target instanceof HTMLElement && target.isContentEditable)
+      ) {
+        return
+      }
+
+      if (match(props.modelKeybind ?? "mod+'", event)) {
+        event.preventDefault()
+        event.stopPropagation()
+        openModel()
+        return
+      }
+
+      if (match(props.agentKeybind ?? "mod+.", event)) {
+        event.preventDefault()
+        event.stopPropagation()
+        cycleAgent()
+        return
+      }
+
+      if (match(props.variantKeybind ?? "shift+mod+d", event)) {
+        event.preventDefault()
+        event.stopPropagation()
+        cycleVariant()
+      }
+    }
+
+    window.addEventListener("keydown", onKey)
+    onCleanup(() => window.removeEventListener("keydown", onKey))
+  })
+
+  const layout = useLayout({
+    isQuestion,
+    isPermission,
+    imageCount: () => images().length,
+    contextCount: () => contexts().length,
+    heightSpring: props.heightSpring,
+    morphSpring: props.morphSpring,
+  })
+
+  const edit = useEditor({
+    value: props.value,
+    onValueChange: props.onValueChange,
+    onSubmit: (input) => {
+      if (working()) {
+        if (props.runtime?.abort) {
+          void Promise.resolve()
+            .then(() => props.runtime?.abort?.())
+            .catch(() => {})
+          return
+        }
+        void Promise.resolve()
+          .then(() => props.onAbort?.())
+          .catch(() => {})
+        return
+      }
+
+      const payload = {
+        source: input.source,
+        mode: input.mode,
+        text: input.text,
+        parts: input.parts,
+        files: images(),
+        context: contexts(),
+      }
+
+      const text = payload.text.trim()
+      if (!text && payload.files.length === 0 && payload.context.length === 0) return
+      if (!props.runtime?.submit && !props.onSubmit) return
+
+      const run = props.runtime?.submit ? () => props.runtime?.submit?.(payload) : () => props.onSubmit?.(payload)
+
+      setSending(true)
+      void Promise.resolve()
+        .then(run)
+        .then(() => {
+          setImages([])
+          edit.setText("")
+        })
+        .catch(() => {})
+        .finally(() => {
+          setSending(false)
+        })
+    },
+    onAbort: () => {
+      if (props.runtime?.abort) {
+        return Promise.resolve()
+          .then(() => props.runtime?.abort?.())
+          .catch(() => {})
+      }
+      if (props.onAbort) {
+        return Promise.resolve()
+          .then(() => props.onAbort?.())
+          .catch(() => {})
+      }
+      if (!sending()) return
+      setSending(false)
+    },
+    onAuto: toggleAccept,
+    onPick: pick,
+    onModel: openModel,
+    onAgent: cycleAgent,
+    onVariant: cycleVariant,
+    modelKeybind: props.modelKeybind ?? "mod+'",
+    agentKeybind: props.agentKeybind ?? "mod+.",
+    variantKeybind: props.variantKeybind ?? "shift+mod+d",
+    onSlash: props.runtime?.runSlash ?? props.onSlashCommand,
+    historyRead: props.runtime?.historyRead ?? props.historyRead,
+    historyWrite: props.runtime?.historyWrite ?? props.historyWrite,
+    working,
+    atOptions: props.runtime?.searchAt ?? props.atOptions ?? DEFAULT_AT_OPTIONS,
+    slashCommands: props.runtime?.searchSlash ?? props.slashCommands ?? DEFAULT_SLASH_COMMANDS,
+    editor: () => editor,
+    measure: () => layout.measure(editor),
+  })
+
+  const listAnswers = () => {
+    const count = Math.max((props.questionTotal ?? 0) || 0, tab() + 1)
+    return Array.from({ length: count }, (_, i) => answers()[i] ?? [])
+  }
+
+  const updateCustom = (value: string, on: boolean = customOn()) => {
+    const i = tab()
+    const prev = (customs()[i] ?? "").trim()
+    const next = value.trim()
+
+    setCustoms((map) => ({ ...map, [i]: value }))
+    if (!on) return
+
+    if (isMulti()) {
+      setAnswerMap((map) => {
+        const list = map[i] ?? []
+        const clean = prev ? list.filter((item) => item.trim() !== prev) : list
+        if (!next) return { ...map, [i]: clean }
+        if (clean.some((item) => item.trim() === next)) return { ...map, [i]: clean }
+        return { ...map, [i]: [...clean, next] }
+      })
+      return
+    }
+    setAnswerMap((map) => ({ ...map, [i]: next ? [next] : [] }))
+  }
+
+  const setCustom = (value: string) => {
+    updateCustom(value)
+  }
+
+  const setCustomOn = (value: boolean) => {
+    const i = tab()
+    setCustomOns((map) => ({ ...map, [i]: value }))
+    if (value) {
+      updateCustom(custom(), true)
+      return
+    }
+
+    const text = custom().trim()
+    if (!text) return
+    setAnswerMap((map) => {
+      const list = map[i] ?? []
+      return { ...map, [i]: list.filter((item) => item.trim() !== text) }
+    })
+  }
+
+  const questionSubmit = () => {
+    if (questionBusy()) return
+    const run = props.runtime?.submitQuestion ?? props.onQuestionSubmit
+    if (!run) {
+      props.onQuestionNext?.()
+      return
+    }
+
+    setQuestionSending(true)
+    void Promise.resolve()
+      .then(() => run(listAnswers()))
+      .catch(() => {})
+      .finally(() => {
+        setQuestionSending(false)
+      })
+  }
+
+  const questionNext = () => {
+    if (questionBusy()) return
+    const last = (props.questionIndex ?? 0) >= (props.questionTotal ?? 1) - 1
+    if (last) {
+      questionSubmit()
+      return
+    }
+    props.onQuestionNext?.()
+  }
+
+  const questionForward = () => {
+    if (questionBusy()) return
+    const last = (props.questionIndex ?? 0) >= (props.questionTotal ?? 1) - 1
+    if (last) return
+    props.onQuestionNext?.()
+  }
+
+  const questionBack = () => {
+    if (questionBusy()) return
+    props.onQuestionBack?.()
+  }
+
+  const questionDismiss = () => {
+    if (questionBusy()) return
+
+    const run = props.runtime?.rejectQuestion ?? props.onQuestionReject
+    if (!run) {
+      props.onQuestionDismiss?.()
+      return
+    }
+
+    setQuestionSending(true)
+    void Promise.resolve()
+      .then(run)
+      .then(() => {
+        props.onQuestionDismiss?.()
+      })
+      .catch(() => {})
+      .finally(() => {
+        setQuestionSending(false)
+      })
+  }
+
+  const decidePermission = (response: "once" | "always" | "reject") => {
+    if (permissionBusy()) return
+
+    const run = props.runtime?.decidePermission ?? props.onPermissionDecide
+    if (!run) return
+
+    setPermissionSending(true)
+    void Promise.resolve()
+      .then(() => run(response))
+      .catch(() => {})
+      .finally(() => {
+        setPermissionSending(false)
+      })
+  }
+
+  const todo = useTodo({
+    todos: () => props.todos ?? [],
+    show: () => props.showTodos ?? false,
+    blocked: () => isQuestion() || isPermission(),
+    collapsed: () => props.todoCollapsed,
+    onCollapsed: props.onTodoCollapseChange,
+    shellHeight: layout.height,
+    trayHeight,
+    trayOverlap,
+  })
+
+  const toggleOption = (label: string) => {
+    const index = tab()
+    if (isMulti()) {
+      setAnswerMap((prev) => {
+        const list = prev[index] ?? []
+        const next = list.includes(label) ? list.filter((item) => item !== label) : [...list, label]
+        return { ...prev, [index]: next }
+      })
+      return
+    }
+    setCustomOns((prev) => ({ ...prev, [index]: false }))
+    setAnswerMap((prev) => ({ ...prev, [index]: [label] }))
+  }
+
+  const ctxKey = (item: ContextItem) => {
+    if (item.id) return `id:${item.id}`
+    if (item.commentID) return `comment:${item.path}:${item.commentID}`
+    const start = item.selection?.startLine ?? 0
+    const end = item.selection?.endLine ?? 0
+    return `path:${item.path}:${start}:${end}`
+  }
+
+  const dropContext = (item: ContextItem) => {
+    const id = ctxKey(item)
+    setContexts((prev) => prev.filter((x) => ctxKey(x) !== id))
+    props.runtime?.removeContext?.(item)
+    props.onContextDrop?.(item)
+  }
+
+  const openContext = (item: ContextItem) => {
+    props.runtime?.openContext?.(item)
+    props.onContextOpen?.(item)
+  }
+
+  const rejectFile = (source: "paste" | "drop" | "pick", file?: File) => {
+    const input = { source, file }
+    const runtime = props.runtime?.fileRejected
+    const handler = props.onFileRejected
+
+    if (runtime) {
+      runtime(input)
+      return
+    }
+    if (handler) {
+      handler(input)
+      return
+    }
+    if (source !== "paste") return
+
+    showToast({
+      title: i18n.t("ui.prompt.toast.pasteUnsupported.title"),
+      description: i18n.t("ui.prompt.toast.pasteUnsupported.description"),
+    })
+  }
+
+  const dialogOn = () => props.runtime?.dialogActive?.() ?? props.dialogActive ?? false
+
+  const addFile = (file: File, source: "paste" | "drop" | "pick") => {
+    if (!ACCEPTED_FILE_TYPES.includes(file.type)) {
+      rejectFile(source, file)
+      return false
+    }
+
+    const reader = new FileReader()
+    reader.onload = () => {
+      const dataUrl = reader.result as string
+      setImages((prev) => [
+        ...prev,
+        {
+          id: `img-${++id}-${Date.now()}`,
+          filename: file.name,
+          mime: file.type,
+          dataUrl,
+        },
+      ])
+    }
+    reader.readAsDataURL(file)
+    return true
+  }
+
+  const dropImage = (itemID: string) => setImages((prev) => prev.filter((item) => item.id !== itemID))
+
+  const handlePaste = (event: ClipboardEvent) => {
+    const data = event.clipboardData
+    if (!data) return
+    event.preventDefault()
+    event.stopPropagation()
+
+    const items = Array.from(data.items)
+    const fileItems = items.filter((item) => item.kind === "file")
+    const accepted = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
+
+    if (accepted.length > 0) {
+      for (const item of accepted) {
+        const file = item.getAsFile()
+        if (file) addFile(file, "paste")
+      }
+      return
+    }
+
+    if (fileItems.length > 0) {
+      const file = fileItems[0]?.getAsFile() ?? undefined
+      rejectFile("paste", file)
+      return
+    }
+
+    const text = data.getData("text/plain") ?? ""
+    if (!text) {
+      const read = props.runtime?.readClipboardImage ?? props.readClipboardImage
+      if (!read) return
+      void Promise.resolve()
+        .then(read)
+        .then((file) => {
+          if (!file) return
+          addFile(file, "paste")
+        })
+        .catch(() => {})
+      return
+    }
+
+    document.execCommand("insertText", false, text)
+  }
+
+  const handleGlobalDragOver = (event: DragEvent) => {
+    if (dialogOn()) return
+    event.preventDefault()
+    if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"
+
+    const hasFiles = event.dataTransfer?.types.includes("Files")
+    if (hasFiles) {
+      setDrag("image")
+      return
+    }
+
+    const hasText = event.dataTransfer?.types.includes("text/plain")
+    if (hasText) {
+      setDrag("@mention")
+      return
+    }
+
+    setDrag(null)
+  }
+
+  const handleGlobalDragLeave = (event: DragEvent) => {
+    if (dialogOn()) return
+    if (!event.relatedTarget) setDrag(null)
+  }
+
+  const handleGlobalDrop = (event: DragEvent) => {
+    if (dialogOn()) return
+    event.preventDefault()
+    setDrag(null)
+
+    const text = event.dataTransfer?.getData("text/plain")
+    if (text?.startsWith("file:")) {
+      edit.insertFile(text.slice(5))
+      return
+    }
+
+    const list = event.dataTransfer?.files
+    if (!list) return
+    for (const file of Array.from(list)) {
+      addFile(file, "drop")
+    }
+  }
+
+  const onPick = (event: Event) => {
+    const target = event.currentTarget as HTMLInputElement
+    const files = target.files
+    if (!files) return
+    for (const file of Array.from(files)) addFile(file, "pick")
+    target.value = ""
+  }
+
+  const send = (source: ComposerSource = "button") => {
+    if (working()) {
+      if (props.runtime?.abort) {
+        void Promise.resolve()
+          .then(() => props.runtime?.abort?.())
+          .catch(() => {})
+      } else {
+        void Promise.resolve()
+          .then(() => props.onAbort?.())
+          .catch(() => {})
+      }
+      return
+    }
+    edit.submit(source)
+  }
+
+  const pickShortcut = (n: number) => {
+    const opts = props.questionOptions ?? []
+    if (n <= 0) return
+
+    const option = opts[n - 1]
+    if (option) {
+      toggleOption(option.label)
+      return
+    }
+
+    if (n === opts.length + 1) {
+      setCustomOn(true)
+      const input = document.querySelector('[data-slot="question-custom-input"]')
+      if (input instanceof HTMLTextAreaElement) input.focus()
+    }
+  }
+
+  onMount(() => {
+    const onKey = (event: KeyboardEvent) => {
+      if (!isQuestion()) return
+      if (questionBusy()) return
+
+      const target = event.target
+      const writing =
+        target instanceof HTMLInputElement ||
+        target instanceof HTMLSelectElement ||
+        target instanceof HTMLTextAreaElement ||
+        (target instanceof HTMLElement && target.isContentEditable)
+
+      if (
+        event.key === "Escape" &&
+        target instanceof HTMLTextAreaElement &&
+        target.dataset.slot === "question-custom-input"
+      ) {
+        target.blur()
+        event.preventDefault()
+        return
+      }
+
+      if (
+        event.key === "Enter" &&
+        !event.shiftKey &&
+        !event.metaKey &&
+        !event.ctrlKey &&
+        !event.altKey &&
+        target instanceof HTMLTextAreaElement &&
+        target.dataset.slot === "question-custom-input"
+      ) {
+        target.blur()
+        event.preventDefault()
+        return
+      }
+
+      const mod = event.metaKey || event.ctrlKey
+      if (mod && event.key === "Enter") {
+        event.preventDefault()
+        questionSubmit()
+        return
+      }
+
+      if (writing) return
+
+      if (event.key >= "1" && event.key <= "9") {
+        pickShortcut(Number(event.key))
+        event.preventDefault()
+        return
+      }
+
+      if (event.key === "ArrowLeft") {
+        questionBack()
+        event.preventDefault()
+        return
+      }
+
+      if (event.key === "ArrowRight") {
+        questionForward()
+        event.preventDefault()
+        return
+      }
+
+      if (event.key !== "Enter") return
+      event.preventDefault()
+      questionNext()
+    }
+
+    window.addEventListener("keydown", onKey)
+    onCleanup(() => window.removeEventListener("keydown", onKey))
+  })
+
+  onMount(() => {
+    document.addEventListener("dragover", handleGlobalDragOver)
+    document.addEventListener("dragleave", handleGlobalDragLeave)
+    document.addEventListener("drop", handleGlobalDrop)
+
+    onCleanup(() => {
+      document.removeEventListener("dragover", handleGlobalDragOver)
+      document.removeEventListener("dragleave", handleGlobalDragLeave)
+      document.removeEventListener("drop", handleGlobalDrop)
+    })
+  })
+
+  return (
+    <div
+      data-component="new-composer"
+      data-mode={props.mode}
+      style={{
+        width: "100%",
+        "max-width": "720px",
+        margin: "0 auto",
+        position: "relative",
+        height: `${todo.totalHeight()}px`,
+      }}
+    >
+      <ComposerTray
+        onRef={setTrayRef}
+        inputOpacity={layout.inputOpacity()}
+        inputBlur={layout.inputBlur()}
+        questionOpacity={layout.questionOpacity()}
+        questionBlur={layout.questionBlur()}
+        morph={layout.morph()}
+        isQuestion={isQuestion()}
+        isPermission={isPermission()}
+        showQuestion={layout.showQuestion()}
+        showPermission={layout.showPermission()}
+        agentName={props.agentName}
+        modelName={props.modelName}
+        variant={props.variant}
+        agentOptions={props.agentOptions}
+        modelOptions={props.modelOptions}
+        variantOptions={props.variantOptions}
+        agentCurrent={props.agentCurrent}
+        modelCurrent={props.modelCurrent}
+        variantCurrent={props.variantCurrent}
+        onAgentSelect={props.onAgentSelect}
+        onModelSelect={props.onModelSelect}
+        onVariantSelect={props.onVariantSelect}
+        agentKeybind={props.agentKeybind ?? "mod+."}
+        modelKeybind={props.modelKeybind ?? "mod+'"}
+        variantKeybind={props.variantKeybind ?? "shift+mod+d"}
+        modelOpenTick={modelTick()}
+        agentControl={props.agentControl}
+        modelControl={props.modelControl}
+        variantControl={props.variantControl}
+        shell={edit.mode()}
+        onShell={edit.setMode}
+        questionIndex={props.questionIndex}
+        questionTotal={props.questionTotal}
+        onQuestionDismiss={questionDismiss}
+        onQuestionBack={questionBack}
+        onQuestionNext={questionNext}
+        questionBusy={questionBusy()}
+        onPermissionDecide={decidePermission}
+        permissionBusy={permissionBusy()}
+      />
+
+      <Show when={todo.progress() > 0.001}>
+        <ComposerTodoTray
+          todos={props.todos ?? []}
+          collapsed={todo.collapsed()}
+          onToggle={todo.toggle}
+          progress={todo.progress()}
+          collapse={todo.collapse()}
+          visibleHeight={todo.visible()}
+          hide={todo.hide()}
+          shut={todo.shut()}
+          bottom={todo.bottom()}
+          onContentRef={todo.contentRef}
+        />
+      </Show>
+
+      <ComposerPopover
+        kind={edit.popover()}
+        bottom={trayHeight() - trayOverlap + layout.height() + 8}
+        atFlat={edit.at.flat()}
+        atActive={edit.at.active()}
+        atKey={edit.atKey}
+        onAtHover={edit.at.setActive}
+        onAtPick={edit.insertAt}
+        slashFlat={edit.slash.flat()}
+        slashActive={edit.slash.active()}
+        onSlashHover={edit.slash.setActive}
+        onSlashPick={edit.insertSlash}
+      />
+
+      <div
+        data-dock-surface="shell"
+        style={{
+          position: "absolute",
+          bottom: `${trayHeight() - trayOverlap}px`,
+          left: 0,
+          right: 0,
+          "z-index": 10,
+          height: `${layout.height()}px`,
+          ...(activeDrag()
+            ? {
+                border: "2px dashed var(--icon-info-active)",
+                "box-shadow": "none",
+              }
+            : {
+                "box-shadow": `0 0 0 1px light-dark(#cfcecd, #595353), 0 1px 2px -1px rgba(19,16,16,0.04), 0 1px 2px 0 rgba(19,16,16,0.06), 0 1px 3px 0 rgba(19,16,16,0.08)`,
+              }),
+        }}
+      >
+        <ComposerDragOverlay type={activeDrag()} />
+
+        <div
+          ref={layout.setQuestionRef}
+          data-component="dock-prompt"
+          data-kind="question"
+          aria-hidden="true"
+          style={{ position: "absolute", visibility: "hidden", left: 0, right: 0, "pointer-events": "none" }}
+        >
+          <div data-slot="question-body" style={{ padding: "8px 8px 0" }}>
+            <QuestionSizer
+              text={props.questionText}
+              options={props.questionOptions}
+              multi={isMulti()}
+              index={props.questionIndex}
+              total={props.questionTotal}
+              answered={answered()}
+            />
+          </div>
+        </div>
+
+        <div
+          ref={layout.setPermissionRef}
+          data-component="dock-prompt"
+          data-kind="permission"
+          aria-hidden="true"
+          style={{ position: "absolute", visibility: "hidden", left: 0, right: 0, "pointer-events": "none" }}
+        >
+          <div data-slot="permission-body">
+            <PermissionBody
+              tool={props.permissionTool}
+              description={props.permissionDescription}
+              patterns={props.permissionPatterns}
+            />
+          </div>
+        </div>
+
+        <div
+          style={{
+            position: "absolute",
+            inset: 0,
+            display: "flex",
+            "flex-direction": "column",
+            opacity: layout.inputOpacity(),
+            transform: `scale(${layout.inputScale()})`,
+            filter: `blur(${layout.inputBlur()}px)`,
+            "pointer-events": layout.morph() > 0.5 ? "none" : "auto",
+          }}
+        >
+          <ComposerInputLayer
+            value={edit.value()}
+            mode={edit.mode()}
+            images={images()}
+            contexts={contexts()}
+            working={working()}
+            accepting={accepting()}
+            placeholder={props.placeholder}
+            onImageDrop={dropImage}
+            contextActive={props.contextActive}
+            onContextOpen={openContext}
+            onContextDrop={dropContext}
+            onAccept={toggleAccept}
+            onPick={pick}
+            onSend={() => send("button")}
+            onEditorRef={(el) => {
+              editor = el
+              requestAnimationFrame(() => {
+                layout.measure(editor)
+                el.focus()
+              })
+            }}
+            onInput={edit.handleInput}
+            onKeyDown={edit.handleKeyDown}
+            onPaste={handlePaste}
+            onCompStart={() => edit.setComposing(true)}
+            onCompEnd={() => edit.setComposing(false)}
+          />
+        </div>
+
+        <div
+          style={{
+            position: "absolute",
+            inset: 0,
+            opacity: layout.questionOpacity(),
+            transform: `scale(${layout.questionScale()})`,
+            filter: `blur(${layout.questionBlur()}px)`,
+            "pointer-events": layout.morph() < 0.5 ? "none" : "auto",
+          }}
+        >
+          <Switch>
+            <Match when={layout.showQuestion()}>
+              <div data-component="dock-prompt" data-kind="question">
+                <div data-slot="question-body" style={{ padding: "8px 8px 0" }}>
+                  <QuestionBody
+                    text={props.questionText}
+                    options={props.questionOptions}
+                    multi={isMulti()}
+                    index={props.questionIndex}
+                    total={props.questionTotal}
+                    answered={answered()}
+                    selected={selected()}
+                    custom={custom()}
+                    customOn={customOn()}
+                    busy={questionBusy()}
+                    onToggle={toggleOption}
+                    onCustomOn={setCustomOn}
+                    onCustom={setCustom}
+                    onJump={props.onQuestionJump}
+                  />
+                </div>
+              </div>
+            </Match>
+            <Match when={layout.showPermission()}>
+              <div data-component="dock-prompt" data-kind="permission">
+                <div data-slot="permission-body">
+                  <PermissionBody
+                    tool={props.permissionTool}
+                    description={props.permissionDescription}
+                    patterns={props.permissionPatterns}
+                  />
+                </div>
+              </div>
+            </Match>
+          </Switch>
+        </div>
+      </div>
+
+      <input
+        ref={(el) => {
+          input = el
+        }}
+        type="file"
+        multiple
+        accept={ACCEPTED_FILE_TYPES.join(",")}
+        style={{ display: "none" }}
+        onChange={onPick}
+      />
+    </div>
+  )
+}
+
+export const Composer = NewComposer
+
+export type {
+  AtOption,
+  ComposerHistoryItem,
+  ContextItem,
+  ImageAttachment,
+  NewComposerProps,
+  SlashCommand,
+  TodoItem,
+} from "./new-composer/types"

+ 64 - 0
packages/ui/src/components/new-composer/defaults.ts

@@ -0,0 +1,64 @@
+import type { AtOption, SlashCommand } from "./types"
+
+export const DEFAULT_AT_OPTIONS: AtOption[] = [
+  { type: "file", path: "src/auth.ts", display: "src/auth.ts" },
+  { type: "file", path: "src/middleware.ts", display: "src/middleware.ts" },
+  { type: "file", path: "src/routes/login.ts", display: "src/routes/login.ts" },
+  { type: "file", path: "src/utils/token.ts", display: "src/utils/token.ts" },
+  { type: "file", path: "src/config/database.ts", display: "src/config/database.ts" },
+  { type: "agent", name: "coder", display: "coder" },
+  { type: "agent", name: "reviewer", display: "reviewer" },
+  { type: "agent", name: "planner", display: "planner" },
+]
+
+export const DEFAULT_SLASH_COMMANDS: SlashCommand[] = [
+  {
+    id: "help",
+    trigger: "help",
+    title: "Help",
+    description: "Show available commands",
+    type: "builtin",
+    keybind: "?",
+    source: "command",
+  },
+  {
+    id: "clear",
+    trigger: "clear",
+    title: "Clear",
+    description: "Clear conversation",
+    type: "builtin",
+    source: "command",
+  },
+  {
+    id: "compact",
+    trigger: "compact",
+    title: "Compact",
+    description: "Compact conversation history",
+    type: "builtin",
+    source: "command",
+  },
+  {
+    id: "init",
+    trigger: "init",
+    title: "Init",
+    description: "Initialize CLAUDE.md",
+    type: "builtin",
+    source: "command",
+  },
+  {
+    id: "review",
+    trigger: "review",
+    title: "Review",
+    description: "Review code changes",
+    type: "custom",
+    source: "skill",
+  },
+  {
+    id: "test",
+    trigger: "test",
+    title: "Test",
+    description: "Run tests",
+    type: "custom",
+    source: "mcp",
+  },
+]

+ 23 - 0
packages/ui/src/components/new-composer/drag-overlay.tsx

@@ -0,0 +1,23 @@
+import { Show } from "solid-js"
+import { useI18n } from "../../context/i18n"
+import { Icon } from "../icon"
+
+interface Props {
+  type: "image" | "@mention" | null
+}
+
+export function ComposerDragOverlay(props: Props) {
+  const i18n = useI18n()
+  return (
+    <Show when={props.type}>
+      <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
+        <div class="flex flex-col items-center gap-2 text-text-weak">
+          <Icon name={props.type === "image" ? "photo" : "link"} class="size-8" />
+          <span class="text-14-regular">
+            {props.type === "image" ? i18n.t("ui.prompt.dropzone.attach") : i18n.t("ui.prompt.dropzone.mention")}
+          </span>
+        </div>
+      </div>
+    </Show>
+  )
+}

+ 100 - 0
packages/ui/src/components/new-composer/editor-utils.test.ts

@@ -0,0 +1,100 @@
+import { describe, expect, test } from "bun:test"
+import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-utils"
+
+describe("new-composer editor utils", () => {
+  test("createTextFragment preserves newlines with consecutive br nodes", () => {
+    const fragment = createTextFragment("foo\n\nbar")
+    const box = document.createElement("div")
+    box.appendChild(fragment)
+
+    expect(box.childNodes.length).toBe(4)
+    expect(box.childNodes[0]?.textContent).toBe("foo")
+    expect((box.childNodes[1] as HTMLElement).tagName).toBe("BR")
+    expect((box.childNodes[2] as HTMLElement).tagName).toBe("BR")
+    expect(box.childNodes[3]?.textContent).toBe("bar")
+  })
+
+  test("createTextFragment keeps trailing newline as terminal break", () => {
+    const fragment = createTextFragment("foo\n")
+    const box = document.createElement("div")
+    box.appendChild(fragment)
+
+    expect(box.childNodes.length).toBe(2)
+    expect(box.childNodes[0]?.textContent).toBe("foo")
+    expect((box.childNodes[1] as HTMLElement).tagName).toBe("BR")
+  })
+
+  test("createTextFragment avoids break-node explosion for large multiline content", () => {
+    const value = Array.from({ length: 220 }, () => "line").join("\n")
+    const fragment = createTextFragment(value)
+    const box = document.createElement("div")
+    box.appendChild(fragment)
+
+    expect(box.childNodes.length).toBe(1)
+    expect(box.childNodes[0]?.nodeType).toBe(Node.TEXT_NODE)
+    expect(box.textContent).toBe(value)
+  })
+
+  test("createTextFragment keeps terminal break in large multiline fallback", () => {
+    const value = `${Array.from({ length: 220 }, () => "line").join("\n")}\n`
+    const fragment = createTextFragment(value)
+    const box = document.createElement("div")
+    box.appendChild(fragment)
+
+    expect(box.childNodes.length).toBe(2)
+    expect(box.childNodes[0]?.textContent).toBe(value.slice(0, -1))
+    expect((box.childNodes[1] as HTMLElement).tagName).toBe("BR")
+  })
+
+  test("length helpers treat breaks as one char and ignore zero-width chars", () => {
+    const box = document.createElement("div")
+    box.appendChild(document.createTextNode("ab\u200B"))
+    box.appendChild(document.createElement("br"))
+    box.appendChild(document.createTextNode("cd"))
+
+    expect(getNodeLength(box.childNodes[0]!)).toBe(2)
+    expect(getNodeLength(box.childNodes[1]!)).toBe(1)
+    expect(getTextLength(box)).toBe(5)
+  })
+
+  test("setCursorPosition and getCursorPosition round-trip with pills and breaks", () => {
+    const box = document.createElement("div")
+    const pill = document.createElement("span")
+    pill.dataset.type = "file"
+    pill.textContent = "@file"
+
+    box.appendChild(document.createTextNode("ab"))
+    box.appendChild(pill)
+    box.appendChild(document.createElement("br"))
+    box.appendChild(document.createTextNode("cd"))
+    document.body.appendChild(box)
+
+    setCursorPosition(box, 2)
+    expect(getCursorPosition(box)).toBe(2)
+
+    setCursorPosition(box, 7)
+    expect(getCursorPosition(box)).toBe(7)
+
+    setCursorPosition(box, 8)
+    expect(getCursorPosition(box)).toBe(8)
+
+    box.remove()
+  })
+
+  test("setCursorPosition and getCursorPosition round-trip across blank lines", () => {
+    const box = document.createElement("div")
+    box.appendChild(document.createTextNode("a"))
+    box.appendChild(document.createElement("br"))
+    box.appendChild(document.createElement("br"))
+    box.appendChild(document.createTextNode("b"))
+    document.body.appendChild(box)
+
+    setCursorPosition(box, 2)
+    expect(getCursorPosition(box)).toBe(2)
+
+    setCursorPosition(box, 3)
+    expect(getCursorPosition(box)).toBe(3)
+
+    box.remove()
+  })
+})

+ 206 - 0
packages/ui/src/components/new-composer/editor-utils.ts

@@ -0,0 +1,206 @@
+import type { ComposerPart } from "./types"
+
+const MAX_BREAKS = 200
+
+export function createTextFragment(content: string): DocumentFragment {
+  const fragment = document.createDocumentFragment()
+
+  let breaks = 0
+  for (const char of content) {
+    if (char !== "\n") continue
+    breaks += 1
+    if (breaks > MAX_BREAKS) {
+      const tail = content.endsWith("\n")
+      const text = tail ? content.slice(0, -1) : content
+      if (text) fragment.appendChild(document.createTextNode(text))
+      if (tail) fragment.appendChild(document.createElement("br"))
+      return fragment
+    }
+  }
+
+  const parts = content.split("\n")
+  parts.forEach((part, i) => {
+    if (part) fragment.appendChild(document.createTextNode(part))
+    if (i < parts.length - 1) fragment.appendChild(document.createElement("br"))
+  })
+  return fragment
+}
+
+export function getNodeLength(node: Node): number {
+  if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
+  return (node.textContent ?? "").replace(/\u200B/g, "").length
+}
+
+export function getTextLength(node: Node): number {
+  if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length
+  if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
+  let len = 0
+  for (const child of Array.from(node.childNodes)) len += getTextLength(child)
+  return len
+}
+
+export function getCursorPosition(parent: HTMLElement): number {
+  const selection = window.getSelection()
+  if (!selection || selection.rangeCount === 0) return 0
+  const range = selection.getRangeAt(0)
+  if (!parent.contains(range.startContainer)) return 0
+  const copy = range.cloneRange()
+  copy.selectNodeContents(parent)
+  copy.setEnd(range.startContainer, range.startOffset)
+  return getTextLength(copy.cloneContents())
+}
+
+export function setCursorPosition(parent: HTMLElement, position: number) {
+  let rest = position
+  let node = parent.firstChild
+  while (node) {
+    const len = getNodeLength(node)
+    const text = node.nodeType === Node.TEXT_NODE
+    const pill =
+      node.nodeType === Node.ELEMENT_NODE &&
+      ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
+    const br = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
+
+    if (text && rest <= len) {
+      const range = document.createRange()
+      const selection = window.getSelection()
+      range.setStart(node, rest)
+      range.collapse(true)
+      selection?.removeAllRanges()
+      selection?.addRange(range)
+      return
+    }
+    if ((pill || br) && rest <= len) {
+      const range = document.createRange()
+      const selection = window.getSelection()
+      if (rest === 0) range.setStartBefore(node)
+      else if (pill) range.setStartAfter(node)
+      else {
+        const next = node.nextSibling
+        if (next && next.nodeType === Node.TEXT_NODE) range.setStart(next, 0)
+        else range.setStartAfter(node)
+      }
+      range.collapse(true)
+      selection?.removeAllRanges()
+      selection?.addRange(range)
+      return
+    }
+    rest -= len
+    node = node.nextSibling
+  }
+
+  const end = document.createRange()
+  end.selectNodeContents(parent)
+  end.collapse(false)
+  window.getSelection()?.removeAllRanges()
+  window.getSelection()?.addRange(end)
+}
+
+export function setRangeEdge(parent: HTMLElement, range: Range, edge: "start" | "end", offset: number) {
+  let rest = offset
+  for (const node of Array.from(parent.childNodes)) {
+    const len = getNodeLength(node)
+    const text = node.nodeType === Node.TEXT_NODE
+    const pill =
+      node.nodeType === Node.ELEMENT_NODE &&
+      ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
+    const br = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
+
+    if (text && rest <= len) {
+      if (edge === "start") range.setStart(node, rest)
+      else range.setEnd(node, rest)
+      return
+    }
+    if ((pill || br) && rest <= len) {
+      if (edge === "start") rest === 0 ? range.setStartBefore(node) : range.setStartAfter(node)
+      else rest === 0 ? range.setEndBefore(node) : range.setEndAfter(node)
+      return
+    }
+    rest -= len
+  }
+}
+
+export function createPill(type: "file" | "agent", content: string, path?: string) {
+  const el = document.createElement("span")
+  el.textContent = content
+  el.setAttribute("data-type", type)
+  if (type === "file" && path) el.setAttribute("data-path", path)
+  if (type === "agent") el.setAttribute("data-name", content.replace("@", ""))
+  el.setAttribute("contenteditable", "false")
+  el.style.userSelect = "text"
+  el.style.cursor = "default"
+  return el
+}
+
+export function parseEditorText(editor: HTMLElement): string {
+  return parseEditorParts(editor)
+    .map((part) => part.content)
+    .join("")
+}
+
+function pushText(parts: ComposerPart[], text: string) {
+  if (!text) return
+  const last = parts[parts.length - 1]
+  if (last?.type === "text") {
+    last.content += text
+    return
+  }
+  parts.push({ type: "text", content: text })
+}
+
+export function parseEditorParts(editor: HTMLElement): ComposerPart[] {
+  const parts: ComposerPart[] = []
+  let text = ""
+
+  const flush = () => {
+    if (!text) return
+    pushText(parts, text)
+    text = ""
+  }
+
+  const visit = (node: Node) => {
+    if (node.nodeType === Node.TEXT_NODE) {
+      text += (node.textContent ?? "").replace(/\u200B/g, "")
+      return
+    }
+    if (node.nodeType !== Node.ELEMENT_NODE) return
+    const el = node as HTMLElement
+    if (el.dataset.type === "file" || el.dataset.type === "agent") {
+      flush()
+      const content = el.textContent ?? ""
+      if (el.dataset.type === "file") {
+        parts.push({
+          type: "file",
+          path: el.dataset.path ?? content,
+          content,
+        })
+      }
+      if (el.dataset.type === "agent") {
+        parts.push({
+          type: "agent",
+          name: el.dataset.name ?? content.replace(/^@/, ""),
+          content,
+        })
+      }
+      return
+    }
+    if (el.tagName === "BR") {
+      text += "\n"
+      return
+    }
+    for (const child of Array.from(el.childNodes)) visit(child)
+  }
+
+  const nodes = Array.from(editor.childNodes)
+  nodes.forEach((node, i) => {
+    const block = node.nodeType === Node.ELEMENT_NODE && ["DIV", "P"].includes((node as HTMLElement).tagName)
+    visit(node)
+    if (block && i < nodes.length - 1) text += "\n"
+  })
+  flush()
+  return parts
+}
+
+export function isImeComposing(event: KeyboardEvent): boolean {
+  return event.isComposing || (event as unknown as { keyCode?: number }).keyCode === 229
+}

+ 128 - 0
packages/ui/src/components/new-composer/history.test.ts

@@ -0,0 +1,128 @@
+import { describe, expect, test } from "bun:test"
+import {
+  canNavigateAtCursor,
+  cloneParts,
+  navigateEntry,
+  normalizeEntry,
+  partText,
+  prependEntry,
+  type HistoryEntry,
+} from "./history"
+
+const text = (value: string): HistoryEntry => ({
+  text: value,
+  parts: [{ type: "text", content: value }],
+})
+
+describe("new-composer history", () => {
+  test("prependEntry skips empty entries and deduplicates consecutive entries", () => {
+    const blank: HistoryEntry[] = []
+    const first = prependEntry(blank, text(""))
+    expect(first).toBe(blank)
+
+    const one = prependEntry([], text("hello"))
+    expect(one).toHaveLength(1)
+
+    const dup = prependEntry(one, text("hello"))
+    expect(dup).toBe(one)
+  })
+
+  test("navigateEntry restores saved draft when moving down from newest", () => {
+    const entries = [text("third"), text("second"), text("first")]
+
+    const up = navigateEntry({
+      direction: "up",
+      entries,
+      historyIndex: -1,
+      current: text("draft"),
+      saved: null,
+    })
+
+    expect(up.handled).toBe(true)
+    if (!up.handled) throw new Error("expected handled")
+    expect(up.historyIndex).toBe(0)
+    expect(up.cursor).toBe("start")
+    expect(up.entry.text).toBe("third")
+
+    const down = navigateEntry({
+      direction: "down",
+      entries,
+      historyIndex: up.historyIndex,
+      current: text("ignored"),
+      saved: up.saved,
+    })
+
+    expect(down.handled).toBe(true)
+    if (!down.handled) throw new Error("expected handled")
+    expect(down.historyIndex).toBe(-1)
+    expect(down.entry.text).toBe("draft")
+    expect(down.entry.parts).toEqual([{ type: "text", content: "draft" }])
+  })
+
+  test("navigateEntry keeps structured parts when navigating history", () => {
+    const entry: HistoryEntry = {
+      text: "@src/auth.ts",
+      parts: [{ type: "file", path: "src/auth.ts", content: "@src/auth.ts" }],
+    }
+
+    const up = navigateEntry({
+      direction: "up",
+      entries: [entry],
+      historyIndex: -1,
+      current: text("draft"),
+      saved: null,
+    })
+
+    expect(up.handled).toBe(true)
+    if (!up.handled) throw new Error("expected handled")
+    expect(up.entry.parts).toEqual(entry.parts)
+  })
+
+  test("normalizeEntry supports legacy string entries", () => {
+    const entry = normalizeEntry("legacy")
+    expect(entry.text).toBe("legacy")
+    expect(entry.parts).toEqual([{ type: "text", content: "legacy" }])
+  })
+
+  test("helpers clone parts and count combined content", () => {
+    const src = [
+      { type: "text", content: "one" },
+      { type: "file", path: "src/a.ts", content: "@src/a.ts" },
+      { type: "agent", name: "coder", content: "@coder" },
+    ] as const
+
+    const copy = cloneParts([...src])
+    expect(copy).not.toBe(src)
+    expect(partText(copy)).toBe("one@src/a.ts@coder")
+
+    if (copy[1]?.type !== "file") throw new Error("expected file")
+    copy[1].path = "src/b.ts"
+    if (src[1].type !== "file") throw new Error("expected file")
+    expect(src[1].path).toBe("src/a.ts")
+  })
+
+  test("canNavigateAtCursor only allows history navigation at boundaries", () => {
+    const value = "a\nb\nc"
+
+    expect(canNavigateAtCursor("up", value, 0)).toBe(true)
+    expect(canNavigateAtCursor("down", value, 0)).toBe(false)
+
+    expect(canNavigateAtCursor("up", value, 2)).toBe(false)
+    expect(canNavigateAtCursor("down", value, 2)).toBe(false)
+
+    expect(canNavigateAtCursor("up", value, 5)).toBe(false)
+    expect(canNavigateAtCursor("down", value, 5)).toBe(true)
+
+    expect(canNavigateAtCursor("up", "abc", 0)).toBe(true)
+    expect(canNavigateAtCursor("down", "abc", 3)).toBe(true)
+    expect(canNavigateAtCursor("up", "abc", 1)).toBe(false)
+    expect(canNavigateAtCursor("down", "abc", 1)).toBe(false)
+
+    expect(canNavigateAtCursor("up", "abc", 0, true)).toBe(true)
+    expect(canNavigateAtCursor("up", "abc", 3, true)).toBe(true)
+    expect(canNavigateAtCursor("down", "abc", 0, true)).toBe(true)
+    expect(canNavigateAtCursor("down", "abc", 3, true)).toBe(true)
+    expect(canNavigateAtCursor("up", "abc", 1, true)).toBe(false)
+    expect(canNavigateAtCursor("down", "abc", 1, true)).toBe(false)
+  })
+})

+ 189 - 0
packages/ui/src/components/new-composer/history.ts

@@ -0,0 +1,189 @@
+import type { ComposerHistoryItem, ComposerPart } from "./types"
+
+export type HistoryEntry = {
+  text: string
+  parts: ComposerPart[]
+}
+
+export type HistoryStored = string | ComposerHistoryItem
+
+export type NavInput = {
+  direction: "up" | "down"
+  entries: HistoryStored[]
+  historyIndex: number
+  current: HistoryEntry
+  saved: HistoryEntry | null
+}
+
+export type NavResult =
+  | {
+      handled: false
+      historyIndex: number
+      saved: HistoryEntry | null
+    }
+  | {
+      handled: true
+      historyIndex: number
+      saved: HistoryEntry | null
+      entry: HistoryEntry
+      cursor: "start" | "end"
+    }
+
+export const MAX_HISTORY = 50
+
+export const EMPTY_ENTRY: HistoryEntry = {
+  text: "",
+  parts: [{ type: "text", content: "" }],
+}
+
+const clonePart = (part: ComposerPart): ComposerPart => {
+  if (part.type === "text") return { ...part }
+  if (part.type === "file") return { ...part }
+  return { ...part }
+}
+
+export const cloneParts = (parts: ComposerPart[]) => parts.map(clonePart)
+
+const cloneEntry = (entry: HistoryEntry): HistoryEntry => ({
+  text: entry.text,
+  parts: cloneParts(entry.parts),
+})
+
+export const partText = (parts: ComposerPart[]) => parts.map((part) => part.content).join("")
+
+export const normalizeEntry = (item: HistoryStored): HistoryEntry => {
+  if (typeof item === "string") {
+    return {
+      text: item,
+      parts: [{ type: "text", content: item }],
+    }
+  }
+
+  if (!item.parts || item.parts.length === 0) {
+    return {
+      text: item.text,
+      parts: [{ type: "text", content: item.text }],
+    }
+  }
+
+  const parts = cloneParts(item.parts)
+  return {
+    text: partText(parts),
+    parts,
+  }
+}
+
+const samePart = (a: ComposerPart, b: ComposerPart) => {
+  if (a.type !== b.type) return false
+  if (a.type === "text" && b.type === "text") return a.content === b.content
+  if (a.type === "file" && b.type === "file") return a.path === b.path && a.content === b.content
+  if (a.type === "agent" && b.type === "agent") return a.name === b.name && a.content === b.content
+  return false
+}
+
+export const sameEntry = (a: HistoryEntry | undefined, b: HistoryEntry) => {
+  if (!a) return false
+  if (a.text !== b.text) return false
+  if (a.parts.length !== b.parts.length) return false
+
+  for (let i = 0; i < a.parts.length; i++) {
+    const x = a.parts[i]
+    const y = b.parts[i]
+    if (!x || !y || !samePart(x, y)) return false
+  }
+
+  return true
+}
+
+export const prependEntry = (entries: HistoryEntry[], item: HistoryEntry, max = MAX_HISTORY) => {
+  if (!item.text.trim()) return entries
+
+  const next = normalizeEntry(item)
+  if (sameEntry(entries[0], next)) return entries
+  return [next, ...entries].slice(0, max)
+}
+
+export const canNavigateAtCursor = (direction: "up" | "down", text: string, cursor: number, inHistory = false) => {
+  const pos = Math.max(0, Math.min(cursor, text.length))
+  const start = pos === 0
+  const end = pos === text.length
+  if (inHistory) return start || end
+  if (direction === "up") return start
+  return end
+}
+
+export const navigateEntry = (input: NavInput): NavResult => {
+  if (input.direction === "up") {
+    if (input.entries.length === 0) {
+      return {
+        handled: false,
+        historyIndex: input.historyIndex,
+        saved: input.saved,
+      }
+    }
+
+    if (input.historyIndex === -1) {
+      return {
+        handled: true,
+        historyIndex: 0,
+        saved: cloneEntry(input.current),
+        entry: normalizeEntry(input.entries[0] ?? EMPTY_ENTRY),
+        cursor: "start",
+      }
+    }
+
+    if (input.historyIndex < input.entries.length - 1) {
+      const next = input.historyIndex + 1
+      return {
+        handled: true,
+        historyIndex: next,
+        saved: input.saved,
+        entry: normalizeEntry(input.entries[next] ?? EMPTY_ENTRY),
+        cursor: "start",
+      }
+    }
+
+    return {
+      handled: false,
+      historyIndex: input.historyIndex,
+      saved: input.saved,
+    }
+  }
+
+  if (input.historyIndex > 0) {
+    const next = input.historyIndex - 1
+    return {
+      handled: true,
+      historyIndex: next,
+      saved: input.saved,
+      entry: normalizeEntry(input.entries[next] ?? EMPTY_ENTRY),
+      cursor: "end",
+    }
+  }
+
+  if (input.historyIndex === 0) {
+    if (input.saved) {
+      return {
+        handled: true,
+        historyIndex: -1,
+        saved: null,
+        entry: cloneEntry(input.saved),
+        cursor: "end",
+      }
+    }
+
+    return {
+      handled: true,
+      historyIndex: -1,
+      saved: null,
+      entry: normalizeEntry(EMPTY_ENTRY),
+      cursor: "end",
+    }
+  }
+
+  return {
+    handled: false,
+    historyIndex: input.historyIndex,
+    saved: input.saved,
+  }
+}

+ 223 - 0
packages/ui/src/components/new-composer/input-layer.tsx

@@ -0,0 +1,223 @@
+import { For, Show } from "solid-js"
+import { useSpring } from "@opencode-ai/ui/motion-spring"
+import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
+import { useI18n } from "../../context/i18n"
+import { Button } from "../button"
+import { FileIcon } from "../file-icon"
+import { Icon } from "../icon"
+import { IconButton } from "../icon-button"
+import { Tooltip } from "../tooltip"
+import type { ContextItem, ImageAttachment } from "./types"
+
+interface Props {
+  value: string
+  mode: "normal" | "shell"
+  images: ImageAttachment[]
+  contexts: ContextItem[]
+  working: boolean
+  accepting: boolean
+  placeholder?: string
+  onImageDrop: (id: string) => void
+  contextActive?: (item: ContextItem) => boolean
+  onContextOpen?: (item: ContextItem) => void
+  onContextDrop: (item: ContextItem) => void
+  onAccept: () => void
+  onPick: () => void
+  onSend: () => void
+  onEditorRef: (el: HTMLDivElement) => void
+  onInput: () => void
+  onKeyDown: (event: KeyboardEvent) => void
+  onPaste: (event: ClipboardEvent) => void
+  onCompStart: () => void
+  onCompEnd: () => void
+}
+
+export function ComposerInputLayer(props: Props) {
+  const i18n = useI18n()
+  const isShell = () => props.mode === "shell"
+  const shell = useSpring(() => (isShell() ? 1 : 0), { visualDuration: 0.1, bounce: 0 })
+  const buttonsOpacity = () => 1 - shell()
+  const buttonsBlur = () => shell()
+
+  return (
+    <>
+      <Show when={props.images.length > 0}>
+        <div class="flex flex-wrap gap-2 px-3 pt-3">
+          <For each={props.images}>
+            {(item) => (
+              <div class="relative group">
+                <Show
+                  when={item.mime.startsWith("image/")}
+                  fallback={
+                    <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
+                      <Icon name="folder" class="size-6 text-text-weak" />
+                    </div>
+                  }
+                >
+                  <img
+                    src={item.dataUrl}
+                    alt={item.filename}
+                    class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
+                  />
+                </Show>
+                <button
+                  type="button"
+                  onClick={() => props.onImageDrop(item.id)}
+                  class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
+                  aria-label={i18n.t("ui.prompt.attachment.remove")}
+                >
+                  <Icon name="close" class="size-3 text-text-weak" />
+                </button>
+                <div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
+                  <span class="text-10-regular text-white truncate block">{item.filename}</span>
+                </div>
+              </div>
+            )}
+          </For>
+        </div>
+      </Show>
+
+      <Show when={props.contexts.length > 0}>
+        <div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
+          <For each={props.contexts}>
+            {(item) => {
+              const active = () => props.contextActive?.(item) ?? false
+              const dir = () => getDirectory(item.path)
+              const file = () => getFilename(item.path)
+              const filename = () => getFilenameTruncated(item.path, 14)
+              const label = () => {
+                if (!item.selection) return null
+                if (item.selection.startLine === item.selection.endLine) return `:${item.selection.startLine}`
+                return `:${item.selection.startLine}-${item.selection.endLine}`
+              }
+              return (
+                <Tooltip
+                  value={
+                    <span class="flex max-w-[300px]">
+                      <span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">{dir()}</span>
+                      <span class="shrink-0">{file()}</span>
+                    </span>
+                  }
+                  placement="top"
+                  openDelay={2000}
+                >
+                  <div
+                    classList={{
+                      "group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 cursor-default transition-all shadow-xs-border": true,
+                      "hover:bg-surface-interactive-weak": !!item.commentID && !active(),
+                      "bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
+                        active(),
+                      "bg-background-stronger": !active(),
+                    }}
+                    onClick={() => props.onContextOpen?.(item)}
+                  >
+                    <div class="flex items-center gap-1.5">
+                      <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
+                      <div class="flex items-center text-11-regular min-w-0 font-medium">
+                        <span class="text-text-strong whitespace-nowrap">{filename()}</span>
+                        <Show when={label()}>
+                          <span class="text-text-weak whitespace-nowrap shrink-0">{label()}</span>
+                        </Show>
+                      </div>
+                      <IconButton
+                        type="button"
+                        icon="close-small"
+                        variant="ghost"
+                        class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
+                        onClick={(event) => {
+                          event.stopPropagation()
+                          props.onContextDrop(item)
+                        }}
+                        aria-label={i18n.t("ui.prompt.context.removeFile")}
+                      />
+                    </div>
+                    <Show when={item.comment}>
+                      {(note) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{note()}</div>}
+                    </Show>
+                  </div>
+                </Tooltip>
+              )
+            }}
+          </For>
+        </div>
+      </Show>
+
+      <div class="relative max-h-[200px] overflow-y-auto no-scrollbar" style={{ flex: 1 }}>
+        <div
+          data-component="prompt-input"
+          ref={props.onEditorRef}
+          role="textbox"
+          aria-multiline="true"
+          aria-label={props.placeholder ?? i18n.t("ui.prompt.placeholder.simple")}
+          contentEditable={true}
+          autocapitalize="off"
+          autocorrect="off"
+          spellcheck={false}
+          onInput={props.onInput}
+          onKeyDown={props.onKeyDown}
+          onPaste={props.onPaste}
+          onCompositionStart={props.onCompStart}
+          onCompositionEnd={props.onCompEnd}
+          class="select-text w-full pl-3 pr-2 pt-2 pb-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap"
+          classList={{
+            "font-mono!": props.mode === "shell",
+            "[&_[data-type=file]]:text-syntax-property": true,
+            "[&_[data-type=agent]]:text-syntax-type": true,
+          }}
+        />
+        <Show when={!props.value.trim()}>
+          <div
+            class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 pb-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
+            classList={{ "font-mono!": props.mode === "shell" }}
+          >
+            {props.mode === "shell"
+              ? i18n.t("ui.prompt.placeholder.shell")
+              : (props.placeholder ?? i18n.t("ui.prompt.placeholder.simple"))}
+          </div>
+        </Show>
+      </div>
+
+      <div
+        class="flex items-center justify-between px-2 pb-2 shrink-0"
+        style={{
+          opacity: `${buttonsOpacity()}`,
+          filter: buttonsBlur() > 0.01 ? `blur(${buttonsBlur()}px)` : "none",
+          "pointer-events": shell() < 0.5 ? "auto" : "none",
+        }}
+      >
+        <Button
+          variant="ghost"
+          onClick={props.onAccept}
+          class="size-6"
+          classList={{
+            "text-text-base": !props.accepting,
+            "hover:bg-surface-success-base": props.accepting,
+          }}
+          style={{ display: "flex", "align-items": "center", "justify-content": "center" }}
+          aria-label={i18n.t("ui.prompt.autoAccept")}
+          aria-pressed={props.accepting}
+        >
+          <Icon name="chevron-double-right" size="small" classList={{ "text-icon-success-base": props.accepting }} />
+        </Button>
+        <div class="flex items-center gap-1">
+          <Button
+            variant="ghost"
+            class="size-8 p-0"
+            aria-label={i18n.t("ui.prompt.attachment.add")}
+            onClick={props.onPick}
+          >
+            <Icon name="plus" class="size-4.5" />
+          </Button>
+          <IconButton
+            icon={props.working ? "stop" : "arrow-up"}
+            variant="primary"
+            class="size-8"
+            onClick={props.onSend}
+            disabled={!props.working && props.value.trim().length === 0 && props.images.length === 0}
+            aria-label={props.working ? i18n.t("ui.prompt.stop") : i18n.t("ui.prompt.send")}
+          />
+        </div>
+      </div>
+    </>
+  )
+}

+ 242 - 0
packages/ui/src/components/new-composer/model-picker.tsx

@@ -0,0 +1,242 @@
+import { Popover as Kobalte } from "@kobalte/core/popover"
+import { For, Show, createEffect, createMemo, createSignal, on } from "solid-js"
+import { useI18n } from "../../context/i18n"
+import { Button } from "../button"
+import { Icon } from "../icon"
+import { IconButton } from "../icon-button"
+import { ProviderIcon } from "../provider-icon"
+
+interface Props {
+  options: string[]
+  current?: string
+  onSelect?: (value: string) => void
+  openTick?: number
+}
+
+type Item = {
+  value: string
+  provider: string
+  label: string
+}
+
+function parse(value: string, fallback: string): Item {
+  const i = value.indexOf("/")
+  if (i <= 0 || i >= value.length - 1) {
+    return {
+      value,
+      provider: fallback,
+      label: value,
+    }
+  }
+
+  return {
+    value,
+    provider: value.slice(0, i),
+    label: value.slice(i + 1),
+  }
+}
+
+export function ModelPicker(props: Props) {
+  const i18n = useI18n()
+  const [open, setOpen] = createSignal(false)
+  const [full, setFull] = createSignal(false)
+  const [query, setQuery] = createSignal("")
+  let input: HTMLInputElement | undefined
+
+  const focus = () => {
+    requestAnimationFrame(() => {
+      input?.focus()
+      input?.select()
+    })
+  }
+
+  createEffect(
+    on(
+      () => props.openTick,
+      (next, prev) => {
+        if (next === undefined) return
+        if (next === prev) return
+        setFull(true)
+        setOpen(true)
+        focus()
+      },
+      { defer: true },
+    ),
+  )
+
+  const list = createMemo(() => {
+    const q = query().trim().toLowerCase()
+    const fallback = i18n.t("ui.prompt.model.group.default")
+    const items = props.options.map((item) => parse(item, fallback))
+    if (!q) return items
+    return items.filter((item) => {
+      return item.label.toLowerCase().includes(q) || item.provider.toLowerCase().includes(q)
+    })
+  })
+
+  const groups = createMemo(() => {
+    const map = new Map<string, Item[]>()
+    for (const item of list()) {
+      const group = map.get(item.provider)
+      if (group) {
+        group.push(item)
+        continue
+      }
+      map.set(item.provider, [item])
+    }
+    return Array.from(map.entries()).map(([provider, items]) => ({ provider, items }))
+  })
+
+  const label = createMemo(() => {
+    const fallback = i18n.t("ui.prompt.model.group.default")
+    const hit = props.options.map((item) => parse(item, fallback)).find((item) => item.value === props.current)
+    if (!hit) return props.current ?? i18n.t("ui.prompt.control.model")
+    return hit.label
+  })
+
+  const provider = createMemo(() => {
+    const fallback = i18n.t("ui.prompt.model.group.default")
+    const hit = props.options.map((item) => parse(item, fallback)).find((item) => item.value === props.current)
+    if (!hit) return undefined
+    if (hit.provider === fallback) return undefined
+    return hit.provider.toLowerCase()
+  })
+
+  return (
+    <Kobalte
+      open={open()}
+      onOpenChange={(next) => {
+        setOpen(next)
+        if (!next) {
+          setQuery("")
+          setFull(false)
+        }
+        if (next) focus()
+      }}
+      modal={false}
+      placement="top-start"
+      gutter={4}
+    >
+      <Kobalte.Trigger
+        as={Button}
+        variant="ghost"
+        size="normal"
+        class="min-w-0 max-w-[320px] text-13-regular group"
+        style={{ height: "28px" }}
+        onPointerDown={() => setFull(false)}
+      >
+        <Show when={provider()} fallback={<Icon name="brain" size="small" class="shrink-0" />}>
+          {(id) => (
+            <ProviderIcon
+              id={id()}
+              class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
+              style={{ "will-change": "opacity", transform: "translateZ(0)" }}
+            />
+          )}
+        </Show>
+        <span class="truncate">{label()}</span>
+        <Icon name="chevron-down" size="small" class="shrink-0" />
+      </Kobalte.Trigger>
+
+      <Kobalte.Portal>
+        <Show when={open() && full()}>
+          <div class="fixed inset-0 bg-black/35 z-40" onPointerDown={() => setOpen(false)} />
+        </Show>
+        <Kobalte.Content
+          class="flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
+          classList={{
+            "w-72 h-80 p-2": !full(),
+            "w-[min(92vw,1130px)] h-[min(86vh,860px)] p-5": full(),
+          }}
+          style={
+            full()
+              ? {
+                  position: "fixed",
+                  left: "50%",
+                  top: "50%",
+                  transform: "translate(-50%, -50%)",
+                }
+              : undefined
+          }
+          onEscapeKeyDown={(event) => {
+            setOpen(false)
+            event.preventDefault()
+            event.stopPropagation()
+          }}
+        >
+          <Show when={full()}>
+            <div class="flex items-center justify-between mb-3">
+              <div class="text-35-medium text-text-strong">{i18n.t("ui.prompt.model.select")}</div>
+              <Button icon="plus-small" variant="secondary" size="normal" class="text-32-medium h-10 px-4">
+                {i18n.t("ui.prompt.model.connect")}
+              </Button>
+            </div>
+          </Show>
+
+          <div class="flex items-center gap-2 px-2 py-1.5 rounded-md bg-surface-base mb-2">
+            <Icon name="magnifying-glass" size="small" class="text-icon-weak shrink-0" />
+            <input
+              ref={input}
+              value={query()}
+              onInput={(event) => setQuery(event.currentTarget.value)}
+              placeholder={i18n.t("ui.prompt.model.search")}
+              class="min-w-0 flex-1 bg-transparent border-none outline-none text-14-regular text-text-strong placeholder:text-text-weak"
+            />
+            <Show when={!full()}>
+              <IconButton
+                icon="plus-small"
+                variant="ghost"
+                iconSize="normal"
+                class="size-6"
+                aria-label={i18n.t("ui.prompt.model.connect")}
+              />
+              <IconButton
+                icon="sliders"
+                variant="ghost"
+                iconSize="normal"
+                class="size-6"
+                aria-label={i18n.t("ui.prompt.model.manage")}
+              />
+            </Show>
+          </div>
+
+          <div class="flex-1 min-h-0 overflow-auto no-scrollbar px-1 pt-1">
+            <Show
+              when={groups().length > 0}
+              fallback={<div class="px-2 py-1 text-13-regular text-text-weak">{i18n.t("ui.prompt.model.none")}</div>}
+            >
+              <For each={groups()}>
+                {(group) => (
+                  <div class="pb-2.5">
+                    <div class="px-2 py-1.5 text-35-medium text-text-weak">{group.provider}</div>
+                    <For each={group.items}>
+                      {(item) => (
+                        <button
+                          class="w-full h-10 px-2 rounded-md flex items-center justify-between text-left"
+                          classList={{ "bg-surface-raised-base-hover": props.current === item.value }}
+                          onClick={() => {
+                            props.onSelect?.(item.value)
+                            setOpen(false)
+                          }}
+                        >
+                          <span class="text-32-medium text-text-strong truncate">{item.label}</span>
+                          <Show when={props.current === item.value}>
+                            <Icon name="check-small" size="small" class="text-icon-strong-base shrink-0" />
+                          </Show>
+                        </button>
+                      )}
+                    </For>
+                  </div>
+                )}
+              </For>
+            </Show>
+          </div>
+
+          <Show when={full()}>
+            <div class="pt-2 px-2 text-32-medium text-text-weak">{i18n.t("ui.prompt.model.manage")}</div>
+          </Show>
+        </Kobalte.Content>
+      </Kobalte.Portal>
+    </Kobalte>
+  )
+}

+ 41 - 0
packages/ui/src/components/new-composer/permission-body.tsx

@@ -0,0 +1,41 @@
+import { For, Show } from "solid-js"
+import { useI18n } from "../../context/i18n"
+import { Icon } from "../icon"
+
+interface Props {
+  tool?: string
+  description?: string
+  patterns?: string[]
+}
+
+export function PermissionBody(props: Props) {
+  const i18n = useI18n()
+  const hint = () => props.description ?? props.tool ?? ""
+
+  return (
+    <>
+      <div data-slot="permission-row" data-variant="header">
+        <span data-slot="permission-icon">
+          <Icon name="warning" size="normal" />
+        </span>
+        <div data-slot="permission-header-title">{i18n.t("ui.permission.title")}</div>
+      </div>
+      <Show when={hint()}>
+        <div data-slot="permission-row">
+          <span data-slot="permission-spacer" aria-hidden="true" />
+          <div data-slot="permission-hint">{hint()}</div>
+        </div>
+      </Show>
+      <Show when={(props.patterns?.length ?? 0) > 0}>
+        <div data-slot="permission-row">
+          <span data-slot="permission-spacer" aria-hidden="true" />
+          <div data-slot="permission-patterns">
+            <For each={props.patterns}>
+              {(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
+            </For>
+          </div>
+        </div>
+      </Show>
+    </>
+  )
+}

+ 111 - 0
packages/ui/src/components/new-composer/popover.tsx

@@ -0,0 +1,111 @@
+import { For, Match, Show, Switch } from "solid-js"
+import { useI18n } from "../../context/i18n"
+import { Icon } from "../icon"
+import { getDirectory, getFilename } from "@opencode-ai/util/path"
+import type { AtOption, SlashCommand } from "./types"
+
+interface Props {
+  kind: "at" | "slash" | null
+  bottom: number
+  atFlat: AtOption[]
+  atActive: string | null
+  atKey: (item: AtOption) => string
+  onAtHover: (key: string) => void
+  onAtPick: (item: AtOption) => void
+  slashFlat: SlashCommand[]
+  slashActive: string | null
+  onSlashHover: (key: string) => void
+  onSlashPick: (item: SlashCommand) => void
+}
+
+export function ComposerPopover(props: Props) {
+  const i18n = useI18n()
+  return (
+    <Show when={props.kind}>
+      <div
+        class="absolute left-0 right-0 max-h-80 min-h-10 overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px] bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]"
+        style={{
+          bottom: `${props.bottom}px`,
+          "z-index": 100,
+        }}
+        onMouseDown={(event) => event.preventDefault()}
+      >
+        <Switch>
+          <Match when={props.kind === "at"}>
+            <Show
+              when={props.atFlat.length > 0}
+              fallback={<div class="text-text-weak px-2 py-1">{i18n.t("ui.list.empty")}</div>}
+            >
+              <For each={props.atFlat.slice(0, 10)}>
+                {(item) => {
+                  const key = props.atKey(item)
+                  const isDir = item.type === "file" && item.path.endsWith("/")
+                  const dir = item.type === "file" ? (isDir ? item.path : getDirectory(item.path)) : ""
+                  const file = item.type === "file" && !isDir ? getFilename(item.path) : ""
+                  return (
+                    <button
+                      class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
+                      classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
+                      onClick={() => props.onAtPick(item)}
+                      onMouseEnter={() => props.onAtHover(key)}
+                    >
+                      <Icon
+                        name={item.type === "agent" ? "brain" : "file-tree"}
+                        size="small"
+                        class={item.type === "agent" ? "text-icon-info-active shrink-0" : "text-icon-base shrink-0"}
+                      />
+                      {item.type === "agent" ? (
+                        <span class="text-14-regular text-text-strong whitespace-nowrap">@{item.name}</span>
+                      ) : (
+                        <div class="flex items-center text-14-regular min-w-0">
+                          <span class="text-text-weak whitespace-nowrap truncate min-w-0">{dir}</span>
+                          <Show when={!isDir}>
+                            <span class="text-text-strong whitespace-nowrap">{file}</span>
+                          </Show>
+                        </div>
+                      )}
+                    </button>
+                  )
+                }}
+              </For>
+            </Show>
+          </Match>
+          <Match when={props.kind === "slash"}>
+            <Show
+              when={props.slashFlat.length > 0}
+              fallback={<div class="text-text-weak px-2 py-1">{i18n.t("ui.prompt.list.noCommands")}</div>}
+            >
+              <For each={props.slashFlat}>
+                {(cmd) => (
+                  <button
+                    classList={{
+                      "w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
+                      "bg-surface-raised-base-hover": props.slashActive === cmd.id,
+                    }}
+                    onClick={() => props.onSlashPick(cmd)}
+                    onMouseEnter={() => props.onSlashHover(cmd.id)}
+                  >
+                    <div class="flex items-center gap-2 min-w-0">
+                      <span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
+                      <Show when={cmd.description}>
+                        <span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
+                      </Show>
+                    </div>
+                    <Show when={cmd.type === "custom" && cmd.source !== "command"}>
+                      <span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
+                        {cmd.source === "skill" ? "skill" : cmd.source === "mcp" ? "mcp" : "custom"}
+                      </span>
+                    </Show>
+                    <Show when={cmd.keybind}>
+                      <span class="text-12-regular text-text-subtle">{cmd.keybind}</span>
+                    </Show>
+                  </button>
+                )}
+              </For>
+            </Show>
+          </Match>
+        </Switch>
+      </div>
+    </Show>
+  )
+}

+ 218 - 0
packages/ui/src/components/new-composer/question-body.tsx

@@ -0,0 +1,218 @@
+import { For, Index, Show } from "solid-js"
+import { useI18n } from "../../context/i18n"
+import { Icon } from "../icon"
+
+interface Option {
+  label: string
+  description?: string
+}
+
+interface Base {
+  text?: string
+  options?: Option[]
+  index?: number
+  total?: number
+  multi?: boolean
+  answered?: boolean[]
+}
+
+interface BodyProps extends Base {
+  selected: string[]
+  custom: string
+  customOn: boolean
+  busy?: boolean
+  onToggle: (label: string) => void
+  onCustomOn: (value: boolean) => void
+  onCustom: (value: string) => void
+  onJump?: (index: number) => void
+}
+
+function Header(props: Base & { onJump?: (index: number) => void; click?: boolean }) {
+  const i18n = useI18n()
+  const tab = () => props.index ?? 0
+  const done = (i: number) => {
+    if (props.answered) return props.answered[i] === true
+    return i < tab()
+  }
+
+  return (
+    <Show when={(props.total ?? 0) > 0}>
+      <div data-slot="question-header">
+        <div data-slot="question-header-title">
+          {i18n.t("ui.question.progress", { index: tab() + 1, total: props.total ?? 0 })}
+        </div>
+        <div data-slot="question-progress">
+          <Index each={Array.from({ length: props.total ?? 0 })}>
+            {(_, i) => (
+              <Show
+                when={props.click}
+                fallback={
+                  <span data-slot="question-progress-segment" data-active={i === tab()} data-answered={done(i)} />
+                }
+              >
+                <button
+                  type="button"
+                  data-slot="question-progress-segment"
+                  data-active={i === tab()}
+                  data-answered={done(i)}
+                  onClick={() => props.onJump?.(i)}
+                />
+              </Show>
+            )}
+          </Index>
+        </div>
+      </div>
+    </Show>
+  )
+}
+
+export function QuestionBody(props: BodyProps) {
+  const i18n = useI18n()
+  const multi = () => props.multi ?? false
+  const selected = (label: string) => props.selected.includes(label)
+  const customPicked = () => {
+    if (props.customOn) return true
+    if (!multi()) return false
+    const text = props.custom.trim()
+    if (!text) return false
+    return props.selected.some((item) => item.trim() === text)
+  }
+
+  return (
+    <>
+      <Header index={props.index} total={props.total} answered={props.answered} onJump={props.onJump} click />
+      <div data-slot="question-content">
+        <div data-slot="question-text">{props.text}</div>
+        <Show when={multi()} fallback={<div data-slot="question-hint">{i18n.t("ui.question.singleHint")}</div>}>
+          <div data-slot="question-hint">{i18n.t("ui.question.multiHint")}</div>
+        </Show>
+        <div data-slot="question-options" style={{ "padding-bottom": "8px" }}>
+          <For each={props.options}>
+            {(opt) => {
+              const picked = () => selected(opt.label)
+              return (
+                <button
+                  data-slot="question-option"
+                  data-picked={picked()}
+                  onClick={() => props.onToggle(opt.label)}
+                  disabled={props.busy}
+                >
+                  <span data-slot="question-option-check" aria-hidden="true">
+                    <span
+                      data-slot="question-option-box"
+                      data-type={multi() ? "checkbox" : "radio"}
+                      data-picked={picked()}
+                    >
+                      <Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
+                        <Icon name="check-small" size="small" />
+                      </Show>
+                    </span>
+                  </span>
+                  <span data-slot="question-option-main">
+                    <span data-slot="option-label">{opt.label}</span>
+                    <Show when={opt.description}>
+                      <span data-slot="option-description">{opt.description}</span>
+                    </Show>
+                  </span>
+                </button>
+              )
+            }}
+          </For>
+
+          <form
+            data-slot="question-option"
+            data-custom="true"
+            data-picked={customPicked()}
+            onMouseDown={(event) => {
+              if (event.target instanceof HTMLTextAreaElement) return
+              const input = event.currentTarget.querySelector('[data-slot="question-custom-input"]')
+              if (input instanceof HTMLTextAreaElement) input.focus()
+            }}
+            onSubmit={(event) => event.preventDefault()}
+          >
+            <span
+              data-slot="question-option-check"
+              aria-hidden="true"
+              onClick={(event) => {
+                event.preventDefault()
+                event.stopPropagation()
+                const next = multi() ? !props.customOn : true
+                props.onCustomOn(next)
+
+                const form = event.currentTarget.closest("form")
+                const input = form?.querySelector('[data-slot="question-custom-input"]')
+                if (!(input instanceof HTMLTextAreaElement)) return
+                if (!next) {
+                  input.blur()
+                  return
+                }
+                input.focus()
+              }}
+            >
+              <span
+                data-slot="question-option-box"
+                data-type={multi() ? "checkbox" : "radio"}
+                data-picked={customPicked()}
+              >
+                <Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
+                  <Icon name="check-small" size="small" />
+                </Show>
+              </span>
+            </span>
+            <span data-slot="question-option-main">
+              <span data-slot="option-label">{i18n.t("ui.messagePart.option.typeOwnAnswer")}</span>
+              <textarea
+                data-slot="question-custom-input"
+                placeholder={i18n.t("ui.question.custom.placeholder")}
+                value={props.custom}
+                disabled={props.busy}
+                onFocus={() => props.onCustomOn(true)}
+                onInput={(event) => {
+                  const value = event.currentTarget.value
+                  props.onCustom(value)
+                  props.onCustomOn(value.trim().length > 0)
+                }}
+                rows={1}
+              />
+            </span>
+          </form>
+        </div>
+      </div>
+    </>
+  )
+}
+
+export function QuestionSizer(props: Base) {
+  const i18n = useI18n()
+  return (
+    <>
+      <Header index={props.index} total={props.total} answered={props.answered} />
+      <div data-slot="question-content">
+        <div data-slot="question-text">{props.text}</div>
+        <div data-slot="question-hint">
+          {props.multi ? i18n.t("ui.question.multiHint") : i18n.t("ui.question.singleHint")}
+        </div>
+        <div data-slot="question-options" style={{ overflow: "hidden", "padding-bottom": "8px" }}>
+          <For each={props.options}>
+            {(opt) => (
+              <div data-slot="question-option" style={{ "pointer-events": "none" }}>
+                <span data-slot="question-option-main">
+                  <span data-slot="option-label">{opt.label}</span>
+                  <Show when={opt.description}>
+                    <span data-slot="option-description">{opt.description}</span>
+                  </Show>
+                </span>
+              </div>
+            )}
+          </For>
+          <div data-slot="question-option" style={{ "pointer-events": "none" }}>
+            <span data-slot="question-option-main">
+              <span data-slot="option-label">{i18n.t("ui.messagePart.option.typeOwnAnswer")}</span>
+              <span data-slot="option-description">{i18n.t("ui.question.custom.placeholder")}</span>
+            </span>
+          </div>
+        </div>
+      </div>
+    </>
+  )
+}

+ 302 - 0
packages/ui/src/components/new-composer/todo-tray.tsx

@@ -0,0 +1,302 @@
+import { Index, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
+import { useI18n } from "../../context/i18n"
+import { AnimatedNumber } from "../animated-number"
+import { Checkbox } from "../checkbox"
+import { IconButton } from "../icon-button"
+import { TextReveal } from "../text-reveal"
+import { TextStrikethrough } from "../text-strikethrough"
+import type { TodoItem } from "./types"
+
+const SUBTITLE = { duration: 600, travel: 25, edge: 17 }
+const COUNT = { duration: 600, mask: 18, maskHeight: 0, widthDuration: 560 }
+
+interface Props {
+  todos: TodoItem[]
+  collapsed: boolean
+  onToggle: () => void
+  progress: number
+  collapse: number
+  visibleHeight: number
+  hide: number
+  shut: number
+  bottom: number
+  onContentRef: (el: HTMLDivElement) => void
+}
+
+function dot(status: TodoItem["status"]) {
+  if (status !== "in_progress") return undefined
+  return (
+    <svg
+      viewBox="0 0 12 12"
+      width="12"
+      height="12"
+      fill="currentColor"
+      xmlns="http://www.w3.org/2000/svg"
+      class="block"
+    >
+      <circle
+        cx="6"
+        cy="6"
+        r="3"
+        style={{
+          animation: "var(--animate-pulse-scale)",
+          "transform-origin": "center",
+          "transform-box": "fill-box",
+        }}
+      />
+    </svg>
+  )
+}
+
+export function ComposerTodoTray(props: Props) {
+  const i18n = useI18n()
+  const [stuck, setStuck] = createSignal(false)
+  const [scrolling, setScrolling] = createSignal(false)
+  let scroll: HTMLDivElement | undefined
+  let timer: number | undefined
+
+  const total = createMemo(() => props.todos.length)
+  const done = createMemo(() => props.todos.filter((item) => item.status === "completed").length)
+  const activeIndex = createMemo(() => props.todos.findIndex((item) => item.status === "in_progress"))
+  const active = createMemo(
+    () =>
+      props.todos.find((item) => item.status === "in_progress") ??
+      props.todos.find((item) => item.status === "pending") ??
+      props.todos.filter((item) => item.status === "completed").at(-1) ??
+      props.todos[0],
+  )
+  const preview = createMemo(() => active()?.content ?? "")
+
+  const ensure = () => {
+    if (props.collapsed) return
+    if (scrolling()) return
+    if (!scroll || scroll.offsetParent === null) return
+
+    const el = scroll.querySelector("[data-in-progress]")
+    if (!(el instanceof HTMLElement)) return
+
+    const topFade = 16
+    const bottomFade = 44
+    const box = scroll.getBoundingClientRect()
+    const rect = el.getBoundingClientRect()
+    const top = rect.top - box.top + scroll.scrollTop
+    const bottom = rect.bottom - box.top + scroll.scrollTop
+    const viewTop = scroll.scrollTop + topFade
+    const viewBottom = scroll.scrollTop + scroll.clientHeight - bottomFade
+
+    if (top < viewTop) {
+      scroll.scrollTop = Math.max(0, top - topFade)
+    } else if (bottom > viewBottom) {
+      scroll.scrollTop = bottom - (scroll.clientHeight - bottomFade)
+    }
+
+    setStuck(scroll.scrollTop > 0)
+  }
+
+  createEffect(
+    on([() => props.collapsed, activeIndex], () => {
+      if (props.collapsed || activeIndex() < 0) return
+      requestAnimationFrame(ensure)
+    }),
+  )
+
+  onCleanup(() => {
+    if (!timer) return
+    window.clearTimeout(timer)
+  })
+
+  return (
+    <div
+      data-dock-surface="tray"
+      style={{
+        position: "absolute",
+        bottom: `${props.bottom}px`,
+        left: 0,
+        right: 0,
+        "z-index": 5,
+        "max-height": `${props.visibleHeight}px`,
+        "overflow-x": "visible",
+        "overflow-y": "hidden",
+        "border-color": "light-dark(#dcd9d9, #3e3a3a)",
+        "pointer-events": props.progress < 0.98 ? "none" : "auto",
+        transform: `translateY(${(1 - props.progress) * 12}px)`,
+      }}
+    >
+      <div ref={props.onContentRef}>
+        <div
+          style={{
+            display: "flex",
+            "align-items": "center",
+            gap: "8px",
+            padding: "8px 8px 8px 12px",
+            height: "40px",
+            cursor: "pointer",
+            overflow: "visible",
+          }}
+          onClick={props.onToggle}
+        >
+          <span
+            style={{
+              "font-size": "14px",
+              color: "var(--text-strong)",
+              "white-space": "nowrap",
+              display: "inline-flex",
+              "align-items": "baseline",
+              "flex-shrink": 0,
+              overflow: "visible",
+              cursor: "default",
+              "--tool-motion-odometer-ms": `${COUNT.duration}ms`,
+              "--tool-motion-mask": `${COUNT.mask}%`,
+              "--tool-motion-mask-height": `${COUNT.maskHeight}px`,
+              "--tool-motion-spring-ms": `${COUNT.widthDuration}ms`,
+              opacity: `${1 - props.shut}`,
+              filter: props.shut > 0.01 ? `blur(${props.shut * 2}px)` : "none",
+            }}
+          >
+            <AnimatedNumber value={done()} />
+            <span style={{ margin: "0 4px" }}>{i18n.t("ui.todo.word.of")}</span>
+            <AnimatedNumber value={total()} />
+            <span>&nbsp;{i18n.t("ui.todo.word.completed")}</span>
+          </span>
+
+          <div
+            style={{
+              "margin-left": "4px",
+              "min-width": 0,
+              overflow: "hidden",
+              flex: "1 1 auto",
+              "max-width": "100%",
+            }}
+          >
+            <TextReveal
+              class="text-14-regular text-text-base cursor-default"
+              text={props.collapsed ? preview() : undefined}
+              duration={SUBTITLE.duration}
+              travel={SUBTITLE.travel}
+              edge={SUBTITLE.edge}
+              spring="cubic-bezier(0.34, 1, 0.64, 1)"
+              springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
+              growOnly
+              truncate
+            />
+          </div>
+
+          <div style={{ "margin-left": "auto" }}>
+            <IconButton
+              icon="chevron-down"
+              size="normal"
+              variant="ghost"
+              style={{ transform: `rotate(${props.collapse * 180}deg)` }}
+              onMouseDown={(event: MouseEvent) => {
+                event.preventDefault()
+                event.stopPropagation()
+              }}
+              onClick={(event: MouseEvent) => {
+                event.stopPropagation()
+                props.onToggle()
+              }}
+              aria-label={props.collapsed ? i18n.t("ui.todo.expand") : i18n.t("ui.todo.collapse")}
+            />
+          </div>
+        </div>
+
+        <div
+          style={{
+            position: "relative",
+            opacity: `${1 - props.hide}`,
+            filter: props.hide > 0.01 ? `blur(${props.hide * 2}px)` : "none",
+            visibility: props.hide > 0.98 ? "hidden" : "visible",
+            "pointer-events": props.hide > 0.1 ? "none" : "auto",
+          }}
+        >
+          <div style={{ position: "relative" }}>
+            <div
+              ref={(el) => {
+                scroll = el
+              }}
+              class="no-scrollbar"
+              style={{
+                padding: "0 12px 44px",
+                display: "flex",
+                "flex-direction": "column",
+                gap: "6px",
+                "max-height": "200px",
+                "overflow-y": "auto",
+                "overflow-anchor": "none",
+              }}
+              onScroll={(event) => {
+                setStuck(event.currentTarget.scrollTop > 0)
+                setScrolling(true)
+                if (timer) window.clearTimeout(timer)
+                timer = window.setTimeout(() => {
+                  setScrolling(false)
+                  if (activeIndex() < 0) return
+                  requestAnimationFrame(ensure)
+                }, 250)
+              }}
+            >
+              <Index each={props.todos}>
+                {(todo) => (
+                  <Checkbox
+                    readOnly
+                    checked={todo().status === "completed"}
+                    indeterminate={todo().status === "in_progress"}
+                    data-in-progress={todo().status === "in_progress" ? "" : undefined}
+                    data-state={todo().status}
+                    icon={dot(todo().status)}
+                    style={{
+                      "--checkbox-align": "flex-start",
+                      "--checkbox-offset": "1px",
+                      transition: "opacity 220ms cubic-bezier(0.22, 1, 0.36, 1)",
+                      opacity: todo().status === "pending" ? "0.5" : "1",
+                    }}
+                  >
+                    <TextStrikethrough
+                      active={todo().status === "completed" || todo().status === "cancelled"}
+                      text={todo().content}
+                      class="text-14-regular min-w-0 break-words"
+                      style={{
+                        "line-height": "var(--line-height-normal)",
+                        transition: "color 220ms cubic-bezier(0.22, 1, 0.36, 1)",
+                        color:
+                          todo().status === "completed" || todo().status === "cancelled"
+                            ? "var(--text-weak)"
+                            : "var(--text-strong)",
+                      }}
+                    />
+                  </Checkbox>
+                )}
+              </Index>
+            </div>
+            <div
+              style={{
+                position: "absolute",
+                top: 0,
+                left: 0,
+                right: 0,
+                height: "16px",
+                background: "linear-gradient(to bottom, var(--background-base), transparent)",
+                "pointer-events": "none",
+                opacity: stuck() ? 1 : 0,
+                transition: "opacity 150ms ease",
+                "z-index": 2,
+              }}
+            />
+            <div
+              style={{
+                position: "absolute",
+                bottom: 0,
+                left: 0,
+                right: 0,
+                height: "56px",
+                background: "linear-gradient(to bottom, transparent, var(--background-base) 85%)",
+                "pointer-events": "none",
+                "z-index": 2,
+              }}
+            />
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 410 - 0
packages/ui/src/components/new-composer/tray.tsx

@@ -0,0 +1,410 @@
+import { Show, type JSXElement } from "solid-js"
+import { useSpring } from "@opencode-ai/ui/motion-spring"
+import { useI18n } from "../../context/i18n"
+import { Button } from "../button"
+import { Icon } from "../icon"
+import { RadioGroup } from "../radio-group"
+import { Select } from "../select"
+import { Tooltip, TooltipKeybind } from "../tooltip"
+import { ModelPicker } from "./model-picker"
+
+const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
+
+const pretty = (config: string | undefined) => {
+  if (!config) return ""
+  const parts = config.split(",")[0]?.trim().toLowerCase().split("+")
+  if (!parts) return ""
+
+  const kb = {
+    key: "",
+    ctrl: false,
+    meta: false,
+    shift: false,
+    alt: false,
+  }
+
+  for (const part of parts) {
+    if (part === "ctrl" || part === "control") {
+      kb.ctrl = true
+      continue
+    }
+    if (part === "meta" || part === "cmd" || part === "command") {
+      kb.meta = true
+      continue
+    }
+    if (part === "mod") {
+      if (IS_MAC) kb.meta = true
+      else kb.ctrl = true
+      continue
+    }
+    if (part === "alt" || part === "option") {
+      kb.alt = true
+      continue
+    }
+    if (part === "shift") {
+      kb.shift = true
+      continue
+    }
+    kb.key = part
+  }
+
+  const out: string[] = []
+  if (kb.ctrl) out.push(IS_MAC ? "⌃" : "Ctrl")
+  if (kb.alt) out.push(IS_MAC ? "⌥" : "Alt")
+  if (kb.shift) out.push(IS_MAC ? "⇧" : "Shift")
+  if (kb.meta) out.push(IS_MAC ? "⌘" : "Meta")
+
+  const map: Record<string, string> = {
+    arrowup: "↑",
+    arrowdown: "↓",
+    arrowleft: "←",
+    arrowright: "→",
+    comma: ",",
+    plus: "+",
+    space: "Space",
+  }
+
+  if (kb.key) {
+    const hit = map[kb.key]
+    if (hit) out.push(hit)
+    else if (kb.key.length === 1) out.push(kb.key.toUpperCase())
+    else out.push(kb.key.charAt(0).toUpperCase() + kb.key.slice(1))
+  }
+
+  if (IS_MAC) return out.join("")
+  return out.join("+")
+}
+
+interface Props {
+  inputOpacity: number
+  inputBlur: number
+  questionOpacity: number
+  questionBlur: number
+  morph: number
+  isQuestion: boolean
+  isPermission: boolean
+  showQuestion: boolean
+  showPermission: boolean
+  agentName?: string
+  modelName?: string
+  variant?: string
+  agentOptions?: string[]
+  modelOptions?: string[]
+  variantOptions?: string[]
+  agentCurrent?: string
+  modelCurrent?: string
+  variantCurrent?: string
+  onAgentSelect?: (value: string) => void
+  onModelSelect?: (value: string) => void
+  onVariantSelect?: (value: string) => void
+  agentKeybind?: string
+  modelKeybind?: string
+  variantKeybind?: string
+  modelOpenTick?: number
+  agentControl?: JSXElement
+  modelControl?: JSXElement
+  variantControl?: JSXElement
+  shell: "shell" | "normal"
+  onShell: (mode: "shell" | "normal") => void
+  questionIndex?: number
+  questionTotal?: number
+  onQuestionDismiss?: () => void
+  onQuestionBack?: () => void
+  onQuestionNext?: () => void
+  questionBusy?: boolean
+  onPermissionDecide?: (response: "once" | "always" | "reject") => void | Promise<void>
+  permissionBusy?: boolean
+  onRef: (el: HTMLDivElement) => void
+}
+
+export function ComposerTray(props: Props) {
+  const i18n = useI18n()
+  const isShell = () => props.shell === "shell"
+  const shell = useSpring(() => (isShell() ? 1 : 0), { visualDuration: 0.1, bounce: 0 })
+  const buttonsOpacity = () => 1 - shell()
+  const buttonsBlur = () => shell()
+  const labelOpacity = () => shell()
+  const labelBlur = () => 1 - shell()
+
+  const hint = (title: string, keybind: string | undefined, node: JSXElement) => {
+    const kb = pretty(keybind)
+    if (kb) {
+      return (
+        <TooltipKeybind placement="top" gutter={4} title={title} keybind={kb}>
+          {node}
+        </TooltipKeybind>
+      )
+    }
+
+    return (
+      <Tooltip placement="top" value={<span>{title}</span>}>
+        {node}
+      </Tooltip>
+    )
+  }
+
+  return (
+    <div
+      ref={props.onRef}
+      data-dock-surface="tray"
+      data-dock-attach="top"
+      style={{
+        position: "absolute",
+        bottom: 0,
+        left: 0,
+        right: 0,
+        "z-index": 0,
+        height: "58px",
+        "border-color": "light-dark(#dcd9d9, #3e3a3a)",
+      }}
+    >
+      <div
+        style={{
+          position: "absolute",
+          inset: 0,
+          display: "flex",
+          "align-items": "center",
+          "justify-content": "space-between",
+          padding: "22px 7px 8px",
+          gap: "8px",
+          opacity: props.inputOpacity,
+          filter: `blur(${props.inputBlur}px)`,
+          "pointer-events": props.morph > 0.5 ? "none" : "auto",
+        }}
+      >
+        <div
+          style={{
+            display: "flex",
+            "align-items": "center",
+            gap: "6px",
+            "min-width": 0,
+            flex: 1,
+            position: "relative",
+          }}
+        >
+          {/* Shell label — fades in when shell mode active */}
+          <div
+            class="absolute inset-y-0 left-0 flex items-center px-1"
+            style={{
+              opacity: `${labelOpacity()}`,
+              filter: labelBlur() > 0.01 ? `blur(${labelBlur()}px)` : "none",
+              "pointer-events": shell() > 0.5 ? "auto" : "none",
+            }}
+          >
+            <span class="truncate text-13-medium text-text-strong">{i18n.t("ui.prompt.control.shell")}</span>
+          </div>
+          {/* Agent / model / variant buttons — fade out in shell mode */}
+          <div
+            style={{
+              display: "flex",
+              "align-items": "center",
+              gap: "6px",
+              opacity: `${buttonsOpacity()}`,
+              filter: buttonsBlur() > 0.01 ? `blur(${buttonsBlur()}px)` : "none",
+              "pointer-events": shell() < 0.5 ? "auto" : "none",
+            }}
+          >
+            <Show
+              when={props.agentControl}
+              fallback={
+                <Show
+                  when={(props.agentOptions?.length ?? 0) > 0}
+                  fallback={hint(
+                    i18n.t("ui.prompt.control.agent"),
+                    props.agentKeybind,
+                    <Button variant="ghost" size="normal">
+                      {props.agentName ?? "Ask"}
+                      <Icon name="chevron-down" size="small" />
+                    </Button>,
+                  )}
+                >
+                  {hint(
+                    i18n.t("ui.prompt.control.agent"),
+                    props.agentKeybind,
+                    <Select
+                      size="normal"
+                      variant="ghost"
+                      options={props.agentOptions ?? []}
+                      current={props.agentCurrent}
+                      onSelect={(value) => {
+                        if (!value) return
+                        props.onAgentSelect?.(value)
+                      }}
+                      class="capitalize max-w-[160px]"
+                      valueClass="truncate text-13-regular"
+                      triggerStyle={{ height: "28px" }}
+                    />,
+                  )}
+                </Show>
+              }
+            >
+              {props.agentControl}
+            </Show>
+            <Show
+              when={props.modelControl}
+              fallback={
+                <Show
+                  when={(props.modelOptions?.length ?? 0) > 0}
+                  fallback={hint(
+                    i18n.t("ui.prompt.control.model"),
+                    props.modelKeybind,
+                    <Button variant="ghost" size="normal">
+                      <Icon name="brain" size="small" />
+                      {props.modelName ?? "GPT-4"}
+                      <Icon name="chevron-down" size="small" />
+                    </Button>,
+                  )}
+                >
+                  {hint(
+                    i18n.t("ui.prompt.control.model"),
+                    props.modelKeybind,
+                    <ModelPicker
+                      options={props.modelOptions ?? []}
+                      current={props.modelCurrent ?? props.modelName}
+                      onSelect={props.onModelSelect}
+                      openTick={props.modelOpenTick}
+                    />,
+                  )}
+                </Show>
+              }
+            >
+              {props.modelControl}
+            </Show>
+            <Show
+              when={props.variantControl}
+              fallback={
+                <Show
+                  when={(props.variantOptions?.length ?? 0) > 0}
+                  fallback={hint(
+                    i18n.t("ui.prompt.control.variant"),
+                    props.variantKeybind,
+                    <Button variant="ghost" size="normal">
+                      {props.variant ?? "Default"}
+                      <Icon name="chevron-down" size="small" />
+                    </Button>,
+                  )}
+                >
+                  {hint(
+                    i18n.t("ui.prompt.control.variant"),
+                    props.variantKeybind,
+                    <Select
+                      size="normal"
+                      variant="ghost"
+                      options={props.variantOptions ?? []}
+                      current={props.variantCurrent}
+                      onSelect={(value) => {
+                        if (!value) return
+                        props.onVariantSelect?.(value)
+                      }}
+                      class="capitalize max-w-[160px]"
+                      valueClass="truncate text-13-regular"
+                      triggerStyle={{ height: "28px" }}
+                    />,
+                  )}
+                </Show>
+              }
+            >
+              {props.variantControl}
+            </Show>
+          </div>
+        </div>
+        <RadioGroup
+          options={["shell", "normal"] as const}
+          current={props.shell}
+          onSelect={(mode) => props.onShell(mode ?? "normal")}
+          value={(mode) => mode}
+          label={(mode) => <Icon name={mode === "shell" ? "console" : "prompt"} class="size-[18px]" />}
+          fill
+          pad="none"
+          class="w-[68px] shrink-0"
+        />
+      </div>
+
+      <Show when={props.showQuestion}>
+        <div
+          style={{
+            position: "absolute",
+            inset: 0,
+            display: "flex",
+            "align-items": "center",
+            "justify-content": "space-between",
+            padding: "22px 8px 8px",
+            opacity: props.questionOpacity,
+            filter: `blur(${props.questionBlur}px)`,
+            "pointer-events": props.morph < 0.5 ? "none" : "auto",
+          }}
+        >
+          <Button variant="ghost" size="normal" onClick={() => props.onQuestionDismiss?.()}>
+            {i18n.t("ui.common.dismiss")}
+          </Button>
+          <div style={{ display: "flex", "align-items": "center", gap: "8px" }}>
+            <Show when={(props.questionIndex ?? 0) > 0}>
+              <Button
+                variant="secondary"
+                size="normal"
+                onClick={() => props.onQuestionBack?.()}
+                disabled={props.questionBusy}
+              >
+                {i18n.t("ui.common.back")}
+              </Button>
+            </Show>
+            <Button
+              variant={(props.questionIndex ?? 0) >= (props.questionTotal ?? 1) - 1 ? "primary" : "secondary"}
+              size="normal"
+              onClick={() => props.onQuestionNext?.()}
+              disabled={props.questionBusy}
+            >
+              {(props.questionIndex ?? 0) >= (props.questionTotal ?? 1) - 1
+                ? i18n.t("ui.common.submit")
+                : i18n.t("ui.common.next")}
+            </Button>
+          </div>
+        </div>
+      </Show>
+
+      <Show when={props.showPermission}>
+        <div
+          style={{
+            position: "absolute",
+            inset: 0,
+            display: "flex",
+            "align-items": "center",
+            "justify-content": "space-between",
+            padding: "22px 8px 8px",
+            opacity: props.questionOpacity,
+            filter: `blur(${props.questionBlur}px)`,
+            "pointer-events": props.morph < 0.5 ? "none" : "auto",
+          }}
+        >
+          <div />
+          <div style={{ display: "flex", "align-items": "center", gap: "8px" }}>
+            <Button
+              variant="ghost"
+              size="normal"
+              onClick={() => props.onPermissionDecide?.("reject")}
+              disabled={props.permissionBusy}
+            >
+              {i18n.t("ui.permission.deny")}
+            </Button>
+            <Button
+              variant="secondary"
+              size="normal"
+              onClick={() => props.onPermissionDecide?.("always")}
+              disabled={props.permissionBusy}
+            >
+              {i18n.t("ui.permission.allowAlways")}
+            </Button>
+            <Button
+              variant="primary"
+              size="normal"
+              onClick={() => props.onPermissionDecide?.("once")}
+              disabled={props.permissionBusy}
+            >
+              {i18n.t("ui.permission.allowOnce")}
+            </Button>
+          </div>
+        </div>
+      </Show>
+    </div>
+  )
+}

+ 157 - 0
packages/ui/src/components/new-composer/types.ts

@@ -0,0 +1,157 @@
+import type { JSXElement } from "solid-js"
+
+export interface TodoItem {
+  content: string
+  status: "pending" | "in_progress" | "completed" | "cancelled"
+}
+
+export type ComposerMode = "normal" | "shell"
+export type ComposerSource = "enter" | "button"
+
+export type AtOption =
+  | { type: "agent"; name: string; display: string }
+  | { type: "file"; path: string; display: string; recent?: boolean }
+
+export interface SlashCommand {
+  id: string
+  trigger: string
+  title: string
+  description?: string
+  keybind?: string
+  type: "builtin" | "custom"
+  source?: "command" | "mcp" | "skill"
+}
+
+export interface ImageAttachment {
+  id: string
+  filename: string
+  mime: string
+  dataUrl: string
+}
+
+export type ComposerPart =
+  | { type: "text"; content: string }
+  | { type: "file"; path: string; content: string }
+  | { type: "agent"; name: string; content: string }
+
+export type ComposerHistoryItem = {
+  text: string
+  parts?: ComposerPart[]
+}
+
+export interface ContextItem {
+  id?: string
+  path: string
+  selection?: { startLine: number; endLine: number }
+  comment?: string
+  commentID?: string
+  commentOrigin?: "review" | "file"
+  preview?: string
+}
+
+export interface ComposerSubmit {
+  source: ComposerSource
+  mode: ComposerMode
+  text: string
+  parts: ComposerPart[]
+  files: ImageAttachment[]
+  context: ContextItem[]
+}
+
+export interface ComposerRuntime {
+  submit?: (input: ComposerSubmit) => void | Promise<void>
+  abort?: () => void | Promise<void>
+  toggleAccept?: () => void | Promise<void>
+  openModel?: () => void | Promise<void>
+  cycleAgent?: () => void | Promise<void>
+  cycleVariant?: () => void | Promise<void>
+  runSlash?: (cmd: SlashCommand) => boolean | Promise<boolean>
+  historyRead?: (mode: ComposerMode) => Array<string | ComposerHistoryItem>
+  historyWrite?: (mode: ComposerMode, list: ComposerHistoryItem[]) => void
+  decidePermission?: (response: "once" | "always" | "reject") => void | Promise<void>
+  submitQuestion?: (answers: string[][]) => void | Promise<void>
+  rejectQuestion?: () => void | Promise<void>
+  dialogActive?: () => boolean
+  readClipboardImage?: () => Promise<File | null>
+  fileRejected?: (input: { source: "paste" | "drop" | "pick"; file?: File }) => void
+  searchAt?: (filter: string) => AtOption[] | Promise<AtOption[]>
+  searchSlash?: (filter: string) => SlashCommand[] | Promise<SlashCommand[]>
+  openContext?: (item: ContextItem) => void
+  removeContext?: (item: ContextItem) => void
+}
+
+export interface NewComposerProps {
+  mode?: "input" | "question" | "permission"
+  questionText?: string
+  questionOptions?: Array<{ label: string; description?: string }>
+  questionMultiple?: boolean
+  questionAnswered?: boolean[]
+  questionAnswers?: string[][]
+  placeholder?: string
+  value?: string
+  onValueChange?: (value: string) => void
+  onSubmit?: (input: ComposerSubmit) => void | Promise<void>
+  onAbort?: () => void | Promise<void>
+  onAcceptToggle?: () => void | Promise<void>
+  dialogActive?: boolean
+  readClipboardImage?: () => Promise<File | null>
+  onFileRejected?: (input: { source: "paste" | "drop" | "pick"; file?: File }) => void
+  onSlashCommand?: (cmd: SlashCommand) => boolean | Promise<boolean>
+  historyRead?: (mode: ComposerMode) => Array<string | ComposerHistoryItem>
+  historyWrite?: (mode: ComposerMode, list: ComposerHistoryItem[]) => void
+  agentName?: string
+  modelName?: string
+  variant?: string
+  agentOptions?: string[]
+  modelOptions?: string[]
+  variantOptions?: string[]
+  agentCurrent?: string
+  modelCurrent?: string
+  variantCurrent?: string
+  onAgentSelect?: (value: string) => void
+  onModelSelect?: (value: string) => void
+  onVariantSelect?: (value: string) => void
+  onModelOpen?: () => void | Promise<void>
+  onAgentCycle?: () => void | Promise<void>
+  onVariantCycle?: () => void | Promise<void>
+  agentKeybind?: string
+  modelKeybind?: string
+  variantKeybind?: string
+  agentControl?: JSXElement
+  modelControl?: JSXElement
+  variantControl?: JSXElement
+  working?: boolean
+  accepting?: boolean
+  todos?: TodoItem[]
+  showTodos?: boolean
+  todoCollapsed?: boolean
+  onTodoCollapseChange?: (collapsed: boolean) => void
+  heightSpring?: { visualDuration: number; bounce: number }
+  morphSpring?: { visualDuration: number; bounce: number }
+  atOptions?: AtOption[] | ((filter: string) => AtOption[] | Promise<AtOption[]>)
+  slashCommands?: SlashCommand[] | ((filter: string) => SlashCommand[] | Promise<SlashCommand[]>)
+  questionIndex?: number
+  questionTotal?: number
+  onQuestionNext?: () => void
+  onQuestionBack?: () => void
+  onQuestionDismiss?: () => void
+  onQuestionSubmit?: (answers: string[][]) => void | Promise<void>
+  onQuestionReject?: () => void | Promise<void>
+  onQuestionAnswersChange?: (answers: string[][]) => void
+  onQuestionJump?: (index: number) => void
+  questionBusy?: boolean
+  contextItems?: ContextItem[]
+  contextActive?: (item: ContextItem) => boolean
+  onContextOpen?: (item: ContextItem) => void
+  onContextDrop?: (item: ContextItem) => void
+  forceDragType?: "image" | "@mention" | null
+  permissionTool?: string
+  permissionDescription?: string
+  permissionPatterns?: string[]
+  onPermissionDecide?: (response: "once" | "always" | "reject") => void | Promise<void>
+  permissionBusy?: boolean
+  runtime?: ComposerRuntime
+}
+
+export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
+export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]

+ 722 - 0
packages/ui/src/components/new-composer/use-editor.ts

@@ -0,0 +1,722 @@
+import { createEffect, createSignal, on } from "solid-js"
+import { useFilteredList } from "@opencode-ai/ui/hooks"
+import {
+  canNavigateAtCursor,
+  cloneParts,
+  navigateEntry,
+  normalizeEntry,
+  partText,
+  prependEntry,
+  type HistoryEntry,
+} from "./history"
+import {
+  createPill,
+  createTextFragment,
+  getCursorPosition,
+  isImeComposing,
+  parseEditorParts,
+  parseEditorText,
+  setCursorPosition,
+  setRangeEdge,
+} from "./editor-utils"
+import type { AtOption, ComposerHistoryItem, ComposerMode, ComposerPart, ComposerSource, SlashCommand } from "./types"
+
+interface Props {
+  value?: string
+  onValueChange?: (value: string) => void
+  onSubmit?: (input: {
+    source: ComposerSource
+    mode: ComposerMode
+    text: string
+    parts: ComposerPart[]
+  }) => void | Promise<void>
+  onAbort?: () => void | Promise<void>
+  onAuto?: () => void | Promise<void>
+  onPick?: () => void | Promise<void>
+  onModel?: () => void | Promise<void>
+  onAgent?: () => void | Promise<void>
+  onVariant?: () => void | Promise<void>
+  modelKeybind?: string
+  agentKeybind?: string
+  variantKeybind?: string
+  onSlash?: (cmd: SlashCommand) => boolean | Promise<boolean>
+  historyRead?: (mode: ComposerMode) => Array<string | ComposerHistoryItem>
+  historyWrite?: (mode: ComposerMode, list: ComposerHistoryItem[]) => void
+  working?: () => boolean
+  atOptions: AtOption[] | ((filter: string) => AtOption[] | Promise<AtOption[]>)
+  slashCommands: SlashCommand[] | ((filter: string) => SlashCommand[] | Promise<SlashCommand[]>)
+  editor: () => HTMLDivElement | undefined
+  measure: () => void
+}
+
+const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
+
+type Keybind = {
+  key: string
+  ctrl: boolean
+  meta: boolean
+  shift: boolean
+  alt: boolean
+}
+
+const normalize = (key: string, code?: string) => {
+  if (code === "Quote") return "'"
+  if (key === ",") return "comma"
+  if (key === "+") return "plus"
+  if (key === " ") return "space"
+  if (key === "Dead" && code === "Quote") return "'"
+  return key.toLowerCase()
+}
+
+const parse = (config: string | undefined): Keybind[] => {
+  if (!config || config === "none") return []
+  return config.split(",").map((combo) => {
+    const out: Keybind = {
+      key: "",
+      ctrl: false,
+      meta: false,
+      shift: false,
+      alt: false,
+    }
+    for (const part of combo.trim().toLowerCase().split("+")) {
+      if (part === "ctrl" || part === "control") {
+        out.ctrl = true
+        continue
+      }
+      if (part === "meta" || part === "cmd" || part === "command") {
+        out.meta = true
+        continue
+      }
+      if (part === "mod") {
+        if (IS_MAC) out.meta = true
+        else out.ctrl = true
+        continue
+      }
+      if (part === "alt" || part === "option") {
+        out.alt = true
+        continue
+      }
+      if (part === "shift") {
+        out.shift = true
+        continue
+      }
+      out.key = part
+    }
+    return out
+  })
+}
+
+const match = (config: string | undefined, event: KeyboardEvent) => {
+  const key = normalize(event.key, event.code)
+  return parse(config).some((kb) => {
+    return (
+      kb.key === key &&
+      kb.ctrl === !!event.ctrlKey &&
+      kb.meta === !!event.metaKey &&
+      kb.shift === !!event.shiftKey &&
+      kb.alt === !!event.altKey
+    )
+  })
+}
+
+export function useEditor(props: Props) {
+  const [value, setValue] = createSignal(props.value ?? "")
+  const [mode, setMode] = createSignal<ComposerMode>("normal")
+  const [popover, setPopover] = createSignal<"at" | "slash" | null>(null)
+  const [composing, setComposing] = createSignal(false)
+
+  const readList = (kind: ComposerMode) => {
+    const list = props.historyRead?.(kind) ?? []
+    return list.map(normalizeEntry)
+  }
+
+  const [history, setHistory] = createSignal<Record<ComposerMode, HistoryEntry[]>>({
+    normal: readList("normal"),
+    shell: readList("shell"),
+  })
+  const [index, setIndex] = createSignal(-1)
+  const [saved, setSaved] = createSignal<HistoryEntry | null>(null)
+
+  const setList = (next: HistoryEntry[]) => {
+    const kind = mode()
+    setHistory((prev) => ({ ...prev, [kind]: next }))
+    props.historyWrite?.(
+      kind,
+      next.map((item) => ({
+        text: item.text,
+        parts: cloneParts(item.parts),
+      })),
+    )
+  }
+
+  const getList = () => history()[mode()]
+
+  const setEditorMode = (next: ComposerMode) => {
+    if (next !== mode() && props.historyRead) {
+      setHistory((prev) => ({ ...prev, [next]: readList(next) }))
+    }
+    setMode(next)
+    setIndex(-1)
+    setSaved(null)
+    setPopover(null)
+  }
+
+  const atKey = (item: AtOption) => (item.type === "file" ? item.path : `agent:${item.name}`)
+  const at = useFilteredList<AtOption>({
+    items: props.atOptions,
+    key: atKey,
+    filterKeys: ["display", "name", "path"],
+    onSelect: (item) => {
+      if (!item) return
+      insertAt(item)
+    },
+  })
+
+  const slash = useFilteredList<SlashCommand>({
+    items: props.slashCommands,
+    key: (x) => x.id,
+    filterKeys: ["trigger", "title"],
+    onSelect: (item) => {
+      if (!item) return
+      insertSlash(item)
+    },
+  })
+
+  createEffect(
+    on(
+      () => props.value,
+      (next) => {
+        if (next === undefined || next === value()) return
+        setText(next)
+      },
+      { defer: true },
+    ),
+  )
+
+  const keepCursor = () => {
+    const el = props.editor()
+    if (!el) return
+    const viewport = el.parentElement
+    if (!(viewport instanceof HTMLElement)) return
+    if (el.scrollHeight <= viewport.clientHeight + 2) return
+
+    const selection = window.getSelection()
+    if (!selection || selection.rangeCount === 0) return
+    const range = selection.getRangeAt(0)
+    if (!el.contains(range.startContainer)) return
+
+    const rect = range.getClientRects()[0] ?? range.getBoundingClientRect()
+    const box = viewport.getBoundingClientRect()
+    const pad = Math.max(4, Math.min(12, Math.floor(viewport.clientHeight * 0.25)))
+    const top = box.top + pad
+    const bottom = box.bottom - pad
+
+    if (rect.top < top) {
+      viewport.scrollTop -= top - rect.top
+      return
+    }
+
+    if (rect.bottom > bottom) {
+      viewport.scrollTop += rect.bottom - bottom
+    }
+  }
+
+  const handleInput = () => {
+    const el = props.editor()
+    if (!el) return
+    const text = parseEditorText(el)
+    setValue(text)
+    props.onValueChange?.(text)
+    props.measure()
+
+    if (mode() !== "shell") {
+      const cursor = getCursorPosition(el)
+      const head = text.substring(0, cursor)
+      const atMatch = head.match(/@(\S*)$/)
+      const slashMatch = text.match(/^\/(\S*)$/)
+
+      if (atMatch) {
+        at.onInput(atMatch[1] ?? "")
+        setPopover("at")
+      } else if (slashMatch) {
+        slash.onInput(slashMatch[1] ?? "")
+        setPopover("slash")
+      } else {
+        setPopover(null)
+      }
+    }
+
+    if (mode() === "shell") setPopover(null)
+
+    setIndex(-1)
+    setSaved(null)
+  }
+
+  const setParts = (parts: ComposerPart[], cursor?: number) => {
+    const el = props.editor()
+    if (!el) return
+
+    el.textContent = ""
+
+    for (const part of parts) {
+      if (part.type === "text") {
+        if (!part.content) continue
+        el.appendChild(createTextFragment(part.content))
+        continue
+      }
+
+      if (part.type === "file") {
+        el.appendChild(createPill("file", part.content || `@${part.path}`, part.path))
+        continue
+      }
+
+      el.appendChild(createPill("agent", part.content || `@${part.name}`))
+    }
+
+    const text = partText(parts)
+    if (cursor === undefined) {
+      setCursorPosition(el, text.length)
+    } else {
+      setCursorPosition(el, cursor)
+    }
+
+    setValue(text)
+    props.onValueChange?.(text)
+    props.measure()
+    requestAnimationFrame(keepCursor)
+  }
+
+  const setText = (text: string) => {
+    setParts([{ type: "text", content: text }], text.length)
+  }
+
+  const setEntry = (item: HistoryEntry, cursor: "start" | "end" = "end") => {
+    setParts(item.parts, cursor === "start" ? 0 : partText(item.parts).length)
+  }
+
+  const deleteSelection = () => {
+    const el = props.editor()
+    if (!el) return false
+    const selection = window.getSelection()
+    if (!selection || selection.rangeCount === 0) return false
+    if (selection.isCollapsed) return false
+
+    const range = selection.getRangeAt(0)
+    if (!el.contains(range.startContainer) || !el.contains(range.endContainer)) return false
+
+    range.deleteContents()
+    selection.removeAllRanges()
+    selection.addRange(range)
+    handleInput()
+    return true
+  }
+
+  const deleteSpan = (start: number, end: number) => {
+    const el = props.editor()
+    if (!el) return false
+    if (start >= end) return false
+
+    const range = document.createRange()
+    range.selectNodeContents(el)
+    setRangeEdge(el, range, "start", start)
+    setRangeEdge(el, range, "end", end)
+    range.deleteContents()
+    range.collapse(true)
+
+    const selection = window.getSelection()
+    selection?.removeAllRanges()
+    selection?.addRange(range)
+
+    handleInput()
+    return true
+  }
+
+  const lineStart = (text: string, cursor: number) => {
+    const i = text.lastIndexOf("\n", Math.max(0, cursor - 1))
+    if (i < 0) return 0
+    return i + 1
+  }
+
+  const lineEnd = (text: string, cursor: number) => {
+    const i = text.indexOf("\n", cursor)
+    if (i < 0) return text.length
+    return i
+  }
+
+  const deleteWordBackward = () => {
+    const el = props.editor()
+    if (!el) return false
+    const text = parseEditorText(el)
+    const cursor = getCursorPosition(el)
+    if (cursor <= 0) return false
+
+    let i = cursor
+    while (i > 0 && /\s/.test(text[i - 1] ?? "")) i -= 1
+    while (i > 0 && !/\s/.test(text[i - 1] ?? "")) i -= 1
+    if (i === cursor) i = cursor - 1
+
+    return deleteSpan(i, cursor)
+  }
+
+  const deleteLineForward = () => {
+    const el = props.editor()
+    if (!el) return false
+    const text = parseEditorText(el)
+    const cursor = getCursorPosition(el)
+    if (cursor >= text.length) return false
+
+    let i = lineEnd(text, cursor)
+    if (i === cursor && text[i] === "\n") i += 1
+    return deleteSpan(cursor, Math.min(i, text.length))
+  }
+
+  const deleteLineBackward = () => {
+    const el = props.editor()
+    if (!el) return false
+    const text = parseEditorText(el)
+    const cursor = getCursorPosition(el)
+    if (cursor <= 0) return false
+    return deleteSpan(lineStart(text, cursor), cursor)
+  }
+
+  const insertAt = (item: AtOption) => {
+    const el = props.editor()
+    if (!el) return
+
+    const getRange = () => {
+      const selection = window.getSelection()
+      if (!selection || selection.rangeCount === 0) return null
+      const range = selection.getRangeAt(0)
+      if (!el.contains(range.startContainer)) return null
+      return { selection, range }
+    }
+
+    let current = getRange()
+    if (!current) {
+      el.focus()
+      setCursorPosition(el, parseEditorText(el).length)
+      current = getRange()
+    }
+    if (!current) return
+
+    const selection = current.selection
+    const range = current.range
+
+    const cursor = getCursorPosition(el)
+    const text = parseEditorText(el)
+    const head = text.substring(0, cursor)
+    const match = head.match(/@(\S*)$/)
+
+    const label = item.type === "file" ? item.path : `@${item.name}`
+    const pill = createPill(item.type, label, item.type === "file" ? item.path : undefined)
+    const gap = document.createTextNode(" ")
+
+    if (match) {
+      const start = match.index ?? cursor - match[0].length
+      setRangeEdge(el, range, "start", start)
+      setRangeEdge(el, range, "end", cursor)
+    }
+
+    range.deleteContents()
+    range.insertNode(gap)
+    range.insertNode(pill)
+    range.setStartAfter(gap)
+    range.collapse(true)
+    selection.removeAllRanges()
+    selection.addRange(range)
+
+    handleInput()
+    setPopover(null)
+  }
+
+  const applySlash = (cmd: SlashCommand) => {
+    const el = props.editor()
+    if (!el) return
+    el.textContent = ""
+    el.appendChild(document.createTextNode(`/${cmd.trigger} `))
+    setCursorPosition(el, cmd.trigger.length + 2)
+    handleInput()
+    setPopover(null)
+  }
+
+  const insertSlash = (cmd: SlashCommand) => {
+    const run = props.onSlash?.(cmd)
+    if (run instanceof Promise) {
+      void run
+        .then((ok) => {
+          if (ok) {
+            setText("")
+            setPopover(null)
+            return
+          }
+          applySlash(cmd)
+        })
+        .catch(() => {
+          applySlash(cmd)
+        })
+      return
+    }
+
+    if (run) {
+      setText("")
+      setPopover(null)
+      return
+    }
+
+    applySlash(cmd)
+  }
+
+  const insertFile = (path: string) => {
+    insertAt({
+      type: "file",
+      path,
+      display: path,
+    })
+  }
+
+  const parts = (): ComposerPart[] => {
+    const el = props.editor()
+    if (!el) return []
+    return parseEditorParts(el)
+  }
+
+  const submit = (source: ComposerSource) => {
+    const el = props.editor()
+    if (!el) return
+
+    const text = parseEditorText(el)
+    const clean = text.trim()
+    const parsed = parseEditorParts(el)
+    if (clean) {
+      const list = getList()
+      const next = {
+        text,
+        parts: cloneParts(parsed),
+      } satisfies HistoryEntry
+      const out = prependEntry(list, next)
+      if (out !== list) setList(out)
+    }
+
+    void props.onSubmit?.({ source, mode: mode(), text, parts: parsed })
+  }
+
+  const abort = () => {
+    if (!props.working?.()) return
+    void props.onAbort?.()
+  }
+
+  const handleKeyDown = (event: KeyboardEvent) => {
+    const el = props.editor()
+    if (!el) return
+
+    const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey
+    const cmd = event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey
+    const mod = (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey
+
+    if (ctrl && event.key.toLowerCase() === "w") {
+      event.preventDefault()
+      if (deleteSelection()) return
+      deleteWordBackward()
+      return
+    }
+
+    if (ctrl && event.key === "Backspace") {
+      event.preventDefault()
+      if (deleteSelection()) return
+      deleteWordBackward()
+      return
+    }
+
+    if (!event.metaKey && !event.ctrlKey && !event.shiftKey && event.key === "Backspace" && event.altKey) {
+      event.preventDefault()
+      if (deleteSelection()) return
+      deleteWordBackward()
+      return
+    }
+
+    if (ctrl && event.key.toLowerCase() === "k") {
+      event.preventDefault()
+      if (deleteSelection()) return
+      deleteLineForward()
+      return
+    }
+
+    if (cmd && event.key === "Backspace") {
+      event.preventDefault()
+      if (deleteSelection()) return
+      deleteLineBackward()
+      return
+    }
+
+    if (cmd && event.key === "Delete") {
+      event.preventDefault()
+      if (deleteSelection()) return
+      deleteLineForward()
+      return
+    }
+
+    if (match(props.modelKeybind ?? "mod+'", event)) {
+      event.preventDefault()
+      event.stopPropagation()
+      void props.onModel?.()
+      return
+    }
+
+    if (match(props.variantKeybind ?? "shift+mod+d", event)) {
+      event.preventDefault()
+      event.stopPropagation()
+      void props.onVariant?.()
+      return
+    }
+
+    if (match(props.agentKeybind ?? "mod+.", event)) {
+      event.preventDefault()
+      event.stopPropagation()
+      void props.onAgent?.()
+      return
+    }
+
+    if (mod && event.key.toLowerCase() === "u") {
+      event.preventDefault()
+      if (mode() !== "normal") return
+      void props.onPick?.()
+      return
+    }
+
+    if (event.key === "!" && mode() === "normal") {
+      const cursor = getCursorPosition(el)
+      if (cursor === 0) {
+        setEditorMode("shell")
+        event.preventDefault()
+        return
+      }
+    }
+
+    if (event.key === "Escape") {
+      if (popover()) {
+        setPopover(null)
+        event.preventDefault()
+        event.stopPropagation()
+        return
+      }
+      if (mode() === "shell") {
+        setEditorMode("normal")
+        event.preventDefault()
+        event.stopPropagation()
+        return
+      }
+
+      if (props.working?.()) {
+        abort()
+        event.preventDefault()
+        event.stopPropagation()
+        return
+      }
+    }
+
+    if (mode() === "shell" && event.key === "Backspace") {
+      if (!parseEditorText(el).trim()) {
+        setEditorMode("normal")
+        event.preventDefault()
+        return
+      }
+    }
+
+    if (event.key === "Enter" && event.shiftKey) return
+    if (event.key === "Enter" && isImeComposing(event)) return
+
+    if (popover()) {
+      if (event.key === "Tab") {
+        if (popover() === "at") {
+          const selected = at.flat().find((x) => atKey(x) === at.active())
+          if (selected) insertAt(selected)
+        } else {
+          const selected = slash.flat().find((x) => x.id === slash.active())
+          if (selected) insertSlash(selected)
+        }
+        event.preventDefault()
+        return
+      }
+
+      const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter"
+      const ctrlNav = ctrl && (event.key === "n" || event.key === "p")
+      if (nav || ctrlNav) {
+        if (popover() === "at") at.onKeyDown(event)
+        else slash.onKeyDown(event)
+        event.preventDefault()
+        return
+      }
+    }
+
+    if (ctrl && event.code === "KeyG") {
+      if (popover()) {
+        setPopover(null)
+        event.preventDefault()
+        return
+      }
+      abort()
+      event.preventDefault()
+      return
+    }
+
+    const up = event.key === "ArrowUp" || (ctrl && event.key === "p")
+    const down = event.key === "ArrowDown" || (ctrl && event.key === "n")
+    if (up || down) {
+      if (event.altKey || event.metaKey) return
+      if (event.ctrlKey && !(event.key === "n" || event.key === "p")) return
+      if (popover()) return
+
+      const text = parseEditorText(el)
+      const cursor = getCursorPosition(el)
+      const list = getList()
+
+      const dir = up ? "up" : "down"
+      if (!canNavigateAtCursor(dir, text, cursor, index() >= 0)) return
+
+      const out = navigateEntry({
+        direction: dir,
+        entries: list,
+        historyIndex: index(),
+        current: {
+          text,
+          parts: cloneParts(parseEditorParts(el)),
+        },
+        saved: saved(),
+      })
+
+      if (!out.handled) return
+      setIndex(out.historyIndex)
+      setSaved(out.saved)
+      setEntry(out.entry, out.cursor)
+      event.preventDefault()
+      return
+    }
+
+    if (event.key === "Enter" && !event.shiftKey) {
+      event.preventDefault()
+      submit("enter")
+    }
+  }
+
+  return {
+    value,
+    setValue,
+    popover,
+    setPopover,
+    mode,
+    setMode: setEditorMode,
+    composing,
+    setComposing,
+    atKey,
+    at,
+    slash,
+    handleInput,
+    handleKeyDown,
+    setText,
+    submit,
+    parts,
+    insertAt,
+    insertFile,
+    insertSlash,
+  }
+}

+ 87 - 0
packages/ui/src/components/new-composer/use-layout.ts

@@ -0,0 +1,87 @@
+import { createEffect, createMemo, createSignal } from "solid-js"
+import { useElementHeight } from "@opencode-ai/ui/hooks"
+import { useSpring } from "@opencode-ai/ui/motion-spring"
+
+const EDITOR_PADDING = 16
+const MAX_EDITOR_CONTENT = 200 - EDITOR_PADDING
+const BUTTON_ROW_HEIGHT = 40
+const IMAGE_BAR_HEIGHT = 80
+const CONTEXT_BAR_HEIGHT = 64
+const SHELL_PADDING_INPUT = EDITOR_PADDING + BUTTON_ROW_HEIGHT
+const SHELL_PADDING_DOCK = 16
+
+interface Props {
+  isQuestion: () => boolean
+  isPermission: () => boolean
+  imageCount: () => number
+  contextCount: () => number
+  heightSpring?: { visualDuration: number; bounce: number }
+  morphSpring?: { visualDuration: number; bounce: number }
+}
+
+export function useLayout(props: Props) {
+  const [editorHeight, setEditorHeight] = createSignal(24)
+  const [questionRef, setQuestionRef] = createSignal<HTMLDivElement>()
+  const [permissionRef, setPermissionRef] = createSignal<HTMLDivElement>()
+
+  const questionHeight = useElementHeight(questionRef, 280)
+  const permissionHeight = useElementHeight(permissionRef, 200)
+
+  const imageBarHeight = createMemo(() => (props.imageCount() > 0 ? IMAGE_BAR_HEIGHT : 0))
+  const contextBarHeight = createMemo(() => (props.contextCount() > 0 ? CONTEXT_BAR_HEIGHT : 0))
+
+  const target = createMemo(() => {
+    if (props.isPermission()) return permissionHeight() + SHELL_PADDING_DOCK
+    if (props.isQuestion()) return questionHeight() + SHELL_PADDING_DOCK
+    return editorHeight() + SHELL_PADDING_INPUT + imageBarHeight() + contextBarHeight()
+  })
+
+  const morphTarget = () => (props.isQuestion() || props.isPermission() ? 1 : 0)
+  const morph = useSpring(morphTarget, () => props.morphSpring ?? { visualDuration: 0.25, bounce: 0.1 })
+
+  const spring = useSpring(target, () => props.heightSpring ?? { visualDuration: 0.35, bounce: 0.2 })
+
+  const inputOpacity = createMemo(() => Math.max(0, 1 - morph() * 1.5))
+  const inputScale = createMemo(() => 1 + morph() * 0.15)
+  const inputBlur = createMemo(() => morph() * 5)
+  const questionOpacity = createMemo(() => Math.max(0, morph() * 1.5 - 0.5))
+  const questionScale = createMemo(() => 0.85 + morph() * 0.15)
+  const questionBlur = createMemo(() => (1 - morph()) * 5)
+
+  // Keep overlay content mounted until both morph and height springs settle
+  const [overlay, setOverlay] = createSignal<"question" | "permission" | null>(null)
+  createEffect(() => {
+    if (props.isQuestion()) setOverlay("question")
+    else if (props.isPermission()) setOverlay("permission")
+    else if (morph() < 0.01 && Math.abs(spring() - target()) < 2) setOverlay(null)
+  })
+  const showQuestion = () => overlay() === "question"
+  const showPermission = () => overlay() === "permission"
+
+  const height = () => spring()
+
+  const measure = (el: HTMLDivElement | undefined) => {
+    if (!el) return
+    const raw = Math.ceil(el.scrollHeight - EDITOR_PADDING)
+    const next = Math.min(MAX_EDITOR_CONTENT, Math.max(24, raw))
+    const curr = editorHeight()
+    if (Math.abs(next - curr) <= 2) return
+    setEditorHeight(next)
+  }
+
+  return {
+    measure,
+    setQuestionRef,
+    setPermissionRef,
+    height,
+    morph,
+    inputOpacity,
+    inputScale,
+    inputBlur,
+    questionOpacity,
+    questionScale,
+    questionBlur,
+    showQuestion,
+    showPermission,
+  }
+}

+ 76 - 0
packages/ui/src/components/new-composer/use-todo.ts

@@ -0,0 +1,76 @@
+import { createEffect, createMemo, createSignal, on } from "solid-js"
+import { useElementHeight } from "@opencode-ai/ui/hooks"
+import { useSpring } from "@opencode-ai/ui/motion-spring"
+import type { TodoItem } from "./types"
+
+export const COLLAPSED_HEIGHT = 78
+
+interface Props {
+  todos: () => TodoItem[]
+  show: () => boolean
+  blocked: () => boolean
+  collapsed?: () => boolean | undefined
+  onCollapsed?: (value: boolean) => void
+  shellHeight: () => number
+  trayHeight: () => number
+  trayOverlap: number
+}
+
+export function useTodo(props: Props) {
+  const [collapsed, setCollapsedInner] = createSignal(props.collapsed?.() ?? false)
+  const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
+
+  createEffect(
+    on(
+      () => props.collapsed?.(),
+      (next) => {
+        if (next === undefined) return
+        setCollapsedInner(next)
+      },
+      { defer: true },
+    ),
+  )
+
+  const setCollapsed = (value: boolean | ((prev: boolean) => boolean)) => {
+    const next = typeof value === "function" ? value(collapsed()) : value
+    setCollapsedInner(next)
+    props.onCollapsed?.(next)
+  }
+
+  const toggle = () => setCollapsed((prev) => !prev)
+
+  const open = createMemo(() => props.todos().length > 0 && props.show() && !props.blocked())
+  const progress = useSpring(() => (open() ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
+  const collapse = useSpring(() => (collapsed() ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
+
+  const raw = useElementHeight(contentRef, 200)
+  const full = useSpring(() => Math.max(COLLAPSED_HEIGHT, raw()), { visualDuration: 0.3, bounce: 0 })
+  const visible = createMemo(() => {
+    const max = full()
+    const cut = max - collapse() * (max - COLLAPSED_HEIGHT)
+    return cut * progress()
+  })
+
+  const overlap = createMemo(() => 36 * progress())
+  const shut = createMemo(() => 1 - progress())
+  const hide = createMemo(() => Math.max(collapse(), shut()))
+
+  const bottom = createMemo(() => props.trayHeight() - props.trayOverlap + props.shellHeight() - overlap())
+  const totalHeight = createMemo(
+    () => visible() - overlap() + props.shellHeight() + props.trayHeight() - props.trayOverlap,
+  )
+
+  return {
+    contentRef: setContentRef,
+    collapsed,
+    setCollapsed,
+    toggle,
+    progress,
+    collapse,
+    visible,
+    shut,
+    hide,
+    bottom,
+    totalHeight,
+  }
+}

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

@@ -0,0 +1,92 @@
+[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);
+  }
+}

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

@@ -0,0 +1,316 @@
+import { For, Show, batch, createEffect, createMemo, createSignal, on, onCleanup, onMount, type JSX } from "solid-js"
+import { animate, clearMaskStyles, GROW_SPRING, type AnimationPlaybackControls, type SpringConfig } from "./motion"
+import { prefersReducedMotion } from "../hooks/use-reduced-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
+}
+
+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 reducedMotion = prefersReducedMotion
+
+  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) && !reducedMotion())
+  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)
+        clearEdge()
+        return
+      }
+      const collapsing = next === 0 && prev !== undefined && prev > 0
+      const expanding = prev === 0 && next > 0
+      resize?.stop()
+      applyEdge()
+      const spring = props.spring ?? GROW_SPRING
+      const anim = collapsing
+        ? animate(view, { height: `${next}px`, opacity: 0 }, spring)
+        : expanding
+          ? animate(view, { height: `${next}px`, opacity: [0, 1] }, spring)
+          : animate(view, { height: `${next}px` }, spring)
+      resize = anim
+      anim.finished
+        .catch(() => {})
+        .finally(() => {
+          if (resize !== anim) return
+          setView(next)
+          if (collapsing || expanding) view!.style.opacity = ""
+          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>
+  )
+}

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

@@ -9,6 +9,9 @@
   overflow-y: auto;
   scrollbar-width: none;
   outline: none;
+  display: flex;
+  flex-direction: column-reverse;
+  overflow-anchor: none;
 }
 
 .scroll-view__viewport::-webkit-scrollbar {
@@ -45,18 +48,6 @@
   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;
 }

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

@@ -1,17 +1,17 @@
-import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js"
+import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show } from "solid-js"
+import { animate, type AnimationPlaybackControls } from "motion"
 import { useI18n } from "../context/i18n"
+import { FAST_SPRING } from "./motion"
 
 export interface ScrollViewProps extends ComponentProps<"div"> {
   viewportRef?: (el: HTMLDivElement) => void
-  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(
-    merged,
-    ["class", "children", "viewportRef", "orientation", "style"],
+    props,
+    ["class", "children", "viewportRef", "style"],
     [
       "onScroll",
       "onWheel",
@@ -25,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)
@@ -57,9 +57,12 @@ export function ScrollView(props: ScrollViewProps) {
     const maxScrollTop = scrollHeight - clientHeight
     const maxThumbTop = trackHeight - height
 
-    const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0
+    // With column-reverse: scrollTop=0 is at bottom, negative = scrolled up
+    // Normalize so 0 = at top, maxScrollTop = at bottom
+    const normalizedScrollTop = maxScrollTop + scrollTop
+    const top = maxScrollTop > 0 ? (normalizedScrollTop / maxScrollTop) * maxThumbTop : 0
 
-    // Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety)
+    // Ensure thumb stays within bounds
     const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop))
 
     setThumbHeight(height)
@@ -82,6 +85,7 @@ export function ScrollView(props: ScrollViewProps) {
     }
 
     onCleanup(() => {
+      stop()
       observer.disconnect()
     })
 
@@ -123,6 +127,30 @@ 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
+    return Math.max(-max, Math.min(0, 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,
@@ -147,11 +175,13 @@ export function ScrollView(props: ScrollViewProps) {
         break
       case "Home":
         e.preventDefault()
-        viewportRef.scrollTo({ top: 0, behavior: "smooth" })
+        // With column-reverse, top of content = -(scrollHeight - clientHeight)
+        glide(-(viewportRef.scrollHeight - viewportRef.clientHeight))
         break
       case "End":
         e.preventDefault()
-        viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" })
+        // With column-reverse, bottom of content = 0
+        glide(0)
         break
       case "ArrowUp":
         e.preventDefault()
@@ -166,7 +196,6 @@ export function ScrollView(props: ScrollViewProps) {
 
   return (
     <div
-      ref={rootRef}
       class={`scroll-view ${local.class || ""}`}
       style={local.style}
       onPointerEnter={() => setIsHovered(true)}
@@ -181,12 +210,21 @@ export function ScrollView(props: ScrollViewProps) {
           updateThumb()
           if (typeof events.onScroll === "function") events.onScroll(e as any)
         }}
-        onWheel={events.onWheel as any}
-        onTouchStart={events.onTouchStart 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)
+        }}
         onTouchMove={events.onTouchMove as any}
         onTouchEnd={events.onTouchEnd as any}
         onTouchCancel={events.onTouchCancel as any}
-        onPointerDown={events.onPointerDown as any}
+        onPointerDown={(e) => {
+          stop()
+          if (typeof events.onPointerDown === "function") events.onPointerDown(e as any)
+        }}
         onClick={events.onClick as any}
         tabIndex={0}
         role="region"

+ 1 - 1
packages/ui/src/components/session-review.tsx

@@ -145,7 +145,7 @@ export const SessionReview = (props: SessionReviewProps) => {
   const searchHandles = new Map<string, FileSearchHandle>()
   const readyFiles = new Set<string>()
   const [store, setStore] = createStore<{ open: string[]; force: Record<string, boolean> }>({
-    open: [],
+    open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file),
     force: {},
   })
 

+ 1392 - 0
packages/ui/src/components/session-timeline-simulator.stories.tsx

@@ -0,0 +1,1392 @@
+// @ts-nocheck
+import { createSignal, createMemo, createEffect, on, onCleanup, batch, For } from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import type {
+  Message,
+  UserMessage,
+  AssistantMessage,
+  Part,
+  TextPart,
+  ReasoningPart,
+  ToolPart,
+  SessionStatus,
+} from "@opencode-ai/sdk/v2"
+import { DataProvider } from "../context/data"
+import { FileComponentProvider } from "../context/file"
+import { SessionTurn } from "./session-turn"
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+
+const SESSION_ID = "sim-session-1"
+const T0 = Date.now()
+
+// ---------------------------------------------------------------------------
+// Timeline event types
+// ---------------------------------------------------------------------------
+
+type TimelineEvent =
+  | { type: "message"; message: Message }
+  | { type: "part"; part: Part }
+  | { type: "part-update"; messageID: string; partID: string; patch: Record<string, any> }
+  | { type: "status"; status: SessionStatus }
+  | { type: "delay"; ms: number; label?: string }
+
+// ---------------------------------------------------------------------------
+// Helpers to build mock data
+// ---------------------------------------------------------------------------
+
+let _pid = 0
+const pid = () => `p-${++_pid}`
+const cid = () => `c-${_pid}`
+
+function mkUser(id: string): UserMessage {
+  return {
+    id,
+    sessionID: SESSION_ID,
+    role: "user",
+    time: { created: T0 },
+    agent: "assistant",
+    model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" },
+  }
+}
+
+function mkAssistant(id: string, parentID: string, completed?: number): AssistantMessage {
+  return {
+    id,
+    sessionID: SESSION_ID,
+    role: "assistant",
+    time: { created: T0 + 100, completed },
+    parentID,
+    modelID: "claude-sonnet-4-20250514",
+    providerID: "anthropic",
+    mode: "default",
+    agent: "assistant",
+    path: { cwd: "/Users/kit/project", root: "/Users/kit/project" },
+    cost: 0,
+    tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+  }
+}
+
+function mkText(messageID: string, text: string): TextPart {
+  return { id: pid(), sessionID: SESSION_ID, messageID, type: "text", text }
+}
+
+function mkReasoning(messageID: string, text: string): ReasoningPart {
+  return {
+    id: pid(),
+    sessionID: SESSION_ID,
+    messageID,
+    type: "reasoning",
+    text,
+    time: { start: T0 + 200 },
+  }
+}
+
+function mkTool(messageID: string, tool: string, input: Record<string, unknown>): ToolPart {
+  const id = pid()
+  return {
+    id,
+    sessionID: SESSION_ID,
+    messageID,
+    type: "tool",
+    callID: cid(),
+    tool,
+    state: { status: "pending", input, raw: JSON.stringify(input) },
+  }
+}
+
+function toolRunning(part: ToolPart, title: string, t: number): Record<string, any> {
+  return {
+    state: { status: "running", input: part.state.input, title, time: { start: t } },
+  }
+}
+
+function toolCompleted(
+  part: ToolPart,
+  title: string,
+  output: string,
+  tStart: number,
+  tEnd: number,
+): Record<string, any> {
+  return {
+    state: {
+      status: "completed",
+      input: part.state.input,
+      output,
+      title,
+      metadata: {},
+      time: { start: tStart, end: tEnd },
+    },
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Build the timeline
+// ---------------------------------------------------------------------------
+
+function buildTimeline(): TimelineEvent[] {
+  _pid = 0
+  return []
+}
+
+// ---------------------------------------------------------------------------
+// Store-backed playback engine
+// ---------------------------------------------------------------------------
+
+function createPlayback(events: TimelineEvent[]) {
+  const [step, setStep] = createSignal(0)
+  const [playing, setPlaying] = createSignal(false)
+  const [speed, setSpeed] = createSignal(1)
+  const [totalSteps, setTotalSteps] = createSignal(events.length)
+
+  // Reactive store shaped exactly like Data from context/data.tsx
+  const [data, setData] = createStore({
+    session: [],
+    session_status: {},
+    session_diff: {},
+    message: {},
+    part: {},
+  })
+
+  // Apply a single event to the store
+  function applyEvent(event: TimelineEvent) {
+    switch (event.type) {
+      case "status":
+        setData("session_status", SESSION_ID, event.status)
+        break
+
+      case "message":
+        setData(
+          produce((d) => {
+            if (!d.message[SESSION_ID]) d.message[SESSION_ID] = []
+            const list = d.message[SESSION_ID]
+            const idx = list.findIndex((m) => m.id === event.message.id)
+            if (idx >= 0) {
+              list[idx] = event.message
+            } else {
+              list.push(event.message)
+            }
+          }),
+        )
+        break
+
+      case "part":
+        setData(
+          produce((d) => {
+            const mid = event.part.messageID
+            if (!d.part[mid]) d.part[mid] = []
+            d.part[mid].push(event.part)
+          }),
+        )
+        break
+
+      case "part-update":
+        setData(
+          produce((d) => {
+            const list = d.part[event.messageID]
+            if (!list) return
+            const idx = list.findIndex((p) => p.id === event.partID)
+            if (idx < 0) return
+            Object.assign(list[idx], event.patch)
+          }),
+        )
+        break
+    }
+  }
+
+  // Reset the store to empty
+  function resetStore() {
+    setData({
+      session: [],
+      session_status: {},
+      session_diff: {},
+      message: {},
+      part: {},
+    })
+  }
+
+  // Replay events [0, target) into a fresh store
+  function replayTo(target: number) {
+    resetStore()
+    batch(() => {
+      for (let i = 0; i < target && i < events.length; i++) {
+        applyEvent(events[i])
+      }
+    })
+  }
+
+  // When step changes, figure out if we can just apply forward or need a full replay
+  let appliedStep = 0
+
+  createEffect(
+    on(step, (target) => {
+      if (target > appliedStep) {
+        // Forward: apply events [appliedStep, target)
+        batch(() => {
+          for (let i = appliedStep; i < target && i < events.length; i++) {
+            applyEvent(events[i])
+          }
+        })
+      } else if (target < appliedStep) {
+        // Backward: full replay
+        replayTo(target)
+      }
+      appliedStep = target
+    }),
+  )
+
+  // Auto-play timer
+  let timer: ReturnType<typeof setTimeout> | undefined
+
+  const stopTimer = () => {
+    if (timer !== undefined) {
+      clearTimeout(timer)
+      timer = undefined
+    }
+  }
+
+  const scheduleNext = () => {
+    stopTimer()
+    if (!playing()) return
+    const current = step()
+    if (current >= totalSteps()) {
+      setPlaying(false)
+      return
+    }
+    const event = events[current]
+    const delay = event?.type === "delay" ? Math.max(20, event.ms / speed()) : 60 / speed()
+
+    timer = setTimeout(() => {
+      if (!playing()) return
+      const next = step() + 1
+      if (next > totalSteps()) {
+        setPlaying(false)
+        return
+      }
+      setStep(next)
+      scheduleNext()
+    }, delay)
+  }
+
+  const play = () => {
+    if (step() >= totalSteps()) {
+      setStep(0)
+      appliedStep = 0
+      resetStore()
+    }
+    setPlaying(true)
+    scheduleNext()
+  }
+
+  const pause = () => {
+    setPlaying(false)
+    stopTimer()
+  }
+
+  const togglePlay = () => (playing() ? pause() : play())
+
+  const stepForward = () => {
+    pause()
+    let next = step() + 1
+    while (next < totalSteps() && events[next]?.type === "delay") next++
+    setStep(Math.min(next, totalSteps()))
+  }
+
+  const stepBack = () => {
+    pause()
+    let next = step() - 1
+    while (next > 0 && events[next - 1]?.type === "delay") next--
+    setStep(Math.max(next, 0))
+  }
+
+  const reset = () => {
+    pause()
+    setStep(0)
+    appliedStep = 0
+    resetStore()
+  }
+
+  const jumpTo = (s: number) => {
+    pause()
+    setStep(Math.max(0, Math.min(s, totalSteps())))
+  }
+
+  // Append new events and auto-play through them.
+  // If already auto-advancing, the new events are just appended and the existing
+  // advance loop picks them up seamlessly.
+  let appendTimer: ReturnType<typeof setTimeout> | undefined
+  let advancing = false
+
+  const startAdvance = () => {
+    if (advancing) return // already running, it will pick up new events
+    advancing = true
+    const advance = () => {
+      const current = step()
+      const total = events.length
+      if (current >= total) {
+        advancing = false
+        appendTimer = undefined
+        return
+      }
+      const next = current + 1
+      const ev = events[current]
+      const d = ev?.type === "delay" ? Math.max(20, ev.ms) : 40
+      setStep(next)
+      if (next < events.length) {
+        appendTimer = setTimeout(advance, d)
+      } else {
+        advancing = false
+        appendTimer = undefined
+      }
+    }
+    advance()
+  }
+
+  const appendAndPlay = (newEvents: TimelineEvent[]) => {
+    pause()
+    // Cancel any in-flight advance so we don't race
+    if (appendTimer !== undefined) {
+      clearTimeout(appendTimer)
+      appendTimer = undefined
+    }
+    advancing = false
+    // First, catch up: apply any unapplied events instantly
+    const currentTotal = events.length
+    const currentStep = step()
+    if (currentStep < currentTotal) {
+      // Update appliedStep FIRST so the effect triggered by setStep is a no-op
+      for (let i = appliedStep; i < currentTotal && i < events.length; i++) {
+        applyEvent(events[i])
+      }
+      appliedStep = currentTotal
+      setStep(currentTotal)
+    }
+    // Append new events
+    events.push(...newEvents)
+    setTotalSteps(events.length)
+    // Start fresh advance
+    startAdvance()
+  }
+
+  const fullReset = () => {
+    if (appendTimer !== undefined) clearTimeout(appendTimer)
+    advancing = false
+    pause()
+    events.length = 0
+    setTotalSteps(0)
+    setStep(0)
+    appliedStep = 0
+    resetStore()
+  }
+
+  // Event label
+  const label = createMemo(() => {
+    const s = step()
+    if (s <= 0) return "Start"
+    if (s >= totalSteps()) return "Complete"
+    const ev = events[s - 1]
+    if (!ev) return ""
+    switch (ev.type) {
+      case "message":
+        return `${ev.message.role} message`
+      case "part": {
+        const p = ev.part
+        if (p.type === "tool") return `tool (${p.tool}) pending`
+        if (p.type === "reasoning") return "reasoning"
+        return p.type
+      }
+      case "part-update":
+        return `part update`
+      case "status":
+        return `status: ${ev.status.type}`
+      case "delay":
+        return ev.label || `delay ${ev.ms}ms`
+    }
+  })
+
+  return {
+    step,
+    totalSteps,
+    playing,
+    speed,
+    setSpeed,
+    data,
+    label,
+    play,
+    pause,
+    togglePlay,
+    stepForward,
+    stepBack,
+    reset,
+    jumpTo,
+    applyEvent,
+    appendAndPlay,
+    fullReset,
+    cleanup: () => {
+      stopTimer()
+      if (appendTimer !== undefined) clearTimeout(appendTimer)
+      advancing = false
+    },
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Placeholder file component (for FileComponentProvider)
+// ---------------------------------------------------------------------------
+
+function PlaceholderFile(props: any) {
+  return (
+    <pre
+      style={{
+        padding: "8px",
+        "font-size": "12px",
+        "font-family": "monospace",
+        background: "var(--surface-inset-base, #1a1a1a)",
+        color: "var(--text-base, #ccc)",
+        "white-space": "pre-wrap",
+        "max-height": "200px",
+        overflow: "auto",
+      }}
+    >
+      {props.mode === "diff" ? `--- ${props.before?.name}\n+++ ${props.after?.name}` : "file"}
+    </pre>
+  )
+}
+
+// ---------------------------------------------------------------------------
+// Control UI helpers
+// ---------------------------------------------------------------------------
+
+function Btn(props: { onClick: () => void; title?: string; children: any }) {
+  return (
+    <button
+      onClick={props.onClick}
+      title={props.title}
+      style={{
+        width: "32px",
+        height: "28px",
+        display: "flex",
+        "align-items": "center",
+        "justify-content": "center",
+        "font-size": "var(--font-size-base)",
+        "border-radius": "6px",
+        border: "1px solid var(--border-base)",
+        background: "var(--surface-base)",
+        color: "var(--text-base)",
+        cursor: "pointer",
+      }}
+    >
+      {props.children}
+    </button>
+  )
+}
+
+function Toggle(props: { label: string; value: boolean; onChange: (v: boolean) => void }) {
+  return (
+    <label
+      style={{
+        display: "inline-flex",
+        "align-items": "center",
+        gap: "6px",
+        "font-size": "11px",
+        color: "var(--text-weak, #888)",
+        cursor: "pointer",
+        "user-select": "none",
+      }}
+    >
+      <input
+        type="checkbox"
+        checked={props.value}
+        onChange={(e) => props.onChange(e.currentTarget.checked)}
+        style={{ margin: "0" }}
+      />
+      {props.label}
+    </label>
+  )
+}
+
+// ---------------------------------------------------------------------------
+// Simulator component
+// ---------------------------------------------------------------------------
+
+// ---------------------------------------------------------------------------
+// Interactive event trigger factories
+// ---------------------------------------------------------------------------
+
+interface TurnState {
+  turnIndex: number
+  userMsgID: string
+  asstMsgID: string
+}
+
+// A running tool that can be completed later
+interface RunningTool {
+  part: ToolPart
+  turn: TurnState
+  title: string
+  startTime: number
+  completeOutput: string
+  completePatch: Record<string, any>
+}
+
+// Returns [eventsToPlay, runningTool] — the tool is left in "running" state
+const readFiles = [
+  "/Users/kit/project/packages/opencode/src/tool/bash.ts",
+  "/Users/kit/project/packages/ui/src/components/message-part.tsx",
+  "/Users/kit/project/packages/core/src/session/manager.ts",
+  "/Users/kit/project/packages/opencode/src/provider/anthropic.ts",
+  "/Users/kit/project/src/index.ts",
+]
+let readIndex = 0
+
+function buildReadEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
+  const t = Date.now()
+  const filePath = readFiles[readIndex++ % readFiles.length]
+  const fileName = filePath.split("/").pop()!
+  const readPart = mkTool(turn.asstMsgID, "read", {})
+  const events: TimelineEvent[] = [
+    { type: "part", part: readPart },
+    { type: "delay", ms: 60 },
+    { type: "part-update", messageID: turn.asstMsgID, partID: readPart.id, patch: { state: { status: "pending", input: { filePath }, raw: JSON.stringify({ filePath }) } } },
+    { type: "delay", ms: 60 },
+    { type: "part-update", messageID: turn.asstMsgID, partID: readPart.id, patch: toolRunning(readPart, fileName, t) },
+  ]
+  return [events, {
+    part: readPart, turn, title: fileName, startTime: t,
+    completeOutput: `// contents of ${fileName}`,
+    completePatch: toolCompleted(readPart, fileName, `// contents of ${fileName}`, t, t + 300),
+  }]
+}
+
+// Bash output chunks — each press of `b` appends the next chunk to the running tool
+const bashCommands = [
+  { command: "bun run test -- --reporter=verbose", description: "Run tests" },
+  { command: "bun install", description: "Install dependencies" },
+  { command: "bunx tsc --noEmit", description: "Type check the project" },
+  { command: "git log --oneline -20", description: "Recent commits" },
+]
+
+const bashOutputChunks = [
+  // Test run chunks
+  [
+    "$ vitest run --reporter=verbose\n",
+    "\n ✓ session/manager.test.ts > creates a new session  12ms\n",
+    " ✓ session/manager.test.ts > resumes existing session  8ms\n",
+    " ✓ session/manager.test.ts > handles concurrent messages  23ms\n",
+    " ✓ session/manager.test.ts > applies tool results  5ms\n",
+    " ✓ session/manager.test.ts > compacts long conversations  147ms\n",
+    " ✓ session/manager.test.ts > tracks token usage  3ms\n",
+    " ✓ session/manager.test.ts > emits status events  11ms\n",
+    " ✓ session/manager.test.ts > cleans up on close  6ms\n",
+    "\n ✓ provider/anthropic.test.ts > sends messages  34ms\n",
+    " ✓ provider/anthropic.test.ts > handles streaming  89ms\n",
+    " ✓ provider/anthropic.test.ts > retries on 429  201ms\n",
+    " ✓ provider/anthropic.test.ts > maps tool calls  18ms\n",
+    "\n ✓ tool/bash.test.ts > executes command  42ms\n",
+    " ✓ tool/bash.test.ts > captures stderr  7ms\n",
+    " ✓ tool/bash.test.ts > respects timeout  1004ms\n",
+    " ✓ tool/bash.test.ts > sanitizes input  2ms\n",
+    "\nTest Files  3 passed (3)\n     Tests  16 passed (16)\n  Duration  2.41s\n",
+  ],
+  // Install chunks
+  [
+    "bun install v1.2.4 (ae194892)\n",
+    "\nResolving dependencies...\n",
+    " + @opencode-ai/sdk@0.3.1\n",
+    " + solid-js@1.9.5\n",
+    " + @solidjs/router@0.15.3\n",
+    " + @effect/platform@0.76.1\n",
+    " + effect@3.14.2\n",
+    " + @solidjs/start@1.1.0\n",
+    " + vite@6.2.1\n",
+    " + vitest@3.0.4\n",
+    " + typescript@5.7.3\n",
+    " + @anthropic-ai/sdk@0.39.0\n",
+    " + zod@3.24.2\n",
+    "\nLinking packages...\n",
+    " Compiled 12 native modules.\n",
+    "\n 184 packages installed [2.41s]\n",
+  ],
+  // Type check chunks
+  [
+    "Checking project...\n",
+    "\nsrc/tool/bash.ts(42,7): error TS2322: Type 'string | undefined' is not assignable to type 'string'.\n",
+    "  Type 'undefined' is not assignable to type 'string'.\n",
+    "\nsrc/provider/anthropic.ts(118,3): error TS2345: Argument of type 'Message' is not assignable.\n",
+    "  Expected 'CoreMessage', received 'Message'.\n",
+    "\nsrc/session/manager.ts(67,12): error TS2339: Property 'compact' does not exist on type 'Session'.\n",
+    "\nsrc/ui/components/message-part.tsx(234,5): error TS7006: Parameter 'props' implicitly has an 'any' type.\n",
+    "\nsrc/tool/edit.ts(89,21): error TS2769: No overload matches this call.\n",
+    "  Overload 1 of 2 gave the following error:\n",
+    "    Argument of type '{ path: string }' is not assignable to parameter of type 'EditOptions'.\n",
+    "      Property 'oldString' is missing.\n",
+    "\nsrc/config.ts(14,10): error TS2305: Module '\"effect\"' has no exported member 'ConfigProvider'.\n",
+    "\nFound 6 errors in 5 files.\n",
+  ],
+  // Git log chunks
+  [
+    "e11dcf7 fix(ui): align context tool row font size\n",
+    "02c6630 revert(opencode): roll back session guard changes\n",
+    "37da6c8 fix(ui): align Home/End scroll behavior\n",
+    "49aedd5 fix(app): stabilize session message navigation\n",
+    "2378ea8 fix(app): reserve space for timeline header\n",
+    "a1b2c3d feat(tools): add streaming bash output\n",
+    "d4e5f6a refactor(session): extract turn manager\n",
+    "f7a8b9c fix(ui): prevent flash on theme switch\n",
+    "1234abc chore: bump dependencies\n",
+    "5678def docs: update README\n",
+    "9abcdef fix(core): handle empty message array\n",
+    "b1c2d3e feat(ui): add copy button to code blocks\n",
+    "e4f5a6b perf(session): batch store updates\n",
+    "c7d8e9f fix(provider): respect rate limit headers\n",
+    "0a1b2c3 refactor(tool): unify tool state shape\n",
+    "d4e5f67 test: add integration tests for session flow\n",
+  ],
+]
+function buildBashStartEvents(turn: TurnState): [TimelineEvent[], RunningTool, number] {
+  const t = Date.now()
+  const cmdIdx = Math.floor(Math.random() * bashCommands.length)
+  const cmd = bashCommands[cmdIdx]
+  const input = { command: cmd.command, description: cmd.description }
+  const shellPart = mkTool(turn.asstMsgID, "bash", input)
+  const allChunks = bashOutputChunks[cmdIdx]
+  const fullOutput = allChunks.join("")
+  const events: TimelineEvent[] = [
+    { type: "part", part: shellPart },
+    { type: "delay", ms: 120 },
+    { type: "part-update", messageID: turn.asstMsgID, partID: shellPart.id, patch: toolRunning(shellPart, input.command, t) },
+  ]
+  return [events, {
+    part: shellPart, turn, title: input.command, startTime: t,
+    completeOutput: fullOutput,
+    completePatch: toolCompleted(shellPart, input.command, fullOutput, t, t + 2000),
+  }, cmdIdx]
+}
+
+function buildBashChunkEvents(
+  turn: TurnState,
+  part: ToolPart,
+  cmdIdx: number,
+  chunkIdx: number,
+  prevOutput: string,
+): [TimelineEvent[], string] {
+  const chunks = bashOutputChunks[cmdIdx]
+  const chunk = chunks[chunkIdx % chunks.length]
+  const newOutput = prevOutput + chunk
+  const events: TimelineEvent[] = [
+    {
+      type: "part-update",
+      messageID: turn.asstMsgID,
+      partID: part.id,
+      patch: {
+        state: { status: "running", input: part.state.input, title: part.state.input?.command, output: newOutput, time: { start: Date.now() } },
+      },
+    },
+  ]
+  return [events, newOutput]
+}
+
+function buildTextEvents(turn: TurnState): TimelineEvent[] {
+  const chunks = [
+    "Here's what I found ",
+    "after analyzing the codebase. ",
+    "The main entry point is in `src/index.ts` ",
+    "and it exports several key modules:\n\n",
+    "- **Tool system**: Defines all available tools\n",
+    "- **Session**: Manages conversation state\n",
+    "- **Provider**: Handles model communication\n",
+  ]
+  const events: TimelineEvent[] = []
+  let text = ""
+  const partId = pid()
+  const textPart: TextPart = { id: partId, sessionID: SESSION_ID, messageID: turn.asstMsgID, type: "text", text: "" }
+  events.push({ type: "part", part: textPart })
+  for (const chunk of chunks) {
+    text += chunk
+    events.push({ type: "delay", ms: 80 })
+    events.push({ type: "part-update", messageID: turn.asstMsgID, partID: partId, patch: { text } })
+  }
+  return events
+}
+
+const grepPatterns = ["createSignal", "export function", "TODO|FIXME", "import.*from", "async function"]
+let grepIndex = 0
+
+function buildGrepEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
+  const t = Date.now()
+  const pattern = grepPatterns[grepIndex++ % grepPatterns.length]
+  const input = { pattern, path: "/Users/kit/project" }
+  const grepPart = mkTool(turn.asstMsgID, "grep", {})
+  const title = `"${pattern}"`
+  const events: TimelineEvent[] = [
+    { type: "part", part: grepPart },
+    { type: "delay", ms: 60 },
+    { type: "part-update", messageID: turn.asstMsgID, partID: grepPart.id, patch: { state: { status: "pending", input, raw: JSON.stringify(input) } } },
+    { type: "delay", ms: 60 },
+    { type: "part-update", messageID: turn.asstMsgID, partID: grepPart.id, patch: toolRunning(grepPart, title, t) },
+  ]
+  return [events, {
+    part: grepPart, turn, title, startTime: t,
+    completeOutput: "14 matches found",
+    completePatch: toolCompleted(grepPart, title, "14 matches found", t, t + 400),
+  }]
+}
+
+const globPatterns = ["**/*.ts", "**/*.tsx", "src/**/*.css", "packages/*/package.json", "**/*.test.ts"]
+let globIndex = 0
+
+function buildGlobEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
+  const t = Date.now()
+  const pattern = globPatterns[globIndex++ % globPatterns.length]
+  const input = { pattern, path: "/Users/kit/project/src" }
+  const globPart = mkTool(turn.asstMsgID, "glob", {})
+  const events: TimelineEvent[] = [
+    { type: "part", part: globPart },
+    { type: "delay", ms: 60 },
+    { type: "part-update", messageID: turn.asstMsgID, partID: globPart.id, patch: { state: { status: "pending", input, raw: JSON.stringify(input) } } },
+    { type: "delay", ms: 60 },
+    { type: "part-update", messageID: turn.asstMsgID, partID: globPart.id, patch: toolRunning(globPart, pattern, t) },
+  ]
+  return [events, {
+    part: globPart, turn, title: pattern, startTime: t,
+    completeOutput: "23 files matched",
+    completePatch: toolCompleted(globPart, pattern, "23 files matched", t, t + 200),
+  }]
+}
+
+const listPaths = [
+  "/Users/kit/project/src",
+  "/Users/kit/project/packages/ui/src/components",
+  "/Users/kit/project/packages/core/src",
+  "/Users/kit/project/packages/opencode/src/tool",
+]
+let listIndex = 0
+
+function buildListEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
+  const t = Date.now()
+  const path = listPaths[listIndex++ % listPaths.length]
+  const dirName = path.split("/").pop()!
+  const input = { path }
+  const listPart = mkTool(turn.asstMsgID, "list", {})
+  const events: TimelineEvent[] = [
+    { type: "part", part: listPart },
+    { type: "delay", ms: 60 },
+    { type: "part-update", messageID: turn.asstMsgID, partID: listPart.id, patch: { state: { status: "pending", input, raw: JSON.stringify(input) } } },
+    { type: "delay", ms: 60 },
+    { type: "part-update", messageID: turn.asstMsgID, partID: listPart.id, patch: toolRunning(listPart, dirName, t) },
+  ]
+  return [events, {
+    part: listPart, turn, title: dirName, startTime: t,
+    completeOutput: "12 entries",
+    completePatch: toolCompleted(listPart, dirName, "12 entries", t, t + 150),
+  }]
+}
+
+const fetchUrls = [
+  "https://docs.solidjs.com/concepts/signals",
+  "https://effect.website/docs/getting-started",
+  "https://github.com/opencode-ai/opencode/issues/342",
+  "https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver",
+  "https://nodejs.org/api/child_process.html",
+]
+let fetchIndex = 0
+
+function buildWebFetchEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
+  const t = Date.now()
+  const url = fetchUrls[fetchIndex++ % fetchUrls.length]
+  const input = { url }
+  const fetchPart = mkTool(turn.asstMsgID, "webfetch", {})
+  const events: TimelineEvent[] = [
+    { type: "part", part: fetchPart },
+    { type: "delay", ms: 60 },
+    { type: "part-update", messageID: turn.asstMsgID, partID: fetchPart.id, patch: { state: { status: "pending", input, raw: JSON.stringify(input) } } },
+    { type: "delay", ms: 80 },
+    { type: "part-update", messageID: turn.asstMsgID, partID: fetchPart.id, patch: toolRunning(fetchPart, url, t) },
+  ]
+  return [events, {
+    part: fetchPart, turn, title: url, startTime: t,
+    completeOutput: "Fetched 24.3 KB",
+    completePatch: toolCompleted(fetchPart, url, "Fetched 24.3 KB", t, t + 1200),
+  }]
+}
+
+function buildEditEvents(turn: TurnState): [TimelineEvent[], RunningTool] {
+  const t = Date.now()
+  const editInput = {
+    filePath: "/Users/kit/project/packages/opencode/src/tool/bash.ts",
+    oldString: "const cmd = input.command",
+    newString: "const cmd = sanitize(input.command)",
+  }
+  const editPart = mkTool(turn.asstMsgID, "edit", editInput)
+  const filediff = {
+    file: editInput.filePath,
+    before: "const cmd = input.command",
+    after: "const cmd = sanitize(input.command)",
+    additions: 1,
+    deletions: 1,
+  }
+  const events: TimelineEvent[] = [
+    { type: "part", part: editPart },
+    { type: "delay", ms: 100 },
+    {
+      type: "part-update", messageID: turn.asstMsgID, partID: editPart.id, patch: {
+        state: { status: "running", input: editPart.state.input, title: "bash.ts", metadata: { filediff, diagnostics: {} }, time: { start: t } },
+      },
+    },
+  ]
+  const completePatch = {
+    state: {
+      status: "completed",
+      input: editInput,
+      title: "Updated bash.ts",
+      metadata: { filediff, diagnostics: {} },
+      time: { start: t, end: t + 300 },
+    },
+  }
+  return [events, {
+    part: editPart, turn, title: "bash.ts", startTime: t,
+    completeOutput: "",
+    completePatch,
+  }]
+}
+
+function buildErrorEvents(turn: TurnState): TimelineEvent[] {
+  const t = Date.now()
+  const input = { command: "rm -rf /oops", description: "This will fail" }
+  const errPart = mkTool(turn.asstMsgID, "bash", input)
+  return [
+    { type: "part", part: errPart },
+    { type: "delay", ms: 100 },
+    { type: "part-update", messageID: turn.asstMsgID, partID: errPart.id, patch: toolRunning(errPart, input.command, t) },
+    { type: "delay", ms: 200 },
+    {
+      type: "part-update",
+      messageID: turn.asstMsgID,
+      partID: errPart.id,
+      patch: {
+        state: {
+          status: "error",
+          input,
+          error: "Permission denied: cannot remove /oops",
+          title: input.command,
+          time: { start: t, end: t + 200 },
+        },
+      },
+    },
+  ]
+}
+
+// ---------------------------------------------------------------------------
+// Trigger button component
+// ---------------------------------------------------------------------------
+
+function TriggerBtn(props: { key: string; label: string; onClick: () => void }) {
+  return (
+    <button
+      onClick={props.onClick}
+      style={{
+        display: "inline-flex",
+        "align-items": "center",
+        gap: "4px",
+        padding: "4px 10px",
+        "font-size": "var(--font-size-small)",
+        "font-family": "var(--font-family-sans)",
+        "border-radius": "6px",
+        border: "1px solid var(--border-base)",
+        background: "var(--surface-base)",
+        color: "var(--text-base)",
+        cursor: "pointer",
+      }}
+    >
+      <kbd
+        style={{
+          padding: "1px 4px",
+          "font-size": "10px",
+          "font-family": "var(--font-family-mono)",
+          "border-radius": "3px",
+          background: "var(--surface-inset-base)",
+          border: "1px solid var(--border-base)",
+          color: "var(--text-weak)",
+        }}
+      >
+        {props.key}
+      </kbd>
+      {props.label}
+    </button>
+  )
+}
+
+// ---------------------------------------------------------------------------
+// Simulator component
+// ---------------------------------------------------------------------------
+
+interface Action {
+  key: string
+  label: string
+  handler: () => void
+}
+
+const contextToolBuilders = [buildReadEvents, buildGrepEvents, buildGlobEvents, buildListEvents]
+
+function SessionTimelineSimulator() {
+  const events = buildTimeline()
+  const pb = createPlayback(events)
+
+  // Multi-turn state
+  let turnCounter = 0
+  const [turns, setTurns] = createSignal<TurnState[]>([])
+  const [currentTurn, setCurrentTurn] = createSignal<TurnState | null>(null)
+  const [runningTool, setRunningTool] = createSignal<RunningTool | null>(null)
+
+  // Bash streaming state — tracks the current bash tool being streamed into
+  let bashState: { cmdIdx: number; chunkIdx: number; currentOutput: string; part: ToolPart; turn: TurnState } | null = null
+
+  function startNewTurn() {
+    turnCounter++
+    const userMsgID = `msg-user-${turnCounter}`
+    const asstMsgID = `msg-asst-${turnCounter}`
+    const turn: TurnState = { turnIndex: turnCounter, userMsgID, asstMsgID }
+    setTurns((prev) => [...prev, turn])
+    setCurrentTurn(turn)
+    return turn
+  }
+
+  const userPrompts = [
+    "Can you look at the bash tool and fix the streaming output?",
+    "Refactor the session manager to use Effect",
+    "The tests are failing on CI, can you investigate?",
+    "Add a new command for listing recent sessions",
+    "Fix the type errors in the provider module",
+  ]
+  let promptIndex = 0
+
+  function ensureTurn(): TurnState {
+    const t = currentTurn()
+    if (t) return t
+    const turn = startNewTurn()
+    const prompt = userPrompts[promptIndex++ % userPrompts.length]
+    // Apply user message events instantly so SessionTurn has data on mount
+    batch(() => {
+      pb.applyEvent({ type: "status", status: { type: "busy" } })
+      pb.applyEvent({ type: "message", message: mkUser(turn.userMsgID) })
+      pb.applyEvent({ type: "part", part: mkText(turn.userMsgID, prompt) })
+      pb.applyEvent({ type: "message", message: mkAssistant(turn.asstMsgID, turn.userMsgID) })
+    })
+    return turn
+  }
+
+  // Complete the current running tool, returning its completion events
+  function drainRunning(): TimelineEvent[] {
+    bashState = null
+    const tool = runningTool()
+    if (!tool) return []
+    setRunningTool(null)
+    return [
+      {
+        type: "part-update",
+        messageID: tool.turn.asstMsgID,
+        partID: tool.part.id,
+        patch: tool.completePatch,
+      },
+      { type: "delay", ms: 60 },
+    ]
+  }
+
+  // Fire a tool that stays running until the next action
+  function triggerTool(builder: (turn: TurnState) => [TimelineEvent[], RunningTool]) {
+    const drain = drainRunning()
+    const turn = ensureTurn()
+    const [toolEvents, running] = builder(turn)
+    setRunningTool(running)
+    pb.appendAndPlay([...drain, ...toolEvents])
+  }
+
+  // Fire a random context tool (read/grep/glob/list) — stays running until next action
+  function triggerExplore() {
+    const builder = contextToolBuilders[Math.floor(Math.random() * contextToolBuilders.length)]
+    triggerTool(builder)
+  }
+
+  function completeTurn() {
+    const turn = currentTurn()
+    if (!turn) return
+    const drain = drainRunning()
+    const evts: TimelineEvent[] = [
+      ...drain,
+      { type: "delay", ms: 100 },
+      { type: "message", message: mkAssistant(turn.asstMsgID, turn.userMsgID, Date.now()) },
+      { type: "status", status: { type: "idle" } },
+    ]
+    setCurrentTurn(null)
+    pb.appendAndPlay(evts)
+  }
+
+  function fullReset() {
+    _pid = 0
+    readIndex = 0
+    promptIndex = 0
+    fetchIndex = 0
+    grepIndex = 0
+    globIndex = 0
+    listIndex = 0
+    turnCounter = 0
+    bashState = null
+    setTurns([])
+    setCurrentTurn(null)
+    setRunningTool(null)
+    pb.fullReset()
+  }
+
+  // --- Flat action list ---
+
+  const actions: Action[] = [
+    { key: "e", label: "Explore", handler: () => triggerExplore() },
+    {
+      key: "b", label: "Bash", handler: () => {
+        if (bashState) {
+          // Already streaming — append next chunk
+          const chunks = bashOutputChunks[bashState.cmdIdx]
+          if (bashState.chunkIdx < chunks.length) {
+            const [chunkEvents, newOutput] = buildBashChunkEvents(
+              bashState.turn, bashState.part, bashState.cmdIdx, bashState.chunkIdx, bashState.currentOutput,
+            )
+            bashState.chunkIdx++
+            bashState.currentOutput = newOutput
+            // Update the running tool's completePatch to include all streamed output
+            const tool = runningTool()
+            if (tool) {
+              tool.completeOutput = newOutput
+              tool.completePatch = toolCompleted(tool.part, tool.title, newOutput, tool.startTime, Date.now())
+            }
+            pb.appendAndPlay(chunkEvents)
+          }
+          // If we've exhausted chunks, further presses are no-ops (still running until another key)
+          return
+        }
+        // First press — start a new bash tool
+        const drain = drainRunning()
+        const turn = ensureTurn()
+        const [toolEvents, running, cmdIdx] = buildBashStartEvents(turn)
+        setRunningTool(running)
+        bashState = { cmdIdx, chunkIdx: 0, currentOutput: "", part: running.part, turn }
+        pb.appendAndPlay([...drain, ...toolEvents])
+      },
+    },
+    {
+      key: "t", label: "Text", handler: () => {
+        const drain = drainRunning()
+        const turn = ensureTurn()
+        pb.appendAndPlay([...drain, ...buildTextEvents(turn)])
+      },
+    },
+    { key: "d", label: "Edit", handler: () => triggerTool(buildEditEvents) },
+    { key: "w", label: "WebFetch", handler: () => triggerTool(buildWebFetchEvents) },
+    {
+      key: "x", label: "Error", handler: () => {
+        const drain = drainRunning()
+        const turn = ensureTurn()
+        pb.appendAndPlay([...drain, ...buildErrorEvents(turn)])
+      },
+    },
+    {
+      key: "u", label: "User", handler: () => {
+        const prev = currentTurn()
+        const drain = drainRunning()
+        // Complete previous turn if needed
+        const evts: TimelineEvent[] = [...drain]
+        if (prev) {
+          evts.push(
+            { type: "message", message: mkAssistant(prev.asstMsgID, prev.userMsgID, Date.now()) },
+            { type: "status", status: { type: "idle" } },
+          )
+        }
+        // Apply completion events instantly, then set up new turn
+        for (const ev of evts) pb.applyEvent(ev)
+        // New turn — applied instantly so the user message shows immediately
+        const turn = startNewTurn()
+        const prompt = userPrompts[promptIndex++ % userPrompts.length]
+        batch(() => {
+          pb.applyEvent({ type: "status", status: { type: "busy" } })
+          pb.applyEvent({ type: "message", message: mkUser(turn.userMsgID) })
+          pb.applyEvent({ type: "part", part: mkText(turn.userMsgID, prompt) })
+          pb.applyEvent({ type: "message", message: mkAssistant(turn.asstMsgID, turn.userMsgID) })
+        })
+      },
+    },
+    { key: "c", label: "Complete", handler: () => completeTurn() },
+    { key: "0", label: "Reset", handler: () => fullReset() },
+  ]
+
+  const keyMap = new Map(actions.map((a) => [a.key, a.handler]))
+
+  // Controls
+  const [showReasoningSummaries, setShowReasoningSummaries] = createSignal(false)
+  const [animateEnabled, setAnimateEnabled] = createSignal(true)
+
+  onCleanup(pb.cleanup)
+
+  // Keyboard
+  const onKey = (e: KeyboardEvent) => {
+    if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
+    if (e.key === "ArrowRight") {
+      e.preventDefault()
+      pb.stepForward()
+    } else if (e.key === "ArrowLeft") {
+      e.preventDefault()
+      pb.stepBack()
+    } else if (e.key === " ") {
+      e.preventDefault()
+      pb.togglePlay()
+    } else {
+      const handler = keyMap.get(e.key)
+      if (handler) {
+        e.preventDefault()
+        handler()
+      }
+    }
+  }
+  window.addEventListener("keydown", onKey)
+  onCleanup(() => window.removeEventListener("keydown", onKey))
+
+  const progress = createMemo(() => {
+    const total = pb.totalSteps()
+    return total > 0 ? (pb.step() / total) * 100 : 0
+  })
+
+  return (
+    <div
+      tabIndex={0}
+      style={{
+        display: "flex",
+        "flex-direction": "column",
+        height: "100vh",
+        margin: "-24px",
+        outline: "none",
+        "background-color": "var(--background-stronger)",
+        color: "var(--text-base)",
+        "font-family": "var(--font-family-sans)",
+        "font-size": "var(--font-size-base)",
+      }}
+    >
+      {/* Main content — column-reverse pins content to bottom like the real session */}
+      <div
+        style={{
+          flex: "1 1 0",
+          "min-height": "0",
+          "overflow-y": "auto",
+          display: "flex",
+          "flex-direction": "column-reverse",
+          "overflow-anchor": "none",
+          "scrollbar-width": "none",
+        }}
+      >
+        <DataProvider data={pb.data} directory="/Users/kit/project">
+          <FileComponentProvider component={PlaceholderFile}>
+            <div class="flex flex-col gap-0 items-start justify-start pb-16 w-full max-w-200 mx-auto 2xl:max-w-[1000px]">
+              <For each={turns()}>
+                {(turn) => (
+                  <div class="min-w-0 w-full max-w-full max-w-200 2xl:max-w-[1000px]">
+                    <SessionTurn
+                      sessionID={SESSION_ID}
+                      messageID={turn.userMsgID}
+                      active={currentTurn()?.userMsgID === turn.userMsgID}
+                      animate={animateEnabled()}
+                      showReasoningSummaries={showReasoningSummaries()}
+                      classes={{
+                        root: "min-w-0 w-full relative",
+                        content: "flex flex-col justify-between !overflow-visible",
+                        container: "w-full px-4 md:px-5",
+                      }}
+                    />
+                  </div>
+                )}
+              </For>
+            </div>
+            {/* Empty state */}
+            {turns().length === 0 && (
+              <div
+                style={{
+                  display: "flex",
+                  "align-items": "center",
+                  "justify-content": "center",
+                  height: "100%",
+                  color: "var(--text-weak)",
+                  "font-size": "var(--font-size-base)",
+                  "font-family": "var(--font-family-sans)",
+                }}
+              >
+                Press a key or click a button below to start
+              </div>
+            )}
+          </FileComponentProvider>
+        </DataProvider>
+      </div>
+
+      {/* Controls panel */}
+      <div
+        style={{
+          "flex-shrink": "0",
+          "border-top": "1px solid var(--border-base)",
+          "background-color": "var(--background-stronger)",
+          padding: "12px 16px",
+          display: "flex",
+          "flex-direction": "column",
+          gap: "8px",
+        }}
+      >
+        {/* Scrubber */}
+        <div
+          style={{
+            width: "100%",
+            height: "6px",
+            background: "var(--surface-inset-base)",
+            "border-radius": "3px",
+            cursor: "pointer",
+          }}
+          onClick={(e) => {
+            const rect = e.currentTarget.getBoundingClientRect()
+            const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
+            pb.jumpTo(Math.round(ratio * pb.totalSteps()))
+          }}
+        >
+          <div
+            style={{
+              width: `${progress()}%`,
+              height: "100%",
+              background: "var(--color-blue, #3b82f6)",
+              "border-radius": "3px",
+              transition: "width 60ms linear",
+            }}
+          />
+        </div>
+
+        {/* Transport + info */}
+        <div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
+          <div style={{ display: "flex", gap: "4px" }}>
+            <Btn onClick={pb.reset} title="Reset playback">
+              ⏮
+            </Btn>
+            <Btn onClick={pb.stepBack} title="Step back">
+              ⏪
+            </Btn>
+            <Btn onClick={pb.togglePlay} title={pb.playing() ? "Pause" : "Play"}>
+              {pb.playing() ? "⏸" : "▶"}
+            </Btn>
+            <Btn onClick={pb.stepForward} title="Step forward">
+              ⏩
+            </Btn>
+          </div>
+
+          <span
+            style={{
+              "font-size": "var(--font-size-small)",
+              "font-family": "var(--font-family-mono)",
+              color: "var(--text-weak)",
+              "min-width": "80px",
+            }}
+          >
+            {pb.step()}/{pb.totalSteps()}
+          </span>
+
+          <span
+            style={{
+              "font-size": "var(--font-size-small)",
+              "font-family": "var(--font-family-sans)",
+              color: "var(--text-base)",
+              flex: "1",
+              overflow: "hidden",
+              "text-overflow": "ellipsis",
+              "white-space": "nowrap",
+            }}
+          >
+            {pb.label()}
+          </span>
+
+          {/* Speed */}
+          <div style={{ display: "flex", "align-items": "center", gap: "4px", "flex-shrink": "0" }}>
+            <span style={{ "font-size": "var(--font-size-small)", color: "var(--text-weak)", "margin-right": "2px" }}>Speed</span>
+            <For each={[0.25, 0.5, 1, 2, 4]}>
+              {(s) => (
+                <button
+                  onClick={() => pb.setSpeed(s)}
+                  style={{
+                    padding: "2px 6px",
+                    "font-size": "var(--font-size-small)",
+                    "font-family": "var(--font-family-mono)",
+                    "border-radius": "4px",
+                    border:
+                      "1px solid " + (pb.speed() === s ? "var(--color-blue, #3b82f6)" : "var(--border-base)"),
+                    background: pb.speed() === s ? "var(--color-blue, #3b82f6)" : "transparent",
+                    color: pb.speed() === s ? "white" : "var(--text-base)",
+                    cursor: "pointer",
+                  }}
+                >
+                  {s}x
+                </button>
+              )}
+            </For>
+          </div>
+        </div>
+
+        {/* Trigger buttons */}
+        <div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap" }}>
+          <For each={actions}>
+            {(action) => (
+              <TriggerBtn key={action.key} label={action.label} onClick={action.handler} />
+            )}
+          </For>
+        </div>
+
+        {/* Toggles */}
+        <div style={{ display: "flex", gap: "16px" }}>
+          <Toggle
+            label="showReasoningSummaries"
+            value={showReasoningSummaries()}
+            onChange={setShowReasoningSummaries}
+          />
+          <Toggle label="animate" value={animateEnabled()} onChange={setAnimateEnabled} />
+        </div>
+      </div>
+    </div>
+  )
+}
+
+// ---------------------------------------------------------------------------
+// Storybook exports
+// ---------------------------------------------------------------------------
+
+export default {
+  title: "Session/Timeline Simulator",
+  id: "session-timeline-simulator",
+  tags: ["autodocs"],
+  parameters: {
+    layout: "fullscreen",
+    docs: {
+      description: {
+        component: `### Session Timeline Simulator (Interactive)
+
+Flat control panel — each action auto-completes the previous running tool.
+
+| Key | Action |
+|-----|--------|
+| e | Explore (random read/grep/glob/list, stays running) |
+| b | Bash tool (keep pressing to stream output, other key completes) |
+| t | Stream text |
+| d | Edit tool (stays running) |
+| x | Error tool |
+| u | New user turn |
+| c | Complete assistant turn |
+| 0 | Reset everything |
+
+**Transport:** Space = play/pause, Arrow keys = step, scrubber bar to jump.
+`,
+      },
+    },
+  },
+}
+
+export const Playback = {
+  render: () => <SessionTimelineSimulator />,
+}

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

@@ -1,5 +1,4 @@
 [data-component="session-turn"] {
-  --sticky-header-height: calc(var(--session-title-height, 0px) + 24px);
   height: 100%;
   min-height: 0;
   min-width: 0;
@@ -26,7 +25,7 @@
     align-items: flex-start;
     align-self: stretch;
     min-width: 0;
-    gap: 18px;
+    gap: 0px;
     overflow-anchor: none;
   }
 
@@ -43,30 +42,127 @@
     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: 20px;
-    min-height: 20px;
+    line-height: var(--line-height-large);
+    height: 36px;
 
     [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 {
@@ -84,7 +180,7 @@
     display: flex;
     flex-direction: column;
     align-self: stretch;
-    gap: 12px;
+    gap: 0px;
 
     > :first-child > [data-component="markdown"]:first-child {
       margin-top: 0;
@@ -109,6 +205,7 @@
 
   [data-component="session-turn-diffs-trigger"] {
     width: 100%;
+    height: 36px;
     display: flex;
     align-items: center;
     justify-content: flex-start;
@@ -118,7 +215,7 @@
 
   [data-slot="session-turn-diffs-title"] {
     display: inline-flex;
-    align-items: baseline;
+    align-items: center;
     gap: 8px;
   }
 
@@ -135,7 +232,7 @@
     font-family: var(--font-family-sans);
     font-size: var(--font-size-base);
     font-weight: var(--font-weight-regular);
-    line-height: var(--line-height-x-large);
+    line-height: var(--line-height-large);
   }
 
   [data-slot="session-turn-diffs-meta"] {
@@ -171,8 +268,10 @@
 
   [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);
@@ -180,16 +279,22 @@
   }
 
   [data-slot="session-turn-diff-directory"] {
-    color: var(--text-base);
-    overflow: hidden;
-    text-overflow: ellipsis;
+    flex: 1 1 auto;
+    color: var(--text-weak);
+    min-width: 0;
+    overflow: clip;
     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);
   }

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

@@ -3,23 +3,27 @@ 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, ParentProps, Show } from "solid-js"
+import { createEffect, createMemo, createSignal, For, on, onCleanup, ParentProps, Show } from "solid-js"
 import { Dynamic } from "solid-js/web"
-import { AssistantParts, Message, Part, PART_MAPPING } from "./message-part"
+import { GrowBox } from "./grow-box"
+import { AssistantParts, UserMessageDisplay, 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 { SessionRetry } from "./session-retry"
 import { TextReveal } from "./text-reveal"
+import { list } from "./text-utils"
+import { SessionRetry } from "./session-retry"
+import { Tooltip } from "./tooltip"
 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)
 }
@@ -73,18 +77,12 @@ 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") {
@@ -141,6 +139,7 @@ export function SessionTurn(
   props: ParentProps<{
     sessionID: string
     messageID: string
+    animate?: boolean
     showReasoningSummaries?: boolean
     shellToolDefaultOpen?: boolean
     editToolDefaultOpen?: boolean
@@ -159,11 +158,7 @@ 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))
 
@@ -191,42 +186,8 @@ export function SessionTurn(
     return msg
   })
 
-  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 active = createMemo(() => props.active ?? false)
+  const queued = createMemo(() => props.queued ?? false)
   const parts = createMemo(() => {
     const msg = message()
     if (!msg) return emptyParts
@@ -289,7 +250,7 @@ export function SessionTurn(
   const error = createMemo(
     () => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error,
   )
-  const showAssistantCopyPartID = createMemo(() => {
+  const assistantCopyPart = createMemo(() => {
     const messages = assistantMessages()
 
     for (let i = messages.length - 1; i >= 0; i--) {
@@ -299,13 +260,18 @@ 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" || !part.text?.trim()) continue
-        return part.id
+        if (!part || part.type !== "text") continue
+        const text = part.text?.trim()
+        if (!text) continue
+        return {
+          id: part.id,
+          text,
+          message,
+        }
       }
     }
-
-    return undefined
   })
+  const assistantCopyPartID = createMemo(() => assistantCopyPart()?.id ?? null)
   const errorText = createMemo(() => {
     const msg = error()?.data?.message
     if (typeof msg === "string") return unwrap(msg)
@@ -313,18 +279,14 @@ export function SessionTurn(
     return unwrap(String(msg))
   })
 
-  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 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 working = createMemo(() => status().type !== "idle" && active())
   const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true)
-
-  const assistantCopyPartID = createMemo(() => {
-    if (working()) return null
-    return showAssistantCopyPartID() ?? null
-  })
+  const showDiffSummary = createMemo(() => edited() > 0 && !working())
   const turnDurationMs = createMemo(() => {
     const start = message()?.time.created
     if (typeof start !== "number") return undefined
@@ -364,13 +326,109 @@ export function SessionTurn(
       .filter((text): text is string => !!text)
       .at(-1),
   )
-  const showThinking = createMemo(() => {
+  const thinking = 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,
@@ -378,6 +436,119 @@ 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
@@ -388,149 +559,120 @@ export function SessionTurn(
       >
         <div onClick={autoScroll.handleInteraction}>
           <Show when={message()}>
-            <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}
+            {(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>
-              </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 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>
-                </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>
+                  <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>
-                    </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] : [])}
+                      <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}
                           >
-                            <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>
+                            <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>
                         </div>
                       </Show>
-                    </Collapsible.Content>
-                  </Collapsible>
+                    </div>
+                  </GrowBox>
                 </div>
-              </Show>
-              <Show when={error()}>
-                <Card variant="error" class="error-card">
-                  {errorText()}
-                </Card>
-              </Show>
-            </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>
           {props.children}
         </div>

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

@@ -0,0 +1,249 @@
+import { createEffect, createMemo, createSignal, on, onCleanup, onMount, Show } from "solid-js"
+import stripAnsi from "strip-ansi"
+import type { ToolPart } from "@opencode-ai/sdk/v2"
+import { prefersReducedMotion } 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 {
+  animate,
+  clearFadeStyles,
+  clearMaskStyles,
+  FAST_SPRING,
+  GROW_SPRING,
+  WIPE_MASK,
+} from "./motion"
+import { useSpring } from "./motion-spring"
+import { busy, createThrottledValue, 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>
+  )
+}
+
+export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }) {
+  const i18n = useI18n()
+  const wiped = new Set<string>()
+  const [mounted, setMounted] = createSignal(false)
+  const [userToggled, setUserToggled] = createSignal(false)
+  const [userOpen, setUserOpen] = createSignal(false)
+  onMount(() => setMounted(true))
+  const state = createMemo(() => props.part.state as Record<string, any>)
+  const pending = createMemo(() => busy(props.part.state.status))
+  // autoOpen starts true if pending, false if already complete (e.g. scrolling back in history).
+  // When pending transitions false, autoOpen is still true — no intermediate state — then
+  // a 2s timer sets it false to trigger the collapse. This avoids the flash caused by
+  // holdOpen being set in a late-running effect.
+  const [autoOpen, setAutoOpen] = createSignal(pending())
+  createEffect(on(pending, (isPending, wasPending) => {
+    if (isPending) {
+      setAutoOpen(true)
+    } else if (wasPending && !userToggled()) {
+      const timer = setTimeout(() => setAutoOpen(false), 2000)
+      onCleanup(() => clearTimeout(timer))
+    }
+  }))
+  const effectiveOpen = createMemo(() => {
+    if (pending()) return true
+    if (userToggled()) return userOpen()
+    return autoOpen()
+  })
+  const subtitle = createMemo(() => {
+    const value = state().input?.description ?? state().metadata?.description
+    if (typeof value === "string") return value
+    return ""
+  })
+  const command = createMemo(() => {
+    const value = state().input?.command ?? state().metadata?.command
+    if (typeof value === "string") return value
+    return ""
+  })
+  const output = createMemo(() => {
+    const value = state().output ?? state().metadata?.output
+    if (typeof value === "string") return value
+    return ""
+  })
+  const reduce = prefersReducedMotion
+  const skip = () => reduce() || props.animate === false
+  const opacity = useSpring(() => (mounted() ? 1 : 0), GROW_SPRING)
+  const blur = useSpring(() => (mounted() ? 0 : 2), GROW_SPRING)
+  const headerHeight = useSpring(() => (mounted() ? 37 : 0), GROW_SPRING)
+  let headerClipRef: HTMLDivElement | undefined
+  const [copied, setCopied] = createSignal(false)
+  const handleHeaderClick = () => {
+    if (pending()) return
+    const el = headerClipRef
+    const viewport = el?.closest(".scroll-view__viewport") as HTMLElement | null
+    const beforeY = el?.getBoundingClientRect().top ?? 0
+    setUserToggled(true)
+    setUserOpen((prev) => !prev)
+    if (viewport && el) {
+      requestAnimationFrame(() => {
+        const afterY = el.getBoundingClientRect().top
+        const delta = afterY - beforeY
+        if (delta !== 0) viewport.scrollTop += delta
+      })
+    }
+  }
+  const handleCopy = async (e: MouseEvent) => {
+    e.stopPropagation()
+    const cmd = command()
+    const out = stripAnsi(output())
+    const content = `$ ${cmd}${out ? "\n" + out : ""}`
+    if (!content) return
+    await navigator.clipboard.writeText(content)
+    setCopied(true)
+    setTimeout(() => setCopied(false), 2000)
+  }
+  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={!pending() ? "true" : "false"}
+        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>
+          <Show when={!pending()}>
+            <span data-slot="shell-rolling-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-rolling-copy"
+                  onMouseDown={(e: MouseEvent) => e.preventDefault()}
+                  onClick={handleCopy}
+                  aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
+                />
+              </Tooltip>
+              <span data-slot="shell-rolling-arrow" data-open={effectiveOpen() ? "true" : "false"}>
+                <Icon name="chevron-down" size="small" />
+              </span>
+            </span>
+          </Show>
+        </div>
+      </div>
+      <RollingResults
+        class="shell-rolling-output"
+        items={rows()}
+        fixed={fixed()}
+        fixedHeight={22}
+        rows={5}
+        rowHeight={22}
+        rowGap={0}
+        open={effectiveOpen()}
+        scrollable={!pending() && !autoOpen() && userToggled() && userOpen()}
+        spring={userToggled() ? FAST_SPRING : undefined}
+        animate={props.animate !== false}
+        getKey={(row) => row.id}
+        render={(row) => {
+          const [textRef, setTextRef] = createSignal<HTMLSpanElement>()
+          createEffect(() => {
+            const el = textRef()
+            if (!el || !row.text) return
+            if (wiped.has(row.id)) return
+            wiped.add(row.id)
+            if (reduce()) return
+            el.style.maskImage = WIPE_MASK
+            el.style.webkitMaskImage = WIPE_MASK
+            el.style.maskSize = "240% 100%"
+            el.style.webkitMaskSize = "240% 100%"
+            el.style.maskRepeat = "no-repeat"
+            el.style.webkitMaskRepeat = "no-repeat"
+            el.style.maskPosition = "100% 0%"
+            el.style.webkitMaskPosition = "100% 0%"
+            animate(
+              el,
+              {
+                opacity: [0, 1],
+                filter: ["blur(2px)", "blur(0px)"],
+                transform: ["translateX(-0.06em)", "translateX(0)"],
+                maskPosition: "0% 0%",
+              },
+              GROW_SPRING,
+            ).finished.then(() => {
+              if (!el) return
+              clearFadeStyles(el)
+              clearMaskStyles(el)
+            })
+          })
+          return (
+            <div data-component="shell-rolling-row">
+              <span ref={setTextRef} data-slot="shell-rolling-text">
+                {row.text}
+              </span>
+            </div>
+          )
+        }}
+      />
+    </div>
+  )
+}

+ 0 - 329
packages/ui/src/components/shell-submessage-motion.stories.tsx

@@ -1,329 +0,0 @@
-// @ts-nocheck
-import { createEffect, createSignal, onCleanup } from "solid-js"
-import { BasicTool } from "./basic-tool"
-import { animate } from "motion"
-
-export default {
-  title: "UI/Shell Submessage Motion",
-  id: "components-shell-submessage-motion",
-  tags: ["autodocs"],
-  parameters: {
-    docs: {
-      description: {
-        component: `### Overview
-Interactive playground for animating the Shell tool subtitle ("submessage") in the timeline trigger row.
-
-### Production component path
-- Trigger layout: \`packages/ui/src/components/basic-tool.tsx\`
-- Bash tool subtitle source: \`packages/ui/src/components/message-part.tsx\` (tool: \`bash\`, \`trigger.subtitle\`)
-
-### What this playground tunes
-- Width reveal (spring-driven pixel width via \`useSpring\`)
-- Opacity fade
-- Blur settle`,
-      },
-    },
-  },
-}
-
-const btn = (accent?: boolean) =>
-  ({
-    padding: "6px 14px",
-    "border-radius": "6px",
-    border: "1px solid var(--color-divider, #333)",
-    background: accent ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)",
-    color: "var(--color-text, #eee)",
-    cursor: "pointer",
-    "font-size": "13px",
-  }) as const
-
-const sliderLabel = {
-  "font-size": "11px",
-  "font-family": "monospace",
-  color: "var(--color-text-weak, #666)",
-  "min-width": "84px",
-  "flex-shrink": "0",
-  "text-align": "right",
-}
-
-const sliderValue = {
-  "font-family": "monospace",
-  "font-size": "11px",
-  color: "var(--color-text-weak, #aaa)",
-  "min-width": "76px",
-}
-
-const shellCss = `
-[data-component="shell-submessage-scene"] [data-component="tool-trigger"] [data-slot="basic-tool-tool-info-main"] {
-  align-items: baseline;
-}
-
-[data-component="shell-submessage"] {
-  min-width: 0;
-  max-width: 100%;
-  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;
-  opacity: 0;
-  filter: blur(var(--shell-sub-blur, 2px));
-  transition-property: opacity, filter;
-  transition-duration: var(--shell-sub-fade-ms, 320ms);
-  transition-timing-function: var(--shell-sub-fade-ease, cubic-bezier(0.22, 1, 0.36, 1));
-}
-
-[data-component="shell-submessage"][data-visible] [data-slot="shell-submessage-value"] {
-  opacity: 1;
-  filter: blur(0px);
-}
-`
-
-const ease = {
-  smooth: "cubic-bezier(0.16, 1, 0.3, 1)",
-  snappy: "cubic-bezier(0.22, 1, 0.36, 1)",
-  standard: "cubic-bezier(0.2, 0.8, 0.2, 1)",
-  linear: "linear",
-}
-
-function SpringSubmessage(props: { text: string; visible: boolean; visualDuration: number; bounce: number }) {
-  let ref: HTMLSpanElement | undefined
-  let widthRef: HTMLSpanElement | undefined
-
-  createEffect(() => {
-    if (!widthRef) return
-    if (props.visible) {
-      requestAnimationFrame(() => {
-        ref?.setAttribute("data-visible", "")
-        animate(
-          widthRef!,
-          { width: "auto" },
-          { type: "spring", visualDuration: props.visualDuration, bounce: props.bounce },
-        )
-      })
-    } else {
-      ref?.removeAttribute("data-visible")
-      animate(
-        widthRef,
-        { width: "0px" },
-        { type: "spring", visualDuration: props.visualDuration, bounce: props.bounce },
-      )
-    }
-  })
-
-  return (
-    <span ref={ref} data-component="shell-submessage">
-      <span ref={widthRef} data-slot="shell-submessage-width" style={{ width: "0px" }}>
-        <span data-slot="basic-tool-tool-subtitle">
-          <span data-slot="shell-submessage-value">{props.text || "\u00A0"}</span>
-        </span>
-      </span>
-    </span>
-  )
-}
-
-export const Playground = {
-  render: () => {
-    const [text, setText] = createSignal("Prints five topic blocks between timed commands")
-    const [show, setShow] = createSignal(true)
-    const [visualDuration, setVisualDuration] = createSignal(0.35)
-    const [bounce, setBounce] = createSignal(0)
-    const [fadeMs, setFadeMs] = createSignal(320)
-    const [blur, setBlur] = createSignal(2)
-    const [fadeEase, setFadeEase] = createSignal<keyof typeof ease>("snappy")
-    const [auto, setAuto] = createSignal(false)
-    let replayTimer
-    let autoTimer
-
-    const replay = () => {
-      setShow(false)
-      if (replayTimer) clearTimeout(replayTimer)
-      replayTimer = setTimeout(() => {
-        setShow(true)
-      }, 50)
-    }
-
-    const stopAuto = () => {
-      if (autoTimer) clearInterval(autoTimer)
-      autoTimer = undefined
-      setAuto(false)
-    }
-
-    const toggleAuto = () => {
-      if (auto()) {
-        stopAuto()
-        return
-      }
-      setAuto(true)
-      autoTimer = setInterval(replay, 2200)
-    }
-
-    onCleanup(() => {
-      if (replayTimer) clearTimeout(replayTimer)
-      if (autoTimer) clearInterval(autoTimer)
-    })
-
-    return (
-      <div
-        data-component="shell-submessage-scene"
-        style={{
-          display: "grid",
-          gap: "20px",
-          padding: "20px",
-          "max-width": "860px",
-          "--shell-sub-fade-ms": `${fadeMs()}ms`,
-          "--shell-sub-blur": `${blur()}px`,
-          "--shell-sub-fade-ease": ease[fadeEase()],
-        }}
-      >
-        <style>{shellCss}</style>
-
-        <BasicTool
-          icon="console"
-          defaultOpen
-          trigger={
-            <div data-slot="basic-tool-tool-info-structured">
-              <div data-slot="basic-tool-tool-info-main">
-                <span data-slot="basic-tool-tool-title">Shell</span>
-                <SpringSubmessage text={text()} visible={show()} visualDuration={visualDuration()} bounce={bounce()} />
-              </div>
-            </div>
-          }
-        >
-          <div
-            style={{
-              "border-radius": "8px",
-              border: "1px solid var(--color-divider, #333)",
-              background: "var(--color-fill-secondary, #161616)",
-              padding: "14px 16px",
-              "font-family": "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
-              "font-size": "18px",
-              color: "var(--color-text, #eee)",
-              "white-space": "pre-wrap",
-            }}
-          >
-            {"$ cat <<'TOPIC1'"}
-          </div>
-        </BasicTool>
-
-        <div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
-          <button onClick={replay} style={btn()}>
-            Replay entry
-          </button>
-          <button onClick={() => setShow((v) => !v)} style={btn(show())}>
-            {show() ? "Hide subtitle" : "Show subtitle"}
-          </button>
-          <button onClick={toggleAuto} style={btn(auto())}>
-            {auto() ? "Stop auto replay" : "Auto replay"}
-          </button>
-        </div>
-
-        <div
-          style={{
-            display: "grid",
-            gap: "10px",
-            "border-top": "1px solid var(--color-divider, #333)",
-            "padding-top": "14px",
-          }}
-        >
-          <div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
-            <span style={sliderLabel}>subtitle</span>
-            <input
-              value={text()}
-              onInput={(e) => setText(e.currentTarget.value)}
-              style={{
-                width: "420px",
-                "max-width": "100%",
-                padding: "6px 8px",
-                "border-radius": "6px",
-                border: "1px solid var(--color-divider, #333)",
-                background: "var(--color-fill-element, #222)",
-                color: "var(--color-text, #eee)",
-              }}
-            />
-          </div>
-
-          <div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
-            <span style={sliderLabel}>visualDuration</span>
-            <input
-              type="range"
-              min={0.05}
-              max={1.5}
-              step={0.01}
-              value={visualDuration()}
-              onInput={(e) => setVisualDuration(Number(e.currentTarget.value))}
-            />
-            <span style={sliderValue}>{visualDuration().toFixed(2)}s</span>
-          </div>
-
-          <div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
-            <span style={sliderLabel}>bounce</span>
-            <input
-              type="range"
-              min={0}
-              max={0.5}
-              step={0.01}
-              value={bounce()}
-              onInput={(e) => setBounce(Number(e.currentTarget.value))}
-            />
-            <span style={sliderValue}>{bounce().toFixed(2)}</span>
-          </div>
-
-          <div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
-            <span style={sliderLabel}>fade ease</span>
-            <button
-              onClick={() =>
-                setFadeEase((v) =>
-                  v === "snappy" ? "smooth" : v === "smooth" ? "standard" : v === "standard" ? "linear" : "snappy",
-                )
-              }
-              style={btn()}
-            >
-              {fadeEase()}
-            </button>
-          </div>
-
-          <div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
-            <span style={sliderLabel}>fade</span>
-            <input
-              type="range"
-              min={0}
-              max={1400}
-              step={10}
-              value={fadeMs()}
-              onInput={(e) => setFadeMs(Number(e.currentTarget.value))}
-            />
-            <span style={sliderValue}>{fadeMs()}ms</span>
-          </div>
-
-          <div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
-            <span style={sliderLabel}>blur</span>
-            <input
-              type="range"
-              min={0}
-              max={14}
-              step={0.5}
-              value={blur()}
-              onInput={(e) => setBlur(Number(e.currentTarget.value))}
-            />
-            <span style={sliderValue}>{blur()}px</span>
-          </div>
-        </div>
-      </div>
-    )
-  },
-}

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

@@ -1,23 +1,13 @@
 [data-component="shell-submessage"] {
   min-width: 0;
   max-width: 100%;
-  display: inline-flex;
-  align-items: baseline;
+  display: inline-block;
   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;
 }

+ 30 - 31
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: top-to-bottom. New text drops in from above, old text exits downward.
+ * Direction: bottom-to-top. New text rises in from below, old text exits upward.
  *
- * Entering: gradient reveals top-to-bottom (top of text appears first).
+ * Entering: gradient reveals bottom-to-top (bottom 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 top-to-bottom (top of text disappears first).
+ * Leaving: gradient hides bottom-to-top (bottom 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 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)
+  /* ── 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)
    */
   [data-slot="text-reveal-entering"] {
-    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%;
+    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%;
     transition-property:
       mask-position,
       -webkit-mask-position,
@@ -74,37 +74,37 @@
     transform: translateY(0);
   }
 
-  /* ── 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)
+  /* ── 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)
    */
   [data-slot="text-reveal-leaving"] {
-    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%;
+    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%;
     transition-property:
       mask-position,
       -webkit-mask-position,
       transform;
-    transform: translateY(var(--_travel));
+    transform: translateY(calc(var(--_travel) * -1));
   }
 
   /* ── swapping: instant reset ──
-   * Snap entering to hidden (above), leaving to visible (center).
+   * Snap entering to hidden (below), leaving to visible (center).
    */
   &[data-swapping="true"] [data-slot="text-reveal-entering"] {
-    mask-position: 0 0%;
-    -webkit-mask-position: 0 0%;
-    transform: translateY(calc(var(--_travel) * -1));
+    mask-position: 0 100%;
+    -webkit-mask-position: 0 100%;
+    transform: translateY(var(--_travel));
     transition-duration: 0ms !important;
   }
 
   &[data-swapping="true"] [data-slot="text-reveal-leaving"] {
-    mask-position: 0 0%;
-    -webkit-mask-position: 0 0%;
+    mask-position: 0 100%;
+    -webkit-mask-position: 0 100%;
     transform: translateY(0);
     transition-duration: 0ms !important;
   }
@@ -126,15 +126,14 @@
   &[data-truncate="true"] [data-slot="text-reveal-track"] {
     width: 100%;
     min-width: 0;
-    overflow: hidden;
+    overflow: clip;
   }
 
   &[data-truncate="true"] [data-slot="text-reveal-entering"],
   &[data-truncate="true"] [data-slot="text-reveal-leaving"] {
     min-width: 0;
     width: 100%;
-    overflow: hidden;
-    text-overflow: ellipsis;
+    overflow: clip;
   }
 }
 

+ 0 - 310
packages/ui/src/components/text-reveal.stories.tsx

@@ -1,310 +0,0 @@
-// @ts-nocheck
-import { createSignal, onCleanup } from "solid-js"
-import { TextReveal } from "./text-reveal"
-
-export default {
-  title: "UI/TextReveal",
-  id: "components-text-reveal",
-  tags: ["autodocs"],
-  parameters: {
-    docs: {
-      description: {
-        component: `### Overview
-Playground for the TextReveal text transition component.
-
-**Hybrid** — mask wipe + vertical slide: gradient sweeps AND text moves downward.
-
-**Wipe only** — pure mask wipe: gradient sweeps top-to-bottom, text stays in place.`,
-      },
-    },
-  },
-}
-
-const TEXTS = [
-  "Refactor ToolStatusTitle DOM measurement",
-  "Remove inline measure nodes",
-  "Run typechecks and report changes",
-  "Verify reduced-motion behavior",
-  "Review diff for animation edge cases",
-  "Check keyboard semantics",
-  undefined,
-  "Planning key generation details",
-  "Analyzing error handling",
-  "Considering edge cases",
-]
-
-const btn = (accent?: boolean) =>
-  ({
-    padding: "5px 12px",
-    "border-radius": "6px",
-    border: accent ? "1px solid var(--color-accent, #58f)" : "1px solid var(--color-divider, #333)",
-    background: accent ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)",
-    color: "var(--color-text, #eee)",
-    cursor: "pointer",
-    "font-size": "12px",
-  }) as const
-
-const sliderLabel = {
-  width: "90px",
-  "font-size": "12px",
-  color: "var(--color-text-secondary, #a3a3a3)",
-  "flex-shrink": "0",
-} as const
-
-const cardStyle = {
-  padding: "20px 24px",
-  "border-radius": "10px",
-  border: "1px solid var(--color-divider, #333)",
-  background: "var(--color-fill-element, #1a1a1a)",
-  display: "grid",
-  gap: "12px",
-} as const
-
-const cardLabel = {
-  "font-size": "11px",
-  "font-family": "monospace",
-  color: "var(--color-text-weak, #666)",
-} as const
-
-const previewRow = {
-  display: "flex",
-  "align-items": "center",
-  gap: "8px",
-  "font-size": "14px",
-  "font-weight": "500",
-  "line-height": "20px",
-  color: "var(--text-weak, #aaa)",
-  "min-height": "20px",
-  overflow: "visible",
-} as const
-
-const headingSlot = {
-  "min-width": "0",
-  overflow: "visible",
-  color: "var(--text-weaker, #888)",
-  "font-weight": "400",
-} as const
-
-export const Playground = {
-  render: () => {
-    const [index, setIndex] = createSignal(0)
-    const [cycling, setCycling] = createSignal(false)
-    const [growOnly, setGrowOnly] = createSignal(true)
-
-    const [duration, setDuration] = createSignal(600)
-    const [bounce, setBounce] = createSignal(1.0)
-    const [bounceSoft, setBounceSoft] = createSignal(1.0)
-
-    const [hybridTravel, setHybridTravel] = createSignal(25)
-    const [hybridEdge, setHybridEdge] = createSignal(17)
-
-    const [edge, setEdge] = createSignal(17)
-    const [revealTravel, setRevealTravel] = createSignal(0)
-
-    let timer: number | undefined
-    const text = () => TEXTS[index()]
-    const next = () => setIndex((i) => (i + 1) % TEXTS.length)
-    const prev = () => setIndex((i) => (i - 1 + TEXTS.length) % TEXTS.length)
-
-    const toggleCycle = () => {
-      if (cycling()) {
-        if (timer) clearTimeout(timer)
-        timer = undefined
-        setCycling(false)
-        return
-      }
-      setCycling(true)
-      const tick = () => {
-        next()
-        timer = window.setTimeout(tick, 700 + Math.floor(Math.random() * 600))
-      }
-      timer = window.setTimeout(tick, 700 + Math.floor(Math.random() * 600))
-    }
-
-    onCleanup(() => {
-      if (timer) clearTimeout(timer)
-    })
-
-    const spring = () => `cubic-bezier(0.34, ${bounce()}, 0.64, 1)`
-    const springSoft = () => `cubic-bezier(0.34, ${bounceSoft()}, 0.64, 1)`
-
-    return (
-      <div style={{ display: "grid", gap: "24px", padding: "20px", "max-width": "700px" }}>
-        <div style={{ display: "grid", gap: "16px" }}>
-          <div style={cardStyle}>
-            <span style={cardLabel}>text-reveal (mask wipe + slide)</span>
-            <div style={previewRow}>
-              <span>Thinking</span>
-              <span style={headingSlot}>
-                <TextReveal
-                  class="text-14-regular"
-                  text={text()}
-                  duration={duration()}
-                  edge={hybridEdge()}
-                  travel={hybridTravel()}
-                  spring={spring()}
-                  springSoft={springSoft()}
-                  growOnly={growOnly()}
-                />
-              </span>
-            </div>
-          </div>
-
-          <div style={cardStyle}>
-            <span style={cardLabel}>text-reveal (mask wipe only)</span>
-            <div style={previewRow}>
-              <span>Thinking</span>
-              <span style={headingSlot}>
-                <TextReveal
-                  class="text-14-regular"
-                  text={text()}
-                  duration={duration()}
-                  edge={edge()}
-                  travel={revealTravel()}
-                  spring={spring()}
-                  springSoft={springSoft()}
-                  growOnly={growOnly()}
-                />
-              </span>
-            </div>
-          </div>
-        </div>
-
-        <div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap" }}>
-          {TEXTS.map((t, i) => (
-            <button onClick={() => setIndex(i)} style={btn(index() === i)}>
-              {t ?? "(none)"}
-            </button>
-          ))}
-        </div>
-
-        <div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
-          <button onClick={prev} style={btn()}>
-            Prev
-          </button>
-          <button onClick={next} style={btn()}>
-            Next
-          </button>
-          <button onClick={toggleCycle} style={btn(cycling())}>
-            {cycling() ? "Stop cycle" : "Auto cycle"}
-          </button>
-          <button onClick={() => setGrowOnly((v) => !v)} style={btn(growOnly())}>
-            {growOnly() ? "growOnly: on" : "growOnly: off"}
-          </button>
-        </div>
-
-        <div style={{ display: "grid", gap: "8px", "max-width": "480px" }}>
-          <div style={{ "font-size": "11px", color: "var(--color-text-weak, #666)" }}>Hybrid (wipe + slide)</div>
-
-          <label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
-            <span style={sliderLabel}>edge</span>
-            <input
-              type="range"
-              min="1"
-              max="40"
-              step="1"
-              value={hybridEdge()}
-              onInput={(e) => setHybridEdge(e.currentTarget.valueAsNumber)}
-              style={{ flex: 1 }}
-            />
-            <span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{hybridEdge()}%</span>
-          </label>
-
-          <label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
-            <span style={sliderLabel}>travel</span>
-            <input
-              type="range"
-              min="0"
-              max="40"
-              step="1"
-              value={hybridTravel()}
-              onInput={(e) => setHybridTravel(e.currentTarget.valueAsNumber)}
-              style={{ flex: 1 }}
-            />
-            <span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{hybridTravel()}px</span>
-          </label>
-
-          <div style={{ "font-size": "11px", color: "var(--color-text-weak, #666)", "margin-top": "8px" }}>Shared</div>
-
-          <label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
-            <span style={sliderLabel}>duration</span>
-            <input
-              type="range"
-              min="100"
-              max="1400"
-              step="10"
-              value={duration()}
-              onInput={(e) => setDuration(e.currentTarget.valueAsNumber)}
-              style={{ flex: 1 }}
-            />
-            <span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{duration()}ms</span>
-          </label>
-
-          <label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
-            <span style={sliderLabel}>bounce</span>
-            <input
-              type="range"
-              min="1"
-              max="2"
-              step="0.01"
-              value={bounce()}
-              onInput={(e) => setBounce(e.currentTarget.valueAsNumber)}
-              style={{ flex: 1 }}
-            />
-            <span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{bounce().toFixed(2)}</span>
-          </label>
-
-          <label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
-            <span style={sliderLabel}>bounce soft</span>
-            <input
-              type="range"
-              min="1"
-              max="1.5"
-              step="0.01"
-              value={bounceSoft()}
-              onInput={(e) => setBounceSoft(e.currentTarget.valueAsNumber)}
-              style={{ flex: 1 }}
-            />
-            <span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{bounceSoft().toFixed(2)}</span>
-          </label>
-
-          <div style={{ "font-size": "11px", color: "var(--color-text-weak, #666)", "margin-top": "8px" }}>
-            Wipe only
-          </div>
-
-          <label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
-            <span style={sliderLabel}>edge</span>
-            <input
-              type="range"
-              min="1"
-              max="40"
-              step="1"
-              value={edge()}
-              onInput={(e) => setEdge(e.currentTarget.valueAsNumber)}
-              style={{ flex: 1 }}
-            />
-            <span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{edge()}%</span>
-          </label>
-
-          <label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
-            <span style={sliderLabel}>travel</span>
-            <input
-              type="range"
-              min="0"
-              max="16"
-              step="1"
-              value={revealTravel()}
-              onInput={(e) => setRevealTravel(e.currentTarget.valueAsNumber)}
-              style={{ flex: 1 }}
-            />
-            <span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{revealTravel()}px</span>
-          </label>
-        </div>
-
-        <div style={{ "font-size": "11px", color: "var(--color-text-weak, #888)", "font-family": "monospace" }}>
-          text: {text() ?? "(none)"} · growOnly: {growOnly() ? "on" : "off"}
-        </div>
-      </div>
-    )
-  },
-}

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini