Explorar o código

fix(app): preserve todo dock across sessions (#33778)

Brendan Allan hai 1 mes
pai
achega
d13779b1d5

+ 132 - 0
packages/app/e2e/regression/session-request-docks.spec.ts

@@ -0,0 +1,132 @@
+import { base64Encode } from "@opencode-ai/core/util/encode"
+import { expect, test, type Page } from "@playwright/test"
+import { mockOpenCodeServer } from "../utils/mock-server"
+import { expectSessionTitle } from "../utils/waits"
+
+const directory = "C:/OpenCode/RequestDocks"
+const projectID = "proj_request_docks"
+const sessionID = "ses_request_docks"
+const title = "Request dock regression"
+
+test("shows a pending question dock", async ({ page }) => {
+  await mockServer(page, {
+    questions: [
+      {
+        id: "question-request",
+        sessionID,
+        questions: [
+          {
+            header: "Implementation",
+            question: "Which implementation should be used?",
+            options: [
+              { label: "Minimal", description: "Use the smallest correct change" },
+              { label: "Extended", description: "Include additional behavior" },
+            ],
+          },
+        ],
+      },
+    ],
+  })
+
+  await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
+  await expectSessionTitle(page, title)
+
+  const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
+  await expect(question).toBeVisible()
+  await expect(question.getByText("Which implementation should be used?")).toBeVisible()
+  await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible()
+  await expect(question.getByRole("radio", { name: /Extended/ })).toBeVisible()
+  await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
+
+  await question.getByRole("radio", { name: /Minimal/ }).click()
+  const reply = page.waitForRequest(
+    (request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
+  )
+  await question.getByRole("button", { name: "Submit" }).click()
+  expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
+})
+
+test("shows a pending permission dock", async ({ page }) => {
+  await mockServer(page, {
+    permissions: [
+      {
+        id: "permission-request",
+        sessionID,
+        permission: "bash",
+        patterns: ["git status", "git diff"],
+        metadata: {},
+        always: [],
+      },
+    ],
+  })
+
+  await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
+  await expectSessionTitle(page, title)
+
+  const permission = page.locator('[data-component="dock-prompt"][data-kind="permission"]')
+  await expect(permission).toBeVisible()
+  await expect(permission.getByText("git status")).toBeVisible()
+  await expect(permission.getByText("git diff")).toBeVisible()
+  await expect(permission.locator('[data-slot="permission-footer-actions"] button')).toHaveCount(3)
+  await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
+
+  const reply = page.waitForRequest((request) => request.method() === "POST")
+  await permission.getByRole("button", { name: "Allow once" }).click()
+  const request = await reply
+  expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`)
+  expect(request.postDataJSON()).toEqual({ response: "once" })
+})
+
+async function mockServer(
+  page: Page,
+  requests: {
+    permissions?: unknown[] | (() => unknown[])
+    questions?: unknown[] | (() => unknown[])
+  },
+) {
+  await mockOpenCodeServer(page, {
+    directory,
+    project: {
+      id: projectID,
+      worktree: directory,
+      vcs: "git",
+      name: "request-docks",
+      time: { created: 1700000000000, updated: 1700000000000 },
+      sandboxes: [],
+    },
+    provider: {
+      all: [
+        {
+          id: "opencode",
+          name: "OpenCode",
+          models: {
+            "claude-opus-4-6": {
+              id: "claude-opus-4-6",
+              name: "Claude Opus 4.6",
+              limit: { context: 200_000 },
+            },
+          },
+        },
+      ],
+      connected: ["opencode"],
+      default: { providerID: "opencode", modelID: "claude-opus-4-6" },
+    },
+    sessions: [
+      {
+        id: sessionID,
+        slug: "request-docks",
+        projectID,
+        directory,
+        title,
+        version: "dev",
+        time: { created: 1700000000000, updated: 1700000000000 },
+      },
+    ],
+    pageMessages: () => ({ items: [] }),
+    permissions: requests.permissions,
+    questions: requests.questions,
+  })
+  await page.addInitScript(() => {
+    localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
+  })
+}

+ 186 - 0
packages/app/e2e/regression/session-todo-dock-navigation.spec.ts

@@ -0,0 +1,186 @@
+import { base64Encode } from "@opencode-ai/core/util/encode"
+import { expect, test, type Page } from "@playwright/test"
+import { mockOpenCodeServer } from "../utils/mock-server"
+import { expectSessionTitle } from "../utils/waits"
+
+const directory = "C:/OpenCode/TodoDockNavigation"
+const projectID = "proj_todo_dock_navigation"
+const sourceID = "ses_todo_dock_source"
+const otherID = "ses_todo_dock_other"
+const sourceTitle = "Todo dock animation"
+const otherTitle = "Separate session"
+
+const activeTodos = [
+  { id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" },
+  { id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" },
+  { id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" },
+]
+
+type EventPayload = {
+  directory: string
+  payload: Record<string, unknown>
+}
+
+test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
+
+test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => {
+  test.setTimeout(90_000)
+  const events: EventPayload[] = []
+  const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
+
+  await mockOpenCodeServer(page, {
+    directory,
+    project: {
+      id: projectID,
+      worktree: directory,
+      vcs: "git",
+      name: "todo-dock-navigation",
+      time: { created: 1700000000000, updated: 1700000000000 },
+      sandboxes: [],
+    },
+    provider: {
+      all: [
+        {
+          id: "opencode",
+          name: "OpenCode",
+          models: {
+            "claude-opus-4-6": {
+              id: "claude-opus-4-6",
+              name: "Claude Opus 4.6",
+              limit: { context: 200_000 },
+            },
+          },
+        },
+      ],
+      connected: ["opencode"],
+      default: { providerID: "opencode", modelID: "claude-opus-4-6" },
+    },
+    sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
+    pageMessages: () => ({ items: [] }),
+    events: () => events.splice(0, 1),
+    eventRetry: 16,
+    todos: (sessionID) => todos[sessionID] ?? [],
+  })
+  await configurePage(page)
+
+  await page.goto(sessionHref(sourceID))
+  await expectSessionTitle(page, sourceTitle)
+  const dock = page.locator('[data-component="session-todo-dock"]')
+  await expect(dock).toHaveCount(0)
+
+  events.push(statusEvent(sourceID, "busy"))
+  await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
+
+  await page.waitForTimeout(700)
+  const opening = sampleDock(page, 1_000)
+  todos[sourceID] = activeTodos
+  events.push(todoEvent(sourceID, activeTodos))
+  await expect(dock).toBeVisible()
+  await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
+  expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
+
+  await switchSession(page, otherID, otherTitle)
+  await expect(dock).toHaveCount(0)
+
+  const returningOpen = sampleDock(page, 700)
+  await switchSession(page, sourceID, sourceTitle)
+  const openSamples = (await returningOpen).filter((sample) => sample.present)
+  expect(openSamples.length).toBeGreaterThan(0)
+  expect(openSamples[0]!.opacity).toBeGreaterThan(0.98)
+  expect(openSamples[0]!.height).toBeGreaterThan(70)
+  await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
+
+  const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" }))
+  const closing = sampleDock(page, 1_000)
+  todos[sourceID] = completedTodos
+  events.push(todoEvent(sourceID, completedTodos))
+  await expect(dock).toHaveCount(0)
+  expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
+  todos[sourceID] = []
+  events.push(todoEvent(sourceID, []))
+
+  await switchSession(page, otherID, otherTitle)
+  const returningEmpty = sampleDock(page, 700)
+  await switchSession(page, sourceID, sourceTitle)
+  await expect(dock).toHaveCount(0)
+  expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
+})
+
+function session(id: string, title: string, created: number) {
+  return {
+    id,
+    slug: id,
+    projectID,
+    directory,
+    title,
+    version: "dev",
+    time: { created, updated: created },
+  }
+}
+
+function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload {
+  return {
+    directory,
+    payload: { type: "session.status", properties: { sessionID, status: { type } } },
+  }
+}
+
+function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload {
+  return {
+    directory,
+    payload: { type: "todo.updated", properties: { sessionID, todos: next } },
+  }
+}
+
+async function configurePage(page: Page) {
+  const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
+  await page.addInitScript(
+    ({ directory, dirBase64, server, sessionIDs }) => {
+      localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
+      localStorage.setItem(
+        "opencode.global.dat:server",
+        JSON.stringify({
+          projects: { local: [{ worktree: directory, expanded: true }] },
+          lastProject: { local: directory },
+        }),
+      )
+      localStorage.setItem(
+        "opencode.global.dat:tabs",
+        JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
+      )
+    },
+    { directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] },
+  )
+}
+
+function sessionHref(sessionID: string) {
+  const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
+  return `/server/${base64Encode(server)}/session/${sessionID}`
+}
+
+async function switchSession(page: Page, sessionID: string, title: string) {
+  const href = sessionHref(sessionID)
+  const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
+  await expect(tab).toBeVisible()
+  await tab.click()
+  await expectSessionTitle(page, title)
+}
+
+function sampleDock(page: Page, duration: number) {
+  return page.evaluate(async (duration) => {
+    const samples: { present: boolean; height: number; opacity: number }[] = []
+    const start = performance.now()
+    while (performance.now() - start < duration) {
+      const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
+      const clip = dock?.parentElement?.parentElement
+      const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
+      samples.push({
+        present: !!dock,
+        height: clip?.getBoundingClientRect().height ?? 0,
+        opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
+      })
+      await new Promise(requestAnimationFrame)
+    }
+    return samples
+  }, duration)
+}

+ 11 - 11
packages/app/e2e/utils/mock-server.ts

@@ -1,15 +1,6 @@
 import type { Page, Route } from "@playwright/test"
 
-const emptyList = new Set([
-  "/skill",
-  "/command",
-  "/lsp",
-  "/formatter",
-  "/permission",
-  "/question",
-  "/vcs/status",
-  "/vcs/diff",
-])
+const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
 const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"])
 
 export interface MockServerConfig {
@@ -23,6 +14,9 @@ export interface MockServerConfig {
   onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
   events?: () => unknown[]
   eventRetry?: number
+  todos?: (sessionID: string) => unknown[]
+  permissions?: unknown[] | (() => unknown[])
+  questions?: unknown[] | (() => unknown[])
 }
 
 export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
@@ -55,6 +49,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
     const path = url.pathname
     if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
     if (path === "/global/health") return json(route, { healthy: true })
+    if (path === "/permission")
+      return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
+    if (path === "/question")
+      return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
     if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
     if (emptyObject.has(path)) return json(route, {})
     if (emptyList.has(path)) return json(route, [])
@@ -66,7 +64,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
       return json(route, session ?? {})
     }
 
-    if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
+    const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
+    if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
+    if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
 
     const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
     if (messagesMatch) {

+ 105 - 11
packages/app/src/components/prompt-input.stories.tsx

@@ -1,11 +1,27 @@
 // @ts-nocheck
 import { createStore } from "solid-js/store"
+import type { Todo } from "@opencode-ai/sdk/v2"
 import { createPromptState } from "@/context/prompt"
+import { SessionComposerRegion } from "@/pages/session/composer"
 import { createPromptInputHistory, PromptInput } from "./prompt-input"
 
-function PromptInputExample() {
+function createPromptInputStoryRuntime() {
   const state = createPromptState()
-  const history = createPromptInputHistory()
+  return {
+    state,
+    history: createPromptInputHistory(),
+    submission: {
+      abort() {},
+      handleSubmit(event: Event) {
+        event.preventDefault()
+        state.reset()
+      },
+    },
+  }
+}
+
+function PromptInputExample() {
+  const input = createPromptInputStoryRuntime()
   const [controls, setControls] = createStore({
     agent: "build",
     variant: undefined as string | undefined,
@@ -22,13 +38,6 @@ function PromptInputExample() {
       set: (variant?: string) => setControls("variant", variant),
     },
   }
-  const submission = {
-    abort() {},
-    handleSubmit(event: Event) {
-      event.preventDefault()
-      state.reset()
-    },
-  }
   const inputControls = {
     agents: {
       available: [{ name: "review", hidden: false, mode: "subagent" }],
@@ -69,7 +78,7 @@ function PromptInputExample() {
   const addReviewComment = () => {
     const comment = controls.comments + 1
     setControls("comments", comment)
-    state.context.add({
+    input.state.context.add({
       type: "file",
       path: "src/components/prompt-input.tsx",
       selection: {
@@ -87,7 +96,7 @@ function PromptInputExample() {
 
   return (
     <div class="flex flex-col gap-3">
-      <PromptInput controls={inputControls} state={state} history={history} submission={submission} />
+      <PromptInput controls={inputControls} {...input} />
       <div>
         <button
           type="button"
@@ -101,6 +110,82 @@ function PromptInputExample() {
   )
 }
 
+const todos: Todo[] = [
+  { id: "todo-1", content: "Inspect the session composer animation", status: "completed" },
+  { id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" },
+  { id: "todo-3", content: "Verify session navigation behavior", status: "pending" },
+]
+
+function PromptInputWithOpenDock() {
+  const input = createPromptInputStoryRuntime()
+  const [controls, setControls] = createStore({
+    agent: "build",
+    activeTab: undefined as string | undefined,
+    todoCollapsed: false,
+  })
+  const inputControls = {
+    agents: {
+      available: [],
+      options: ["build"],
+      get current() {
+        return controls.agent
+      },
+      loading: false,
+      visible: true,
+      select: (agent?: string) => setControls("agent", agent ?? "build"),
+    },
+    model: {
+      selection: {
+        current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
+        variant: { list: () => [], current: () => undefined, set: () => {} },
+      },
+      paid: true,
+      loading: false,
+    },
+    projects: { available: [], directory: "/tmp/story", select: () => {}, add: () => {} },
+    session: {
+      id: "story-session",
+      tabs: {
+        active: () => controls.activeTab,
+        all: () => [],
+        open: () => {},
+        setActive: (tab: string) => setControls("activeTab", tab),
+      },
+      reviewPanel: { opened: () => false, open: () => {} },
+    },
+    newLayoutDesigns: true,
+  }
+  const state = {
+    blocked: () => false,
+    questionRequest: () => undefined,
+    permissionRequest: () => undefined,
+    permissionResponding: () => false,
+    decide: () => {},
+    todos: () => todos,
+    dock: () => true,
+    closing: () => false,
+    opening: () => false,
+  }
+
+  return (
+    <SessionComposerRegion
+      state={state}
+      sessionKey="story-session"
+      sessionID="story-session"
+      controls={inputControls}
+      promptInput={{ ...input, ref: () => {}, newSessionWorktree: "", onNewSessionWorktreeReset: () => {} }}
+      todo={{
+        collapsed: controls.todoCollapsed,
+        onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
+      }}
+      ready
+      centered={false}
+      onResponseSubmit={() => {}}
+      setPromptDockRef={() => {}}
+    />
+  )
+}
+
 export default {
   title: "App/PromptInput",
   id: "app-prompt-input",
@@ -115,3 +200,12 @@ export const Basic = {
     </div>
   ),
 }
+
+export const DockAlreadyOpen = {
+  render: () => (
+    <div class="pt-10">
+      <h1 class="mb-4">Prompt Input with open Todo dock</h1>
+      <PromptInputWithOpenDock />
+    </div>
+  ),
+}

+ 26 - 7
packages/app/src/pages/new-session.tsx

@@ -6,7 +6,13 @@ import { useComments } from "@/context/comments"
 import { usePrompt } from "@/context/prompt"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
-import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
+import { useServerSync } from "@/context/server-sync"
+import {
+  createSessionComposerControls,
+  createSessionComposerState,
+  SessionComposerRegion,
+} from "@/pages/session/composer"
+import { useSessionKey } from "@/pages/session/session-layout"
 
 /**
  * The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
@@ -17,12 +23,19 @@ export default function NewSessionPage() {
   const prompt = usePrompt()
   const sdk = useSDK()
   const sync = useSync()
+  const serverSync = useServerSync()
   const comments = useComments()
+  const route = useSessionKey()
   const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
 
   let inputRef: HTMLDivElement | undefined
 
   const composer = createSessionComposerState()
+  const composerControls = createSessionComposerControls({
+    sessionKey: route.sessionKey,
+    sessionID: () => route.params.id,
+    queryOptions: serverSync().queryOptions,
+  })
 
   const [store, setStore] = createStore({
     worktree: "main",
@@ -57,15 +70,21 @@ export default function NewSessionPage() {
             <NewSessionDesignView>
               <SessionComposerRegion
                 state={composer}
+                sessionKey={route.sessionKey()}
+                sessionID={route.params.id}
+                controls={composerControls()}
+                promptInput={{
+                  ref: (el) => {
+                    inputRef = el
+                  },
+                  newSessionWorktree: newSessionWorktree(),
+                  onNewSessionWorktreeReset: () => setStore("worktree", "main"),
+                  onSubmit: () => comments.clear(),
+                }}
+                todo={{ collapsed: false, onToggle: () => {} }}
                 ready
                 centered={false}
                 placement="inline"
-                inputRef={(el) => {
-                  inputRef = el
-                }}
-                newSessionWorktree={newSessionWorktree()}
-                onNewSessionWorktreeReset={() => setStore("worktree", "main")}
-                onSubmit={() => comments.clear()}
                 onResponseSubmit={() => {}}
                 setPromptDockRef={() => {}}
               />

+ 28 - 10
packages/app/src/pages/session.tsx

@@ -43,7 +43,11 @@ import { useSettings } from "@/context/settings"
 import { useSync } from "@/context/sync"
 import { useTerminal } from "@/context/terminal"
 import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
-import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
+import {
+  createSessionComposerControls,
+  createSessionComposerState,
+  SessionComposerRegion,
+} from "@/pages/session/composer"
 import {
   createOpenReviewFile,
   createSessionTabs,
@@ -152,6 +156,11 @@ export default function Page() {
   })
 
   const composer = createSessionComposerState()
+  const composerControls = createSessionComposerControls({
+    sessionKey,
+    sessionID: () => params.id,
+    queryOptions: serverSync().queryOptions,
+  })
 
   const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
 
@@ -1561,6 +1570,24 @@ export default function Page() {
   const composerRegion = (placement: "dock" | "inline") => (
     <SessionComposerRegion
       state={composer}
+      sessionKey={sessionKey()}
+      sessionID={params.id}
+      controls={composerControls()}
+      promptInput={{
+        ref: (el) => {
+          inputRef = el
+        },
+        newSessionWorktree: newSessionWorktree(),
+        onNewSessionWorktreeReset: () => setStore("newSessionWorktree", "main"),
+        onSubmit: () => {
+          comments.clear()
+          resumeScroll()
+        },
+      }}
+      todo={{
+        collapsed: view().todoCollapsed.get(),
+        onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()),
+      }}
       ready={!store.deferRender && messagesReady()}
       centered={placement === "dock" && centered()}
       placement={placement}
@@ -1573,15 +1600,6 @@ export default function Page() {
             : legacySessionHref(sdk().directory, id),
         )
       }}
-      inputRef={(el) => {
-        inputRef = el
-      }}
-      newSessionWorktree={newSessionWorktree()}
-      onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
-      onSubmit={() => {
-        comments.clear()
-        resumeScroll()
-      }}
       onResponseSubmit={resumeScroll}
       followup={
         params.id && !isChildSession()

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

@@ -1,2 +1,3 @@
 export { SessionComposerRegion } from "./session-composer-region"
+export { createSessionComposerControls } from "./session-composer-controls"
 export { createSessionComposerState } from "./session-composer-state"

+ 115 - 0
packages/app/src/pages/session/composer/session-composer-controls.ts

@@ -0,0 +1,115 @@
+import { base64Encode } from "@opencode-ai/core/util/encode"
+import { createQuery } from "@tanstack/solid-query"
+import { useNavigate, useSearchParams } from "@solidjs/router"
+import { type Accessor, createMemo } from "solid-js"
+import type { PromptInputControls } from "@/components/prompt-input"
+import { useDirectoryPicker } from "@/components/directory-picker"
+import { useGlobal } from "@/context/global"
+import { useLayout } from "@/context/layout"
+import { useLocal } from "@/context/local"
+import type { QueryOptionsApi } from "@/context/server-sync"
+import { ServerConnection, useServer } from "@/context/server"
+import { useSDK } from "@/context/sdk"
+import { useSettings } from "@/context/settings"
+import { useSync } from "@/context/sync"
+import { type DraftTab, useTabs } from "@/context/tabs"
+import { useProviders } from "@/hooks/use-providers"
+import { pathKey } from "@/utils/path-key"
+
+export function createSessionComposerControls(input: {
+  sessionKey: Accessor<string>
+  sessionID: Accessor<string | undefined>
+  queryOptions: Pick<QueryOptionsApi, "agents" | "providers">
+}) {
+  const navigate = useNavigate()
+  const layout = useLayout()
+  const local = useLocal()
+  const providers = useProviders()
+  const settings = useSettings()
+  const server = useServer()
+  const sync = useSync()
+  const sdk = useSDK()
+  const tabs = useTabs()
+  const global = useGlobal()
+  const pickDirectory = useDirectoryPicker()
+  const [search] = useSearchParams<{ draftId?: string }>()
+  const view = layout.view(input.sessionKey)
+
+  const draft = createMemo(() => {
+    if (!search.draftId) return
+    return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
+  })
+  const projectServer = createMemo(() => {
+    if (!search.draftId) return server.current
+    const target = draft()?.server
+    if (!target) return
+    return server.list.find((conn) => ServerConnection.key(conn) === target)
+  })
+  const projectServerCtx = createMemo(() => {
+    const conn = projectServer()
+    if (conn) return global.ensureServerCtx(conn)
+  })
+  const projects = createMemo(() =>
+    search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list(),
+  )
+  const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory)))
+  const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null))
+  const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory)))
+
+  const selectProject = (worktree: string) => {
+    const conn = projectServer()
+    const target = projectServerCtx()
+    if (search.draftId) {
+      if (!conn || !target) return
+      target.projects.open(worktree)
+      target.projects.touch(worktree)
+      tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
+      return
+    }
+
+    layout.projects.open(worktree)
+    server.projects.touch(worktree)
+    navigate(`/${base64Encode(worktree)}/session`)
+  }
+
+  const addProject = (title: string) => {
+    const conn = projectServer()
+    if (!conn) return
+    pickDirectory({
+      server: conn,
+      title,
+      onSelect: (result) => {
+        const directory = Array.isArray(result) ? result[0] : result
+        if (directory) selectProject(directory)
+      },
+    })
+  }
+
+  return createMemo<PromptInputControls>(() => ({
+    agents: {
+      available: sync().data.agent,
+      options: local.agent.list().map((agent) => agent.name),
+      current: local.agent.current()?.name ?? "",
+      loading: agentsQuery.isLoading,
+      visible: settings.visibility.customAgents(),
+      select: local.agent.set,
+    },
+    model: {
+      selection: local.model,
+      paid: providers.paid().length > 0,
+      loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
+    },
+    projects: {
+      available: projects(),
+      directory: sdk().directory,
+      select: selectProject,
+      add: addProject,
+    },
+    session: {
+      id: input.sessionID(),
+      tabs: layout.tabs(input.sessionKey),
+      reviewPanel: view.reviewPanel,
+    },
+    newLayoutDesigns: settings.general.newLayoutDesigns(),
+  }))
+}

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

@@ -1,14 +1,11 @@
 import { Show, createEffect, createMemo, createResource, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
-import { useNavigate, useSearchParams } from "@solidjs/router"
 import { useSpring } from "@opencode-ai/ui/motion-spring"
-import { useLayout } from "@/context/layout"
-import { PromptInput } from "@/components/prompt-input"
+import { PromptInput, type PromptInputControls, type PromptInputProps } from "@/components/prompt-input"
 import { useLanguage } from "@/context/language"
 import { usePrompt } from "@/context/prompt"
 import { useSync } from "@/context/sync"
 import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
-import { useSessionKey } from "@/pages/session/session-layout"
 import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
 import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
 import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
@@ -18,29 +15,21 @@ import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
 import type { FollowupDraft } from "@/components/prompt-input/submit"
 import { createResizeObserver } from "@solid-primitives/resize-observer"
 import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
-import { createQuery } from "@tanstack/solid-query"
-import { useQueryOptions } from "@/context/server-sync"
-import { useSDK } from "@/context/sdk"
-import { pathKey } from "@/utils/path-key"
-import { useLocal } from "@/context/local"
-import { useProviders } from "@/hooks/use-providers"
-import { useSettings } from "@/context/settings"
-import { ServerConnection, useServer } from "@/context/server"
-import { type DraftTab, useTabs } from "@/context/tabs"
-import { useDirectoryPicker } from "@/components/directory-picker"
-import { base64Encode } from "@opencode-ai/core/util/encode"
-import { useGlobal } from "@/context/global"
 
 export function SessionComposerRegion(props: {
   state: SessionComposerState
+  sessionKey: string
+  sessionID?: string
+  controls: PromptInputControls
+  promptInput: Omit<PromptInputProps, "controls" | "variant">
+  todo: {
+    collapsed: boolean
+    onToggle: () => void
+  }
   ready: boolean
   centered: boolean
   placement?: "dock" | "inline"
   openParent?: () => void
-  inputRef: (el: HTMLDivElement) => void
-  newSessionWorktree: string
-  onNewSessionWorktreeReset: () => void
-  onSubmit: () => void
   onResponseSubmit: () => void
   followup?: {
     queue: () => boolean
@@ -61,102 +50,12 @@ export function SessionComposerRegion(props: {
   }
   setPromptDockRef: (el: HTMLDivElement) => void
 }) {
-  const navigate = useNavigate()
-  const layout = useLayout()
-  const prompt = usePrompt()
+  const prompt = props.promptInput.state ?? usePrompt()
   const language = useLanguage()
-  const route = useSessionKey()
   const sync = useSync()
-  const sdk = useSDK()
-  const queryOptions = useQueryOptions()
-  const local = useLocal()
-  const providers = useProviders(() => sdk().directory)
-  const settings = useSettings()
-  const server = useServer()
-  const tabs = useTabs()
-  const global = useGlobal()
-  const pickDirectory = useDirectoryPicker()
-  const [search] = useSearchParams<{ draftId?: string }>()
-  const view = layout.view(route.sessionKey)
-
-  const draft = createMemo(() => {
-    if (!search.draftId) return
-    return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
-  })
-  const projectServer = createMemo(() => {
-    if (!search.draftId) return server.current
-    const target = draft()?.server
-    if (!target) return
-    return server.list.find((conn) => ServerConnection.key(conn) === target)
-  })
-  const projectServerCtx = createMemo(() => {
-    const conn = projectServer()
-    if (conn) return global.ensureServerCtx(conn)
-  })
-  const projects = createMemo(() =>
-    search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list(),
-  )
-
-  const agentsQuery = createQuery(() => queryOptions().agents(pathKey(sdk().directory)))
-  const globalProvidersQuery = createQuery(() => queryOptions().providers(null))
-  const providersQuery = createQuery(() => queryOptions().providers(pathKey(sdk().directory)))
-  const selectProject = (worktree: string) => {
-    const conn = projectServer()
-    const target = projectServerCtx()
-    if (search.draftId) {
-      if (!conn || !target) return
-      target.projects.open(worktree)
-      target.projects.touch(worktree)
-      tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
-      return
-    }
 
-    layout.projects.open(worktree)
-    server.projects.touch(worktree)
-    navigate(`/${base64Encode(worktree)}/session`)
-  }
-  const addProject = (title: string) => {
-    const conn = projectServer()
-    if (!conn) return
-    pickDirectory({
-      server: conn,
-      title,
-      onSelect: (result) => {
-        const directory = Array.isArray(result) ? result[0] : result
-        if (directory) selectProject(directory)
-      },
-    })
-  }
-  const controls = createMemo(() => ({
-    agents: {
-      available: sync().data.agent,
-      options: local.agent.list().map((agent) => agent.name),
-      current: local.agent.current()?.name ?? "",
-      loading: agentsQuery.isLoading,
-      visible: settings.visibility.customAgents(),
-      select: local.agent.set,
-    },
-    model: {
-      selection: local.model,
-      paid: providers.paid().length > 0,
-      loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
-    },
-    projects: {
-      available: projects(),
-      directory: sdk().directory,
-      select: selectProject,
-      add: addProject,
-    },
-    session: {
-      id: route.params.id,
-      tabs: layout.tabs(route.sessionKey),
-      reviewPanel: view.reviewPanel,
-    },
-    newLayoutDesigns: settings.general.newLayoutDesigns(),
-  }))
-
-  const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
-  const info = createMemo(() => (route.params.id ? sync().session.get(route.params.id) : undefined))
+  const handoffPrompt = createMemo(() => getSessionHandoff(props.sessionKey)?.prompt)
+  const info = createMemo(() => (props.sessionID ? sync().session.get(props.sessionID) : undefined))
   const parentID = createMemo(() => info()?.parentID)
   const child = createMemo(() => !!parentID())
   const showComposer = createMemo(() => !props.state.blocked() || child())
@@ -175,11 +74,11 @@ export function SessionComposerRegion(props: {
 
   createEffect(() => {
     if (!prompt.ready()) return
-    setSessionHandoff(route.sessionKey(), { prompt: previewPrompt() })
+    setSessionHandoff(props.sessionKey, { prompt: previewPrompt() })
   })
 
   const [store, setStore] = createStore({
-    ready: false,
+    ready: props.ready || props.state.dock(),
     height: 320,
     body: undefined as HTMLDivElement | undefined,
   })
@@ -198,13 +97,17 @@ export function SessionComposerRegion(props: {
   }
 
   createEffect(() => {
-    route.sessionKey()
+    props.sessionKey
     const ready = props.ready
+    const dock = props.state.dock()
     const delay = 140
 
     clear()
-    setStore("ready", false)
-    if (!ready) return
+    if (store.ready || (!ready && !dock)) return
+    if (dock) {
+      setStore("ready", true)
+      return
+    }
 
     frame = requestAnimationFrame(() => {
       frame = undefined
@@ -218,7 +121,11 @@ export function SessionComposerRegion(props: {
   onCleanup(clear)
 
   const open = createMemo(() => store.ready && props.state.dock() && !props.state.closing())
-  const progress = useSpring(() => (open() ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
+  const progress = useSpring(
+    () => (open() ? 1 : 0),
+    { visualDuration: 0.3, bounce: 0 },
+    () => `${props.sessionKey}\0${store.ready}`,
+  )
   const value = createMemo(() => Math.max(0, Math.min(1, progress())))
   const dock = createMemo(() => (store.ready && props.state.dock()) || value() > 0.001)
   const rolled = createMemo(() => (props.revert?.items.length ? props.revert : undefined))
@@ -280,8 +187,30 @@ export function SessionComposerRegion(props: {
         </Show>
 
         <Show when={showComposer()}>
+          <Show when={dock()}>
+            <div
+              classList={{
+                "overflow-hidden": true,
+                "pointer-events-none": value() < 0.98,
+              }}
+              style={{
+                "max-height": `${full() * value()}px`,
+              }}
+            >
+              <div ref={(el) => setStore("body", el)}>
+                <SessionTodoDock
+                  todos={props.state.todos()}
+                  collapsed={props.todo.collapsed}
+                  onToggle={props.todo.onToggle}
+                  collapseLabel={language.t("session.todo.collapse")}
+                  expandLabel={language.t("session.todo.expand")}
+                  dockProgress={value()}
+                />
+              </div>
+            </div>
+          </Show>
           <Show
-            when={promptReadyResource()}
+            when={prompt.ready() || promptReadyResource()}
             fallback={
               <>
                 <Show when={rolled()} keyed>
@@ -296,35 +225,15 @@ export function SessionComposerRegion(props: {
                     </div>
                   )}
                 </Show>
-                <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
+                <div
+                  class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"
+                  style={{ "margin-top": `${-36 * value()}px` }}
+                >
                   {handoffPrompt() || language.t("prompt.loading")}
                 </div>
               </>
             }
           >
-            <Show when={dock()}>
-              <div
-                classList={{
-                  "overflow-hidden": true,
-                  "pointer-events-none": value() < 0.98,
-                }}
-                style={{
-                  "max-height": `${full() * value()}px`,
-                }}
-              >
-                <div ref={(el) => setStore("body", el)}>
-                  <SessionTodoDock
-                    sessionID={route.params.id}
-                    todos={props.state.todos()}
-                    collapsed={view.todoCollapsed.get()}
-                    onToggle={() => view.todoCollapsed.set(!view.todoCollapsed.get())}
-                    collapseLabel={language.t("session.todo.collapse")}
-                    expandLabel={language.t("session.todo.expand")}
-                    dockProgress={value()}
-                  />
-                </div>
-              </div>
-            </Show>
             <Show when={rolled()} keyed>
               {(revert) => (
                 <div
@@ -362,23 +271,20 @@ export function SessionComposerRegion(props: {
                 fallback={
                   <Show when={!props.state.blocked()}>
                     <PromptInput
-                      controls={controls()}
+                      {...props.promptInput}
+                      controls={props.controls}
                       variant={props.placement === "inline" ? "new-session" : undefined}
-                      ref={props.inputRef}
-                      newSessionWorktree={props.newSessionWorktree}
-                      onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
                       edit={props.followup?.edit}
                       onEditLoaded={props.followup?.onEditLoaded}
                       shouldQueue={props.followup?.queue}
                       onQueue={props.followup?.onQueue}
                       onAbort={props.followup?.onAbort}
-                      onSubmit={props.onSubmit}
                     />
                   </Show>
                 }
               >
                 <div
-                  ref={props.inputRef}
+                  ref={props.promptInput.ref}
                   class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
                 >
                   <span>{language.t("session.child.promptDisabled")} </span>

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

@@ -1,6 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
-import { todoState } from "./session-composer-state"
+import { todoDockAtBoundary, todoState } from "./session-composer-state"
 import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
 
 const session = (input: { id: string; parentID?: string }) =>
@@ -126,3 +126,13 @@ describe("todoState", () => {
     expect(todoState({ count: 2, done: true, live: false })).toBe("clear")
   })
 })
+
+describe("todoDockAtBoundary", () => {
+  test("shows active todos when entering a session", () => {
+    expect(todoDockAtBoundary("open")).toBe(true)
+  })
+
+  test("hides completed todos when entering a session", () => {
+    expect(todoDockAtBoundary("close")).toBe(false)
+  })
+})

+ 20 - 6
packages/app/src/pages/session/composer/session-composer-state.ts

@@ -21,6 +21,8 @@ export const todoState = (input: {
   return "close"
 }
 
+export const todoDockAtBoundary = (state: ReturnType<typeof todoState>) => state === "open"
+
 const idle = { type: "idle" as const }
 
 export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
@@ -60,8 +62,9 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
   const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked())
 
   const [store, setStore] = createStore({
+    sessionID: params.id,
     responding: undefined as string | undefined,
-    dock: todos().length > 0 && live(),
+    dock: todos().length > 0 && !done() && live(),
     closing: false,
     opening: false,
   })
@@ -116,8 +119,8 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
 
   createEffect(
     on(
-      () => [todos().length, done(), live()] as const,
-      ([count, complete, active]) => {
+      () => [params.id, todos().length, done(), live()] as const,
+      ([id, count, complete, active], previous) => {
         if (raf) cancelAnimationFrame(raf)
         raf = undefined
 
@@ -127,6 +130,14 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
           live: active,
         })
 
+        if (!previous || previous[0] !== id) {
+          if (timer) window.clearTimeout(timer)
+          timer = undefined
+          setStore({ sessionID: id, dock: todoDockAtBoundary(next), closing: false, opening: false })
+          if (next === "clear") clear()
+          return
+        }
+
         if (next === "hide") {
           if (timer) window.clearTimeout(timer)
           timer = undefined
@@ -181,9 +192,12 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
     permissionResponding,
     decide,
     todos,
-    dock: () => store.dock,
-    closing: () => store.closing,
-    opening: () => store.opening,
+    dock: () =>
+      store.sessionID === params.id
+        ? store.dock
+        : todoDockAtBoundary(todoState({ count: todos().length, done: done(), live: live() })),
+    closing: () => store.sessionID === params.id && store.closing,
+    opening: () => store.sessionID === params.id && store.opening,
   }
 }
 

+ 2 - 3
packages/app/src/pages/session/composer/session-todo-dock.tsx

@@ -40,7 +40,6 @@ function dot(status: Todo["status"]) {
 }
 
 export function SessionTodoDock(props: {
-  sessionID?: string
   todos: Todo[]
   collapsed: boolean
   onToggle: () => void
@@ -50,7 +49,7 @@ export function SessionTodoDock(props: {
 }) {
   const language = useLanguage()
   const [store, setStore] = createStore({
-    height: 320,
+    height: 78,
   })
 
   const total = createMemo(() => props.todos.length)
@@ -85,7 +84,7 @@ export function SessionTodoDock(props: {
     const el = contentRef
     if (!el) return
     const update = () => {
-      setStore("height", el.getBoundingClientRect().height)
+      setStore("height", (height) => Math.max(height, el.scrollHeight))
     }
     update()
     createResizeObserver(el, update)

+ 34 - 15
packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx

@@ -50,6 +50,25 @@ const btn = (accent?: boolean) =>
     "font-size": "13px",
   }) as const
 
+const controls = {
+  agents: { available: [], options: ["build"], current: "build", loading: false, visible: true, select: () => {} },
+  model: {
+    selection: {
+      current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
+      variant: { list: () => [], current: () => undefined, set: () => {} },
+    },
+    paid: true,
+    loading: false,
+  },
+  projects: { available: [], directory: "/tmp/story", select: () => {}, add: () => {} },
+  session: {
+    id: "story-session",
+    tabs: { active: () => undefined, all: () => [], open: () => {}, setActive: () => {} },
+    reviewPanel: { opened: () => false, open: () => {} },
+  },
+  newLayoutDesigns: true,
+}
+
 const css = `
 [data-component="todo-stage"] {
   display: grid;
@@ -132,6 +151,7 @@ export const Playground = {
     const global = useServerSync()
     const [cfg, setCfg] = createStore({
       open: true,
+      collapsed: false,
       step: 1,
       dockOpenDuration: 0.3,
       dockOpenBounce: 0,
@@ -170,7 +190,6 @@ export const Playground = {
     const countWidthDuration = () => cfg.countWidthDuration
     const state = createSessionComposerState({ closeMs: () => Math.round(dockCloseDuration() * 1000) })
     let frame
-    let composerRef
     let scrollRef
 
     const todos = createMemo<Todo[]>(() => {
@@ -196,15 +215,8 @@ export const Playground = {
       scrollRef.scrollTop = scrollRef.scrollHeight
     }
 
-    const collapsed = () =>
-      !!composerRef?.querySelector('[data-action="session-todo-toggle-button"][data-collapsed="true"]')
-
-    const setCollapsed = (value: boolean) => {
-      const button = composerRef?.querySelector('[data-action="session-todo-toggle-button"]')
-      if (!(button instanceof HTMLButtonElement)) return
-      if (collapsed() === value) return
-      button.click()
-    }
+    const collapsed = () => cfg.collapsed
+    const setCollapsed = (value: boolean) => setCfg("collapsed", value)
 
     const openDock = () => {
       clear()
@@ -267,14 +279,21 @@ export const Playground = {
                   </div>
                 </div>
 
-                <div ref={composerRef}>
+                <div>
                   <SessionComposerRegion
                     state={state}
+                    sessionKey="story-session"
+                    sessionID="story-session"
+                    controls={controls}
+                    promptInput={{
+                      submission: { abort: () => {}, handleSubmit: (event) => event.preventDefault() },
+                      ref: () => {},
+                      newSessionWorktree: "",
+                      onNewSessionWorktreeReset: () => {},
+                    }}
+                    todo={{ collapsed: collapsed(), onToggle: () => setCollapsed(!collapsed()) }}
+                    ready
                     centered={false}
-                    inputRef={() => {}}
-                    newSessionWorktree=""
-                    onNewSessionWorktreeReset={() => {}}
-                    onSubmit={() => {}}
                     onResponseSubmit={pin}
                     setPromptDockRef={() => {}}
                     dockOpenVisualDuration={dockOpenDuration()}

+ 20 - 0
packages/app/test-browser/motion-spring.test.ts

@@ -0,0 +1,20 @@
+import { expect, test } from "bun:test"
+import { useSpring } from "@opencode-ai/ui/motion-spring"
+import { createRoot, createSignal } from "solid-js"
+
+test("snaps spring progress when the session changes", async () => {
+  const state = createRoot((dispose) => {
+    const [target, setTarget] = createSignal(0)
+    const [session, setSession] = createSignal("session-a")
+    const progress = useSpring(target, { visualDuration: 0.3, bounce: 0 }, session)
+    return { dispose, progress, setTarget, setSession }
+  })
+
+  await new Promise<void>(queueMicrotask)
+  state.setTarget(1)
+  expect(state.progress()).toBe(0)
+
+  state.setSession("session-b")
+  expect(state.progress()).toBe(1)
+  state.dispose()
+})

+ 1 - 1
packages/storybook/.storybook/mocks/app/context/prompt.ts

@@ -74,7 +74,7 @@ export function createPromptState() {
   })
 
   return {
-    ready: () => ready,
+    ready,
     current: () => store.prompt,
     cursor: () => store.cursor,
     dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),

+ 17 - 4
packages/ui/src/components/motion-spring.tsx

@@ -1,6 +1,6 @@
 import { attachSpring, motionValue } from "motion"
 import type { SpringOptions } from "motion"
-import { createEffect, createSignal, onCleanup } from "solid-js"
+import { createComputed, createEffect, createSignal, onCleanup } from "solid-js"
 
 type Opt = Partial<Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">>
 const eq = (a: Opt | undefined, b: Opt | undefined) =>
@@ -11,17 +11,30 @@ const eq = (a: Opt | undefined, b: Opt | undefined) =>
   a?.mass === b?.mass &&
   a?.velocity === b?.velocity
 
-export function useSpring(target: () => number, options?: Opt | (() => Opt)) {
+export function useSpring(target: () => number, options?: Opt | (() => Opt), snapKey?: () => unknown) {
   const read = () => (typeof options === "function" ? options() : options)
   const [value, setValue] = createSignal(target())
   const source = motionValue(value())
   const spring = motionValue(value())
   let config = read()
+  let snapValue = snapKey?.()
   let stop = attachSpring(spring, source, config)
   let off = spring.on("change", (next: number) => setValue(next))
 
-  createEffect(() => {
-    source.set(target())
+  createComputed(() => {
+    const next = target()
+    const nextSnap = snapKey?.()
+    if (snapKey && nextSnap !== snapValue) {
+      // State boundaries should adopt their target without animating from the previous context.
+      snapValue = nextSnap
+      stop()
+      spring.jump(next)
+      source.jump(next)
+      stop = attachSpring(spring, source, config)
+      setValue(next)
+      return
+    }
+    source.set(next)
   })
 
   createEffect(() => {