From 96b1d8f639991e896bc8d31afe64d6309bf3ccd2 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:21:50 +1000 Subject: fix(app): stabilize todo dock e2e with composer probe (#17267) --- .../session/composer/session-composer-region.tsx | 9 +-- .../session/composer/session-composer-state.ts | 55 +++++++++++++- .../pages/session/composer/session-todo-dock.tsx | 21 ++++++ packages/app/src/testing/session-composer.ts | 84 ++++++++++++++++++++++ 4 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 packages/app/src/testing/session-composer.ts (limited to 'packages/app/src') diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 2034fbead..84f77ea4a 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -44,9 +44,9 @@ export function SessionComposerRegion(props: { }) { const prompt = usePrompt() const language = useLanguage() - const { sessionKey } = useSessionKey() + const route = useSessionKey() - const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt) + const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt) const previewPrompt = () => prompt @@ -62,7 +62,7 @@ export function SessionComposerRegion(props: { createEffect(() => { if (!prompt.ready()) return - setSessionHandoff(sessionKey(), { prompt: previewPrompt() }) + setSessionHandoff(route.sessionKey(), { prompt: previewPrompt() }) }) const [store, setStore] = createStore({ @@ -85,7 +85,7 @@ export function SessionComposerRegion(props: { } createEffect(() => { - sessionKey() + route.sessionKey() const ready = props.ready const delay = 140 @@ -194,6 +194,7 @@ export function SessionComposerRegion(props: { >
setStore("body", el)}> 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[] => { + if (test.on && test.todos !== undefined) return test.todos const id = params.id if (!id) return [] return globalSync.data.session_todo[id] ?? [] @@ -64,7 +108,10 @@ export function createSessionComposerState(options?: { closeMs?: number | (() => }) 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({ 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. const clear = () => { + if (test.on && test.todos !== undefined) { + setTest("todos", []) + return + } const id = params.id if (!id) return globalSync.todo.set(id, []) diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx index c7907bb54..5500de97a 100644 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ b/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 { Index, createEffect, createMemo, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" +import { composerEnabled, composerProbe } from "@/testing/session-composer" function dot(status: Todo["status"]) { if (status !== "in_progress") return undefined @@ -35,6 +36,7 @@ function dot(status: Todo["status"]) { } export function SessionTodoDock(props: { + sessionID?: string todos: Todo[] title: string collapseLabel: string @@ -69,6 +71,8 @@ export function SessionTodoDock(props: { const off = createMemo(() => hide() > 0.98) const turn = createMemo(() => Math.max(0, Math.min(1, value()))) const full = createMemo(() => Math.max(78, store.height)) + const e2e = composerEnabled() + const probe = composerProbe(props.sessionID) let contentRef: HTMLDivElement | undefined createEffect(() => { @@ -83,6 +87,23 @@ export function SessionTodoDock(props: { 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 ( > +} + +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 + } + } +} + +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: [], + }) + }, + } +} -- cgit v1.2.3