Browse Source

fix(app): stabilize todo dock e2e with composer probe (#17267)

Luke Parker 1 month ago
parent
commit
96b1d8f639

+ 19 - 0
packages/app/e2e/AGENTS.md

@@ -176,6 +176,25 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
 - These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
 - These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
 - Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
 - Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
 
 
+### Wait on state
+
+- Never use wall-clock waits like `page.waitForTimeout(...)` to make a test pass
+- Avoid race-prone flows that assume work is finished after an action
+- Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers
+- Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state
+
+### Add hooks
+
+- If required state is not observable from the UI, add a small test-only driver or probe in app code instead of sleeps or fragile DOM checks
+- Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts`
+- Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony
+- When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI
+
+### Prefer helpers
+
+- Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise
+- Use direct locators when the interaction is simple and a helper would not add clarity
+
 ## Writing New Tests
 ## Writing New Tests
 
 
 1. Choose appropriate folder or create new one
 1. Choose appropriate folder or create new one

+ 171 - 71
packages/app/e2e/session/session-composer-dock.spec.ts

@@ -1,12 +1,11 @@
 import { test, expect } from "../fixtures"
 import { test, expect } from "../fixtures"
-import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
+import { composerEvent, type ComposerDriverState, type ComposerProbeState, type ComposerWindow } from "../../src/testing/session-composer"
+import { cleanupSession, clearSessionDockSeed, seedSessionQuestion } from "../actions"
 import {
 import {
   permissionDockSelector,
   permissionDockSelector,
   promptSelector,
   promptSelector,
   questionDockSelector,
   questionDockSelector,
   sessionComposerDockSelector,
   sessionComposerDockSelector,
-  sessionTodoDockSelector,
-  sessionTodoListSelector,
   sessionTodoToggleButtonSelector,
   sessionTodoToggleButtonSelector,
 } from "../selectors"
 } from "../selectors"
 
 
@@ -42,12 +41,8 @@ async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>
 
 
 async function clearPermissionDock(page: any, label: RegExp) {
 async function clearPermissionDock(page: any, label: RegExp) {
   const dock = page.locator(permissionDockSelector)
   const dock = page.locator(permissionDockSelector)
-  for (let i = 0; i < 3; i++) {
-    const count = await dock.count()
-    if (count === 0) return
-    await dock.getByRole("button", { name: label }).click()
-    await page.waitForTimeout(150)
-  }
+  await expect(dock).toBeVisible()
+  await dock.getByRole("button", { name: label }).click()
 }
 }
 
 
 async function setAutoAccept(page: any, enabled: boolean) {
 async function setAutoAccept(page: any, enabled: boolean) {
@@ -59,6 +54,120 @@ async function setAutoAccept(page: any, enabled: boolean) {
   await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
   await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
 }
 }
 
 
+async function expectQuestionBlocked(page: any) {
+  await expect(page.locator(questionDockSelector)).toBeVisible()
+  await expect(page.locator(promptSelector)).toHaveCount(0)
+}
+
+async function expectQuestionOpen(page: any) {
+  await expect(page.locator(questionDockSelector)).toHaveCount(0)
+  await expect(page.locator(promptSelector)).toBeVisible()
+}
+
+async function expectPermissionBlocked(page: any) {
+  await expect(page.locator(permissionDockSelector)).toBeVisible()
+  await expect(page.locator(promptSelector)).toHaveCount(0)
+}
+
+async function expectPermissionOpen(page: any) {
+  await expect(page.locator(permissionDockSelector)).toHaveCount(0)
+  await expect(page.locator(promptSelector)).toBeVisible()
+}
+
+async function todoDock(page: any, sessionID: string) {
+  await page.addInitScript(() => {
+    const win = window as ComposerWindow
+    win.__opencode_e2e = {
+      ...win.__opencode_e2e,
+      composer: {
+        enabled: true,
+        sessions: {},
+      },
+    }
+  })
+
+  const write = async (driver: ComposerDriverState | undefined) => {
+    await page.evaluate(
+      (input) => {
+        const win = window as ComposerWindow
+        const composer = win.__opencode_e2e?.composer
+        if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled")
+        composer.sessions ??= {}
+        const prev = composer.sessions[input.sessionID] ?? {}
+        if (!input.driver) {
+          if (!prev.probe) {
+            delete composer.sessions[input.sessionID]
+          } else {
+            composer.sessions[input.sessionID] = { probe: prev.probe }
+          }
+        } else {
+          composer.sessions[input.sessionID] = {
+            ...prev,
+            driver: input.driver,
+          }
+        }
+        window.dispatchEvent(new CustomEvent(input.event, { detail: { sessionID: input.sessionID } }))
+      },
+      { event: composerEvent, sessionID, driver },
+    )
+  }
+
+  const read = () =>
+    page.evaluate((sessionID) => {
+      const win = window as ComposerWindow
+      return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.probe ?? null
+    }, sessionID) as Promise<ComposerProbeState | null>
+
+  const api = {
+    async clear() {
+      await write(undefined)
+      return api
+    },
+    async open(todos: NonNullable<ComposerDriverState["todos"]>) {
+      await write({ live: true, todos })
+      return api
+    },
+    async finish(todos: NonNullable<ComposerDriverState["todos"]>) {
+      await write({ live: false, todos })
+      return api
+    },
+    async expectOpen(states: ComposerProbeState["states"]) {
+      await expect.poll(read, { timeout: 10_000 }).toMatchObject({
+        mounted: true,
+        collapsed: false,
+        hidden: false,
+        count: states.length,
+        states,
+      })
+      return api
+    },
+    async expectCollapsed(states: ComposerProbeState["states"]) {
+      await expect.poll(read, { timeout: 10_000 }).toMatchObject({
+        mounted: true,
+        collapsed: true,
+        hidden: true,
+        count: states.length,
+        states,
+      })
+      return api
+    },
+    async expectClosed() {
+      await expect.poll(read, { timeout: 10_000 }).toMatchObject({ mounted: false })
+      return api
+    },
+    async collapse() {
+      await page.locator(sessionTodoToggleButtonSelector).click()
+      return api
+    },
+    async expand() {
+      await page.locator(sessionTodoToggleButtonSelector).click()
+      return api
+    },
+  }
+
+  return api
+}
+
 async function withMockPermission<T>(
 async function withMockPermission<T>(
   page: any,
   page: any,
   request: {
   request: {
@@ -70,7 +179,7 @@ async function withMockPermission<T>(
     always?: string[]
     always?: string[]
   },
   },
   opts: { child?: any } | undefined,
   opts: { child?: any } | undefined,
-  fn: () => Promise<T>,
+  fn: (state: { resolved: () => Promise<void> }) => Promise<T>,
 ) {
 ) {
   let pending = [
   let pending = [
     {
     {
@@ -119,8 +228,14 @@ async function withMockPermission<T>(
 
 
   if (sessionList) await page.route("**/session?*", sessionList)
   if (sessionList) await page.route("**/session?*", sessionList)
 
 
+  const state = {
+    async resolved() {
+      await expect.poll(() => pending.length, { timeout: 10_000 }).toBe(0)
+    },
+  }
+
   try {
   try {
-    return await fn()
+    return await fn(state)
   } finally {
   } finally {
     await page.unroute("**/permission", list)
     await page.unroute("**/permission", list)
     await page.unroute("**/session/*/permissions/*", reply)
     await page.unroute("**/session/*/permissions/*", reply)
@@ -173,14 +288,12 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess
       })
       })
 
 
       const dock = page.locator(questionDockSelector)
       const dock = page.locator(questionDockSelector)
-      await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
-      await expect(page.locator(promptSelector)).toHaveCount(0)
+      await expectQuestionBlocked(page)
 
 
       await dock.locator('[data-slot="question-option"]').first().click()
       await dock.locator('[data-slot="question-option"]').first().click()
       await dock.getByRole("button", { name: /submit/i }).click()
       await dock.getByRole("button", { name: /submit/i }).click()
 
 
-      await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
-      await expect(page.locator(promptSelector)).toBeVisible()
+      await expectQuestionOpen(page)
     })
     })
   })
   })
 })
 })
@@ -199,15 +312,14 @@ test("blocked permission flow supports allow once", async ({ page, sdk, gotoSess
         metadata: { description: "Need permission for command" },
         metadata: { description: "Need permission for command" },
       },
       },
       undefined,
       undefined,
-      async () => {
+      async (state) => {
         await page.goto(page.url())
         await page.goto(page.url())
-        await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
-        await expect(page.locator(promptSelector)).toHaveCount(0)
+        await expectPermissionBlocked(page)
 
 
         await clearPermissionDock(page, /allow once/i)
         await clearPermissionDock(page, /allow once/i)
+        await state.resolved()
         await page.goto(page.url())
         await page.goto(page.url())
-        await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
-        await expect(page.locator(promptSelector)).toBeVisible()
+        await expectPermissionOpen(page)
       },
       },
     )
     )
   })
   })
@@ -226,15 +338,14 @@ test("blocked permission flow supports reject", async ({ page, sdk, gotoSession
         patterns: ["/tmp/opencode-e2e-perm-reject"],
         patterns: ["/tmp/opencode-e2e-perm-reject"],
       },
       },
       undefined,
       undefined,
-      async () => {
+      async (state) => {
         await page.goto(page.url())
         await page.goto(page.url())
-        await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
-        await expect(page.locator(promptSelector)).toHaveCount(0)
+        await expectPermissionBlocked(page)
 
 
         await clearPermissionDock(page, /deny/i)
         await clearPermissionDock(page, /deny/i)
+        await state.resolved()
         await page.goto(page.url())
         await page.goto(page.url())
-        await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
-        await expect(page.locator(promptSelector)).toBeVisible()
+        await expectPermissionOpen(page)
       },
       },
     )
     )
   })
   })
@@ -254,15 +365,14 @@ test("blocked permission flow supports allow always", async ({ page, sdk, gotoSe
         metadata: { description: "Need permission for command" },
         metadata: { description: "Need permission for command" },
       },
       },
       undefined,
       undefined,
-      async () => {
+      async (state) => {
         await page.goto(page.url())
         await page.goto(page.url())
-        await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
-        await expect(page.locator(promptSelector)).toHaveCount(0)
+        await expectPermissionBlocked(page)
 
 
         await clearPermissionDock(page, /allow always/i)
         await clearPermissionDock(page, /allow always/i)
+        await state.resolved()
         await page.goto(page.url())
         await page.goto(page.url())
-        await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
-        await expect(page.locator(promptSelector)).toBeVisible()
+        await expectPermissionOpen(page)
       },
       },
     )
     )
   })
   })
@@ -301,14 +411,12 @@ test("child session question request blocks parent dock and unblocks after submi
         })
         })
 
 
         const dock = page.locator(questionDockSelector)
         const dock = page.locator(questionDockSelector)
-        await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
-        await expect(page.locator(promptSelector)).toHaveCount(0)
+        await expectQuestionBlocked(page)
 
 
         await dock.locator('[data-slot="question-option"]').first().click()
         await dock.locator('[data-slot="question-option"]').first().click()
         await dock.getByRole("button", { name: /submit/i }).click()
         await dock.getByRole("button", { name: /submit/i }).click()
 
 
-        await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
-        await expect(page.locator(promptSelector)).toBeVisible()
+        await expectQuestionOpen(page)
       })
       })
     } finally {
     } finally {
       await cleanupSession({ sdk, sessionID: child.id })
       await cleanupSession({ sdk, sessionID: child.id })
@@ -344,17 +452,15 @@ test("child session permission request blocks parent dock and supports allow onc
           metadata: { description: "Need child permission" },
           metadata: { description: "Need child permission" },
         },
         },
         { child },
         { child },
-        async () => {
+        async (state) => {
           await page.goto(page.url())
           await page.goto(page.url())
-          const dock = page.locator(permissionDockSelector)
-          await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
-          await expect(page.locator(promptSelector)).toHaveCount(0)
+          await expectPermissionBlocked(page)
 
 
           await clearPermissionDock(page, /allow once/i)
           await clearPermissionDock(page, /allow once/i)
+          await state.resolved()
           await page.goto(page.url())
           await page.goto(page.url())
 
 
-          await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
-          await expect(page.locator(promptSelector)).toBeVisible()
+          await expectPermissionOpen(page)
         },
         },
       )
       )
     } finally {
     } finally {
@@ -365,36 +471,31 @@ test("child session permission request blocks parent dock and supports allow onc
 
 
 test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
 test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
   await withDockSession(sdk, "e2e composer dock todo", async (session) => {
   await withDockSession(sdk, "e2e composer dock todo", async (session) => {
-    await withDockSeed(sdk, session.id, async () => {
-      await gotoSession(session.id)
-
-      await seedSessionTodos(sdk, {
-        sessionID: session.id,
-        todos: [
-          { content: "first task", status: "pending", priority: "high" },
-          { content: "second task", status: "in_progress", priority: "medium" },
-        ],
-      })
-
-      await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1)
-      await expect(page.locator(sessionTodoListSelector)).toBeVisible()
-
-      await page.locator(sessionTodoToggleButtonSelector).click()
-      await expect(page.locator(sessionTodoListSelector)).toBeHidden()
-
-      await page.locator(sessionTodoToggleButtonSelector).click()
-      await expect(page.locator(sessionTodoListSelector)).toBeVisible()
-
-      await seedSessionTodos(sdk, {
-        sessionID: session.id,
-        todos: [
-          { content: "first task", status: "completed", priority: "high" },
-          { content: "second task", status: "cancelled", priority: "medium" },
-        ],
-      })
+    const dock = await todoDock(page, session.id)
+    await gotoSession(session.id)
+    await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
 
 
-      await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0)
-    })
+    try {
+      await dock.open([
+        { content: "first task", status: "pending", priority: "high" },
+        { content: "second task", status: "in_progress", priority: "medium" },
+      ])
+      await dock.expectOpen(["pending", "in_progress"])
+
+      await dock.collapse()
+      await dock.expectCollapsed(["pending", "in_progress"])
+
+      await dock.expand()
+      await dock.expectOpen(["pending", "in_progress"])
+
+      await dock.finish([
+        { content: "first task", status: "completed", priority: "high" },
+        { content: "second task", status: "cancelled", priority: "medium" },
+      ])
+      await dock.expectClosed()
+    } finally {
+      await dock.clear()
+    }
   })
   })
 })
 })
 
 
@@ -414,8 +515,7 @@ test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSe
         ],
         ],
       })
       })
 
 
-      await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(1)
-      await expect(page.locator(promptSelector)).toHaveCount(0)
+      await expectQuestionBlocked(page)
 
 
       await page.locator("main").click({ position: { x: 5, y: 5 } })
       await page.locator("main").click({ position: { x: 5, y: 5 } })
       await page.keyboard.type("abc")
       await page.keyboard.type("abc")

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

@@ -44,9 +44,9 @@ export function SessionComposerRegion(props: {
 }) {
 }) {
   const prompt = usePrompt()
   const prompt = usePrompt()
   const language = useLanguage()
   const language = useLanguage()
-  const { sessionKey } = useSessionKey()
+  const route = useSessionKey()
 
 
-  const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt)
+  const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
 
 
   const previewPrompt = () =>
   const previewPrompt = () =>
     prompt
     prompt
@@ -62,7 +62,7 @@ export function SessionComposerRegion(props: {
 
 
   createEffect(() => {
   createEffect(() => {
     if (!prompt.ready()) return
     if (!prompt.ready()) return
-    setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
+    setSessionHandoff(route.sessionKey(), { prompt: previewPrompt() })
   })
   })
 
 
   const [store, setStore] = createStore({
   const [store, setStore] = createStore({
@@ -85,7 +85,7 @@ export function SessionComposerRegion(props: {
   }
   }
 
 
   createEffect(() => {
   createEffect(() => {
-    sessionKey()
+    route.sessionKey()
     const ready = props.ready
     const ready = props.ready
     const delay = 140
     const delay = 140
 
 
@@ -194,6 +194,7 @@ export function SessionComposerRegion(props: {
               >
               >
                 <div ref={(el) => setStore("body", el)}>
                 <div ref={(el) => setStore("body", el)}>
                   <SessionTodoDock
                   <SessionTodoDock
+                    sessionID={route.params.id}
                     todos={props.state.todos()}
                     todos={props.state.todos()}
                     title={language.t("session.todo.title")}
                     title={language.t("session.todo.title")}
                     collapseLabel={language.t("session.todo.collapse")}
                     collapseLabel={language.t("session.todo.collapse")}

+ 53 - 2
packages/app/src/pages/session/composer/session-composer-state.ts

@@ -1,4 +1,4 @@
-import { createEffect, createMemo, on, onCleanup } from "solid-js"
+import { createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createStore } from "solid-js/store"
 import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
 import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
 import { useParams } from "@solidjs/router"
 import { useParams } from "@solidjs/router"
@@ -8,6 +8,7 @@ import { useLanguage } from "@/context/language"
 import { usePermission } from "@/context/permission"
 import { usePermission } from "@/context/permission"
 import { useSDK } from "@/context/sdk"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
 import { useSync } from "@/context/sync"
+import { composerDriver, composerEnabled, composerEvent } from "@/testing/session-composer"
 import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
 import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
 
 
 export const todoState = (input: {
 export const todoState = (input: {
@@ -47,7 +48,50 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
     return !!permissionRequest() || !!questionRequest()
     return !!permissionRequest() || !!questionRequest()
   })
   })
 
 
+  const [test, setTest] = createStore({
+    on: false,
+    live: undefined as boolean | undefined,
+    todos: undefined as Todo[] | undefined,
+  })
+
+  const pull = () => {
+    const id = params.id
+    if (!id) {
+      setTest({ on: false, live: undefined, todos: undefined })
+      return
+    }
+
+    const next = composerDriver(id)
+    if (!next) {
+      setTest({ on: false, live: undefined, todos: undefined })
+      return
+    }
+
+    setTest({
+      on: true,
+      live: next.live,
+      todos: next.todos?.map((todo) => ({ ...todo })),
+    })
+  }
+
+  onMount(() => {
+    if (!composerEnabled()) return
+
+    pull()
+    createEffect(on(() => params.id, pull, { defer: true }))
+
+    const onEvent = (event: Event) => {
+      const detail = (event as CustomEvent<{ sessionID?: string }>).detail
+      if (detail?.sessionID !== params.id) return
+      pull()
+    }
+
+    window.addEventListener(composerEvent, onEvent)
+    onCleanup(() => window.removeEventListener(composerEvent, onEvent))
+  })
+
   const todos = createMemo((): Todo[] => {
   const todos = createMemo((): Todo[] => {
+    if (test.on && test.todos !== undefined) return test.todos
     const id = params.id
     const id = params.id
     if (!id) return []
     if (!id) return []
     return globalSync.data.session_todo[id] ?? []
     return globalSync.data.session_todo[id] ?? []
@@ -64,7 +108,10 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
   })
   })
 
 
   const busy = createMemo(() => status().type !== "idle")
   const busy = createMemo(() => status().type !== "idle")
-  const live = createMemo(() => busy() || blocked())
+  const live = createMemo(() => {
+    if (test.on && test.live !== undefined) return test.live
+    return busy() || blocked()
+  })
 
 
   const [store, setStore] = createStore({
   const [store, setStore] = createStore({
     responding: undefined as string | undefined,
     responding: undefined as string | undefined,
@@ -116,6 +163,10 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
 
 
   // Keep stale turn todos from reopening if the model never clears them.
   // Keep stale turn todos from reopening if the model never clears them.
   const clear = () => {
   const clear = () => {
+    if (test.on && test.todos !== undefined) {
+      setTest("todos", [])
+      return
+    }
     const id = params.id
     const id = params.id
     if (!id) return
     if (!id) return
     globalSync.todo.set(id, [])
     globalSync.todo.set(id, [])

+ 21 - 0
packages/app/src/pages/session/composer/session-todo-dock.tsx

@@ -8,6 +8,7 @@ import { TextReveal } from "@opencode-ai/ui/text-reveal"
 import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
 import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
 import { Index, createEffect, createMemo, on, onCleanup } from "solid-js"
 import { Index, createEffect, createMemo, on, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createStore } from "solid-js/store"
+import { composerEnabled, composerProbe } from "@/testing/session-composer"
 
 
 function dot(status: Todo["status"]) {
 function dot(status: Todo["status"]) {
   if (status !== "in_progress") return undefined
   if (status !== "in_progress") return undefined
@@ -35,6 +36,7 @@ function dot(status: Todo["status"]) {
 }
 }
 
 
 export function SessionTodoDock(props: {
 export function SessionTodoDock(props: {
+  sessionID?: string
   todos: Todo[]
   todos: Todo[]
   title: string
   title: string
   collapseLabel: string
   collapseLabel: string
@@ -69,6 +71,8 @@ export function SessionTodoDock(props: {
   const off = createMemo(() => hide() > 0.98)
   const off = createMemo(() => hide() > 0.98)
   const turn = createMemo(() => Math.max(0, Math.min(1, value())))
   const turn = createMemo(() => Math.max(0, Math.min(1, value())))
   const full = createMemo(() => Math.max(78, store.height))
   const full = createMemo(() => Math.max(78, store.height))
+  const e2e = composerEnabled()
+  const probe = composerProbe(props.sessionID)
   let contentRef: HTMLDivElement | undefined
   let contentRef: HTMLDivElement | undefined
 
 
   createEffect(() => {
   createEffect(() => {
@@ -83,6 +87,23 @@ export function SessionTodoDock(props: {
     onCleanup(() => observer.disconnect())
     onCleanup(() => observer.disconnect())
   })
   })
 
 
+  createEffect(() => {
+    if (!e2e) return
+
+    probe.set({
+      mounted: true,
+      collapsed: store.collapsed,
+      hidden: store.collapsed || off(),
+      count: props.todos.length,
+      states: props.todos.map((todo) => todo.status),
+    })
+  })
+
+  onCleanup(() => {
+    if (!e2e) return
+    probe.drop()
+  })
+
   return (
   return (
     <DockTray
     <DockTray
       data-component="session-todo-dock"
       data-component="session-todo-dock"

+ 84 - 0
packages/app/src/testing/session-composer.ts

@@ -0,0 +1,84 @@
+import type { Todo } from "@opencode-ai/sdk/v2"
+
+export const composerEvent = "opencode:e2e:composer"
+
+export type ComposerDriverState = {
+  live?: boolean
+  todos?: Array<Pick<Todo, "content" | "status" | "priority">>
+}
+
+export type ComposerProbeState = {
+  mounted: boolean
+  collapsed: boolean
+  hidden: boolean
+  count: number
+  states: Todo["status"][]
+}
+
+type ComposerState = {
+  driver?: ComposerDriverState
+  probe?: ComposerProbeState
+}
+
+export type ComposerWindow = Window & {
+  __opencode_e2e?: {
+    composer?: {
+      enabled?: boolean
+      sessions?: Record<string, ComposerState>
+    }
+  }
+}
+
+const clone = (driver: ComposerDriverState) => ({
+  live: driver.live,
+  todos: driver.todos?.map((todo) => ({ ...todo })),
+})
+
+export const composerEnabled = () => {
+  if (typeof window === "undefined") return false
+  return (window as ComposerWindow).__opencode_e2e?.composer?.enabled === true
+}
+
+const root = () => {
+  if (!composerEnabled()) return
+  const state = (window as ComposerWindow).__opencode_e2e?.composer
+  if (!state) return
+  state.sessions ??= {}
+  return state.sessions
+}
+
+export const composerDriver = (sessionID?: string) => {
+  if (!sessionID) return
+  const state = root()?.[sessionID]?.driver
+  if (!state) return
+  return clone(state)
+}
+
+export const composerProbe = (sessionID?: string) => {
+  const set = (next: ComposerProbeState) => {
+    if (!sessionID) return
+    const sessions = root()
+    if (!sessions) return
+    const prev = sessions[sessionID] ?? {}
+    sessions[sessionID] = {
+      ...prev,
+      probe: {
+        ...next,
+        states: [...next.states],
+      },
+    }
+  }
+
+  return {
+    set,
+    drop() {
+      set({
+        mounted: false,
+        collapsed: false,
+        hidden: true,
+        count: 0,
+        states: [],
+      })
+    },
+  }
+}