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

This commit is contained in:
Luke Parker
2026-03-13 12:21:50 +10:00
committed by GitHub
parent dcb17c6a67
commit 96b1d8f639
6 changed files with 350 additions and 74 deletions

View File

@@ -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")}

View File

@@ -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, [])

View File

@@ -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"

View File

@@ -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: [],
})
},
}
}