summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src
diff options
context:
space:
mode:
authorLuke Parker <[email protected]>2026-03-13 12:21:50 +1000
committerGitHub <[email protected]>2026-03-13 12:21:50 +1000
commit96b1d8f639991e896bc8d31afe64d6309bf3ccd2 (patch)
treed51a398a9e13046b634a5cbe517f5ecbe72db765 /packages/app/src
parentdcb17c6a678918ce0786640729fcc8cd8adb1746 (diff)
downloadopencode-96b1d8f639991e896bc8d31afe64d6309bf3ccd2.tar.gz
opencode-96b1d8f639991e896bc8d31afe64d6309bf3ccd2.zip
fix(app): stabilize todo dock e2e with composer probe (#17267)
Diffstat (limited to 'packages/app/src')
-rw-r--r--packages/app/src/pages/session/composer/session-composer-region.tsx9
-rw-r--r--packages/app/src/pages/session/composer/session-composer-state.ts55
-rw-r--r--packages/app/src/pages/session/composer/session-todo-dock.tsx21
-rw-r--r--packages/app/src/testing/session-composer.ts84
4 files changed, 163 insertions, 6 deletions
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: {
>
<div ref={(el) => setStore("body", el)}>
<SessionTodoDock
+ sessionID={route.params.id}
todos={props.state.todos()}
title={language.t("session.todo.title")}
collapseLabel={language.t("session.todo.collapse")}
diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts
index 525766dcf..0884f4cc6 100644
--- a/packages/app/src/pages/session/composer/session-composer-state.ts
+++ b/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 type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { useParams } from "@solidjs/router"
@@ -8,6 +8,7 @@ import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
+import { composerDriver, composerEnabled, composerEvent } from "@/testing/session-composer"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
export const todoState = (input: {
@@ -47,7 +48,50 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
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 (
<DockTray
data-component="session-todo-dock"
diff --git a/packages/app/src/testing/session-composer.ts b/packages/app/src/testing/session-composer.ts
new file mode 100644
index 000000000..01c809e4c
--- /dev/null
+++ b/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: [],
+ })
+ },
+ }
+}