From 2782bf8d8e6cc157b4fe483b6c9f8987b060c634 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 29 Apr 2026 17:08:19 +0800 Subject: [PATCH 1/4] fix: auto-hide completed todo dock --- .../e2e/session/session-composer-dock.spec.ts | 307 +++++++++++++++++- packages/app/src/i18n/en.ts | 1 + packages/app/src/i18n/zh.ts | 1 + .../composer/session-composer-state.ts | 62 +++- .../session/composer/session-todo-dock.tsx | 16 +- packages/app/src/testing/session-composer.ts | 38 +++ 6 files changed, 407 insertions(+), 18 deletions(-) diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index 7747ff903..40eb1b9d4 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -3,6 +3,7 @@ import { composerEvent, type ComposerDriverState, type ComposerProbeState, + type ComposerStateProbeState, type ComposerWindow, } from "../../src/testing/session-composer" import { cleanupSession, clearSessionDockSeed, closeSettingsPanel, openSettings, seedSessionQuestion } from "../actions" @@ -50,6 +51,9 @@ const defaultQuestions = [ test.setTimeout(120_000) +const afterElapsed = (startedAt: number, ms: number) => Date.now() - startedAt >= ms +const elapsedSince = (startedAt: number) => Date.now() - startedAt + async function withDockSeed(sdk: Sdk, sessionID: string, fn: () => Promise) { try { return await fn() @@ -98,11 +102,13 @@ async function expectPermissionOpen(page: any) { async function todoDock(page: any, sessionID: string) { await page.addInitScript(() => { const win = window as ComposerWindow + const saved = window.sessionStorage.getItem("__opencode_e2e_composer_sessions") + const sessions = saved ? JSON.parse(saved) : {} win.__opencode_e2e = { ...win.__opencode_e2e, composer: { enabled: true, - sessions: {}, + sessions, }, } }) @@ -116,10 +122,10 @@ async function todoDock(page: any, sessionID: string) { composer.sessions ??= {} const prev = composer.sessions[input.sessionID] ?? {} if (!input.driver) { - if (!prev.probe) { + if (!prev.probe && !prev.stateProbe) { delete composer.sessions[input.sessionID] } else { - composer.sessions[input.sessionID] = { probe: prev.probe } + composer.sessions[input.sessionID] = { probe: prev.probe, stateProbe: prev.stateProbe } } } else { composer.sessions[input.sessionID] = { @@ -127,19 +133,47 @@ async function todoDock(page: any, sessionID: string) { driver: input.driver, } } + window.sessionStorage.setItem("__opencode_e2e_composer_sessions", JSON.stringify(composer.sessions)) window.dispatchEvent(new CustomEvent(input.event, { detail: { sessionID: input.sessionID } })) }, { event: composerEvent, sessionID, driver }, ) } - const read = () => + const readUi = () => page.evaluate((sessionID: string) => { const win = window as ComposerWindow return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.probe ?? null }, sessionID) as Promise + const readState = () => + page.evaluate((sessionID: string) => { + const win = window as ComposerWindow + return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.stateProbe ?? null + }, sessionID) as Promise + const api = { + async expectUi(expected: Partial, timeout = 10_000) { + await expect.poll(readUi, { timeout }).toMatchObject(expected) + return api + }, + async expectState(expected: Partial, timeout = 10_000) { + await expect.poll(readState, { timeout }).toMatchObject(expected) + return api + }, + async expectUnmounted(timeout = 10_000) { + await expect.poll(readUi, { timeout }).toMatchObject({ + mounted: false, + hidden: true, + count: 0, + states: [], + }) + return api + }, + async expectDockGone(timeout = 10_000) { + await expect(page.locator('[data-component="session-todo-dock"]')).toHaveCount(0, { timeout }) + return api + }, async clear() { await write(undefined) return api @@ -153,7 +187,7 @@ async function todoDock(page: any, sessionID: string) { return api }, async expectOpen(states: ComposerProbeState["states"]) { - await expect.poll(read, { timeout: 10_000 }).toMatchObject({ + await expect.poll(readUi, { timeout: 10_000 }).toMatchObject({ mounted: true, collapsed: false, hidden: false, @@ -163,7 +197,7 @@ async function todoDock(page: any, sessionID: string) { return api }, async expectCollapsed(states: ComposerProbeState["states"]) { - await expect.poll(read, { timeout: 10_000 }).toMatchObject({ + await expect.poll(readUi, { timeout: 10_000 }).toMatchObject({ mounted: true, collapsed: true, hidden: true, @@ -617,6 +651,267 @@ test("todo dock transitions and collapse behavior", async ({ page, project }) => ) }) +test("todo dock auto-hides after all todos complete", async ({ page, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock todo complete auto-hide", + async (session) => { + const dock = await todoDock(page, session.id) + await project.gotoSession(session.id) + + try { + await dock.open([ + { content: "first task", status: "pending", priority: "high" }, + { content: "second task", status: "in_progress", priority: "medium" }, + ]) + await dock.expectCollapsed(["pending", "in_progress"]) + + await dock.finish([ + { content: "first task", status: "completed", priority: "high" }, + { content: "second task", status: "completed", priority: "medium" }, + ]) + await dock.expectState({ dock: true, completing: true, count: 2, states: ["completed", "completed"] }) + await dock.expectState({ dock: false, completing: false, count: 2, states: ["completed", "completed"] }, 5_000) + await dock.expectUnmounted() + await dock.expectDockGone() + } finally { + await dock.clear() + } + }, + { trackSession: project.trackSession }, + ) +}) + +test("todo dock treats cancelled todos as terminal and labels all-cancelled progress", async ({ page, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock todo cancelled auto-hide", + async (session) => { + const dock = await todoDock(page, session.id) + await project.gotoSession(session.id) + + try { + await dock.open([ + { content: "first task", status: "pending", priority: "high" }, + { content: "second task", status: "in_progress", priority: "medium" }, + ]) + await dock.expectCollapsed(["pending", "in_progress"]) + + await dock.finish([ + { content: "first task", status: "cancelled", priority: "high" }, + { content: "second task", status: "cancelled", priority: "medium" }, + ]) + await expect(page.locator('[data-slot="session-todo-progress"]')).toHaveAttribute("aria-label", /cancelled|已取消/i) + await dock.expectState({ dock: true, completing: true, count: 2, states: ["cancelled", "cancelled"] }) + await dock.expectState({ dock: false, completing: false, count: 2, states: ["cancelled", "cancelled"] }, 5_000) + await dock.expectUnmounted() + } finally { + await dock.clear() + } + }, + { trackSession: project.trackSession }, + ) +}) + +test("todo dock hides immediately when todos become empty", async ({ page, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock todo empty hides", + async (session) => { + const dock = await todoDock(page, session.id) + await project.gotoSession(session.id) + + try { + await dock.open([{ content: "active task", status: "in_progress", priority: "high" }]) + await dock.expectCollapsed(["in_progress"]) + + await dock.finish([]) + await dock.expectState({ dock: false, completing: false, count: 0, states: [] }, 1_000) + await dock.expectUnmounted(1_000) + await dock.expectDockGone(1_000) + } finally { + await dock.clear() + } + }, + { trackSession: project.trackSession }, + ) +}) + +test("todo dock cancels pending hide when a new active todo arrives", async ({ page, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock todo hide cancelled", + async (session) => { + const dock = await todoDock(page, session.id) + await project.gotoSession(session.id) + + try { + await dock.open([{ content: "done task", status: "completed", priority: "high" }]) + await dock.expectState({ dock: true, completing: true, count: 1, states: ["completed"] }) + + const startedAt = Date.now() + await dock.finish([ + { content: "done task", status: "completed", priority: "high" }, + { content: "new task", status: "pending", priority: "medium" }, + ]) + await dock.expectState({ dock: true, completing: false, count: 2, states: ["completed", "pending"] }) + await expect + .poll(async () => { + const state = await page.evaluate((sessionID: string) => { + const win = window as ComposerWindow + return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.stateProbe ?? null + }, session.id) + return afterElapsed(startedAt, 3_500) && state?.dock === true && state.completing === false + }, { timeout: 5_000 }) + .toBe(true) + } finally { + await dock.clear() + } + }, + { trackSession: project.trackSession }, + ) +}) + +test("todo dock restarts the hide timer when todos re-complete", async ({ page, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock todo timer reset", + async (session) => { + const dock = await todoDock(page, session.id) + await project.gotoSession(session.id) + + try { + await dock.open([{ content: "first task", status: "completed", priority: "high" }]) + await dock.expectState({ dock: true, completing: true, count: 1, states: ["completed"] }) + + const firstCompletingAt = Date.now() + await expect + .poll(() => elapsedSince(firstCompletingAt), { intervals: [100], timeout: 2_800 }) + .toBeGreaterThanOrEqual(2_400) + await dock.finish([ + { content: "first task", status: "completed", priority: "high" }, + { content: "second task", status: "pending", priority: "medium" }, + ]) + await dock.expectState({ dock: true, completing: false, count: 2, states: ["completed", "pending"] }) + + const recompletedAt = Date.now() + await dock.finish([ + { content: "first task", status: "completed", priority: "high" }, + { content: "second task", status: "completed", priority: "medium" }, + ]) + await dock.expectState({ dock: true, completing: true, count: 2, states: ["completed", "completed"] }) + await expect + .poll(async () => { + const state = await page.evaluate((sessionID: string) => { + const win = window as ComposerWindow + return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.stateProbe ?? null + }, session.id) + return afterElapsed(recompletedAt, 2_500) && state?.dock === true && state.completing === true + }, { timeout: 4_000 }) + .toBe(true) + await dock.expectState({ dock: false, completing: false, count: 2, states: ["completed", "completed"] }, 2_000) + } finally { + await dock.clear() + } + }, + { trackSession: project.trackSession }, + ) +}) + +test("todo dock does not leak a pending hide timeout across sessions", async ({ page, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock todo session switch source", + async (sessionA) => { + await withDockSession( + project.sdk, + "e2e composer dock todo session switch target", + async (sessionB) => { + const dockA = await todoDock(page, sessionA.id) + const dockB = await todoDock(page, sessionB.id) + await project.gotoSession(sessionA.id) + + try { + await dockA.open([{ content: "done task", status: "completed", priority: "high" }]) + await dockA.expectState({ dock: true, completing: true, count: 1, states: ["completed"] }) + + await project.gotoSession(sessionB.id) + await dockB.expectState({ dock: false, completing: false, count: 0, states: [] }) + + const switchedAt = Date.now() + await expect + .poll(() => elapsedSince(switchedAt), { intervals: [100], timeout: 5_000 }) + .toBeGreaterThanOrEqual(3_500) + await project.gotoSession(sessionA.id) + await dockA.expectState({ dock: true, completing: true, count: 1, states: ["completed"] }) + } finally { + await dockA.clear() + await dockB.clear() + } + }, + { trackSession: project.trackSession }, + ) + }, + { trackSession: project.trackSession }, + ) +}) + +test("todo dock restarts completing delay after same-count terminal session switch", async ({ page, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock terminal switch source", + async (sessionA) => { + await withDockSession( + project.sdk, + "e2e composer dock terminal switch target", + async (sessionB) => { + const dockA = await todoDock(page, sessionA.id) + const dockB = await todoDock(page, sessionB.id) + await project.gotoSession(sessionA.id) + + try { + await dockA.open([{ content: "source done", status: "completed", priority: "high" }]) + await dockA.expectState({ dock: true, completing: true, count: 1, states: ["completed"] }) + + const sourceCompletedAt = Date.now() + await expect + .poll(() => elapsedSince(sourceCompletedAt), { intervals: [100], timeout: 2_800 }) + .toBeGreaterThanOrEqual(2_400) + + await dockB.open([{ content: "target done", status: "completed", priority: "high" }]) + await project.gotoSession(sessionB.id) + await dockB.expectState({ dock: true, completing: true, count: 1, states: ["completed"] }) + + const targetCompletedAt = Date.now() + await expect + .poll(async () => { + const state = await page.evaluate((sessionID: string) => { + const win = window as ComposerWindow + return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.stateProbe ?? null + }, sessionB.id) + return afterElapsed(targetCompletedAt, 900) && state?.dock === true && state.completing === true + }, { timeout: 2_000 }) + .toBe(true) + await dockB.expectState({ dock: false, completing: false, count: 1, states: ["completed"] }, 4_000) + } finally { + await dockA.clear() + await dockB.clear() + } + }, + { trackSession: project.trackSession }, + ) + }, + { trackSession: project.trackSession }, + ) +}) + test("keyboard focus stays off prompt while blocked", async ({ page, llm, project }) => { const questions = [ { diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 0cf99a54f..b24897f9c 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -613,6 +613,7 @@ export const dict = { "session.todo.collapse": "Collapse", "session.todo.expand": "Expand", "session.todo.progress": "{{done}} of {{total}} todos completed", + "session.todo.cancelled": "Cancelled", "session.question.progress": "{{current}} of {{total}} questions", "session.followupDock.summary.one": "{{count}} queued message", "session.followupDock.summary.other": "{{count}} queued messages", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index eff9aee09..9edcf834e 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -995,6 +995,7 @@ export const dict = { "session.review.noVcs.createGit.actionLoading": "正在创建 Git 仓库...", "session.review.noVcs.createGit.action": "创建 Git 仓库", "session.todo.progress": "已完成 {{done}} 个任务(共 {{total}} 个)", + "session.todo.cancelled": "已取消", "session.question.progress": "{{current}}/{{total}} 个问题", "session.header.open.finder": "访达", "session.header.open.fileExplorer": "文件资源管理器", 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 335b60afe..2947b614f 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -9,9 +9,15 @@ 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 { composerDriver, composerEnabled, composerEvent, composerStateProbe } from "@/testing/session-composer" import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" +const TODO_DOCK_COMPLETING_DELAY_MS = 3000 + +const todoTerminal = (todo: Todo) => todo.status === "completed" || todo.status === "cancelled" + +const todoSignature = (todos: Todo[]) => todos.map((todo) => `${todo.status}:${todo.content}`).join("\u0000") + export function createSessionComposerState() { const params = useParams() const sdk = useSDK() @@ -79,14 +85,20 @@ export function createSessionComposerState() { if (test.on && test.todos !== undefined) return test.todos const id = params.id if (!id) return [] - // Todo visibility follows the backend todo list; keep the dock until the list is explicitly empty. + // Todo data follows the backend list. Dock visibility is derived below so terminal todos can remain stored after the dock hides. return globalSync.data.session_todo[id] ?? [] }) + const allDone = createMemo(() => { + const list = todos() + return list.length > 0 && list.every(todoTerminal) + }) + const [store, setStore] = createStore({ responding: undefined as string | undefined, dock: todos().length > 0, opening: false, + completing: allDone(), }) const permissionResponding = createMemo(() => { @@ -113,19 +125,42 @@ export function createSessionComposerState() { } let raf: number | undefined + let hideTimeout: number | undefined + + const clearHideTimeout = () => { + if (hideTimeout === undefined) return + window.clearTimeout(hideTimeout) + hideTimeout = undefined + } createEffect( on( - () => todos().length, - (count) => { + () => ({ allDone: allDone(), count: todos().length, sessionID: params.id, signature: todoSignature(todos()) }), + ({ allDone: done, count, sessionID, signature }) => { if (raf) cancelAnimationFrame(raf) raf = undefined if (count === 0) { - setStore({ dock: false, opening: false }) + clearHideTimeout() + setStore({ dock: false, opening: false, completing: false }) return } + if (done) { + setStore({ dock: true, opening: false, completing: true }) + clearHideTimeout() + hideTimeout = window.setTimeout(() => { + if (params.id === sessionID && allDone() && todoSignature(todos()) === signature) { + setStore({ dock: false, opening: false, completing: false }) + } + hideTimeout = undefined + }, TODO_DOCK_COMPLETING_DELAY_MS) + return + } + + clearHideTimeout() + setStore("completing", false) + const hidden = !store.dock setStore("dock", true) if (hidden) { @@ -141,9 +176,22 @@ export function createSessionComposerState() { ), ) + createEffect(() => { + if (!composerEnabled()) return + const probe = composerStateProbe(params.id) + probe.set({ + dock: store.dock, + opening: store.opening, + completing: store.completing, + count: todos().length, + states: todos().map((todo) => todo.status), + }) + onCleanup(() => probe.drop()) + }) + onCleanup(() => { - if (!raf) return - cancelAnimationFrame(raf) + if (raf) cancelAnimationFrame(raf) + clearHideTimeout() }) return { 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 36caea76f..e6fd0764a 100644 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ b/packages/app/src/pages/session/composer/session-todo-dock.tsx @@ -57,12 +57,17 @@ export function SessionTodoDock(props: { const total = createMemo(() => props.todos.length) const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length) - const label = createMemo(() => language.t("session.todo.progress", { done: done(), total: total() })) - const progress = createMemo(() => - language + const allCancelled = createMemo(() => total() > 0 && props.todos.every((todo) => todo.status === "cancelled")) + const label = createMemo(() => { + if (allCancelled()) return language.t("session.todo.cancelled") + return language.t("session.todo.progress", { done: done(), total: total() }) + }) + const progress = createMemo(() => { + if (allCancelled()) return [language.t("session.todo.cancelled")] + return language .t("session.todo.progress", { done: doneToken, total: totalToken }) - .split(/(\u0000done\u0000|\u0000total\u0000)/), - ) + .split(/(\u0000done\u0000|\u0000total\u0000)/) + }) const active = createMemo( () => @@ -135,6 +140,7 @@ export function SessionTodoDock(props: { }} > { }, } } + +export const composerStateProbe = (sessionID?: string) => { + const set = (next: ComposerStateProbeState) => { + if (!sessionID) return + const sessions = root() + if (!sessions) return + const prev = sessions[sessionID] ?? {} + sessions[sessionID] = { + ...prev, + stateProbe: { + ...next, + states: [...next.states], + }, + } + } + + return { + set, + drop() { + set({ + dock: false, + opening: false, + completing: false, + count: 0, + states: [], + }) + }, + } +} From 581aaf08f64e47c3c5b223ce478e40e7ac80080e Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 29 Apr 2026 17:19:56 +0800 Subject: [PATCH 2/4] test: use clock for todo dock timing --- .../e2e/session/session-composer-dock.spec.ts | 72 ++++++------------- 1 file changed, 23 insertions(+), 49 deletions(-) diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index 40eb1b9d4..ccf97402c 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -51,9 +51,6 @@ const defaultQuestions = [ test.setTimeout(120_000) -const afterElapsed = (startedAt: number, ms: number) => Date.now() - startedAt >= ms -const elapsedSince = (startedAt: number) => Date.now() - startedAt - async function withDockSeed(sdk: Sdk, sessionID: string, fn: () => Promise) { try { return await fn() @@ -653,6 +650,7 @@ test("todo dock transitions and collapse behavior", async ({ page, project }) => test("todo dock auto-hides after all todos complete", async ({ page, project }) => { await project.open() + await page.clock.install() await withDockSession( project.sdk, "e2e composer dock todo complete auto-hide", @@ -672,7 +670,8 @@ test("todo dock auto-hides after all todos complete", async ({ page, project }) { content: "second task", status: "completed", priority: "medium" }, ]) await dock.expectState({ dock: true, completing: true, count: 2, states: ["completed", "completed"] }) - await dock.expectState({ dock: false, completing: false, count: 2, states: ["completed", "completed"] }, 5_000) + await page.clock.fastForward(3_000) + await dock.expectState({ dock: false, completing: false, count: 2, states: ["completed", "completed"] }) await dock.expectUnmounted() await dock.expectDockGone() } finally { @@ -685,6 +684,7 @@ test("todo dock auto-hides after all todos complete", async ({ page, project }) test("todo dock treats cancelled todos as terminal and labels all-cancelled progress", async ({ page, project }) => { await project.open() + await page.clock.install() await withDockSession( project.sdk, "e2e composer dock todo cancelled auto-hide", @@ -705,7 +705,8 @@ test("todo dock treats cancelled todos as terminal and labels all-cancelled prog ]) await expect(page.locator('[data-slot="session-todo-progress"]')).toHaveAttribute("aria-label", /cancelled|已取消/i) await dock.expectState({ dock: true, completing: true, count: 2, states: ["cancelled", "cancelled"] }) - await dock.expectState({ dock: false, completing: false, count: 2, states: ["cancelled", "cancelled"] }, 5_000) + await page.clock.fastForward(3_000) + await dock.expectState({ dock: false, completing: false, count: 2, states: ["cancelled", "cancelled"] }) await dock.expectUnmounted() } finally { await dock.clear() @@ -742,6 +743,7 @@ test("todo dock hides immediately when todos become empty", async ({ page, proje test("todo dock cancels pending hide when a new active todo arrives", async ({ page, project }) => { await project.open() + await page.clock.install() await withDockSession( project.sdk, "e2e composer dock todo hide cancelled", @@ -753,21 +755,13 @@ test("todo dock cancels pending hide when a new active todo arrives", async ({ p await dock.open([{ content: "done task", status: "completed", priority: "high" }]) await dock.expectState({ dock: true, completing: true, count: 1, states: ["completed"] }) - const startedAt = Date.now() await dock.finish([ { content: "done task", status: "completed", priority: "high" }, { content: "new task", status: "pending", priority: "medium" }, ]) await dock.expectState({ dock: true, completing: false, count: 2, states: ["completed", "pending"] }) - await expect - .poll(async () => { - const state = await page.evaluate((sessionID: string) => { - const win = window as ComposerWindow - return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.stateProbe ?? null - }, session.id) - return afterElapsed(startedAt, 3_500) && state?.dock === true && state.completing === false - }, { timeout: 5_000 }) - .toBe(true) + await page.clock.fastForward(3_500) + await dock.expectState({ dock: true, completing: false, count: 2, states: ["completed", "pending"] }) } finally { await dock.clear() } @@ -778,6 +772,7 @@ test("todo dock cancels pending hide when a new active todo arrives", async ({ p test("todo dock restarts the hide timer when todos re-complete", async ({ page, project }) => { await project.open() + await page.clock.install() await withDockSession( project.sdk, "e2e composer dock todo timer reset", @@ -789,32 +784,22 @@ test("todo dock restarts the hide timer when todos re-complete", async ({ page, await dock.open([{ content: "first task", status: "completed", priority: "high" }]) await dock.expectState({ dock: true, completing: true, count: 1, states: ["completed"] }) - const firstCompletingAt = Date.now() - await expect - .poll(() => elapsedSince(firstCompletingAt), { intervals: [100], timeout: 2_800 }) - .toBeGreaterThanOrEqual(2_400) + await page.clock.fastForward(2_400) await dock.finish([ { content: "first task", status: "completed", priority: "high" }, { content: "second task", status: "pending", priority: "medium" }, ]) await dock.expectState({ dock: true, completing: false, count: 2, states: ["completed", "pending"] }) - const recompletedAt = Date.now() await dock.finish([ { content: "first task", status: "completed", priority: "high" }, { content: "second task", status: "completed", priority: "medium" }, ]) await dock.expectState({ dock: true, completing: true, count: 2, states: ["completed", "completed"] }) - await expect - .poll(async () => { - const state = await page.evaluate((sessionID: string) => { - const win = window as ComposerWindow - return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.stateProbe ?? null - }, session.id) - return afterElapsed(recompletedAt, 2_500) && state?.dock === true && state.completing === true - }, { timeout: 4_000 }) - .toBe(true) - await dock.expectState({ dock: false, completing: false, count: 2, states: ["completed", "completed"] }, 2_000) + await page.clock.fastForward(2_500) + await dock.expectState({ dock: true, completing: true, count: 2, states: ["completed", "completed"] }) + await page.clock.fastForward(500) + await dock.expectState({ dock: false, completing: false, count: 2, states: ["completed", "completed"] }) } finally { await dock.clear() } @@ -825,6 +810,7 @@ test("todo dock restarts the hide timer when todos re-complete", async ({ page, test("todo dock does not leak a pending hide timeout across sessions", async ({ page, project }) => { await project.open() + await page.clock.install() await withDockSession( project.sdk, "e2e composer dock todo session switch source", @@ -844,10 +830,7 @@ test("todo dock does not leak a pending hide timeout across sessions", async ({ await project.gotoSession(sessionB.id) await dockB.expectState({ dock: false, completing: false, count: 0, states: [] }) - const switchedAt = Date.now() - await expect - .poll(() => elapsedSince(switchedAt), { intervals: [100], timeout: 5_000 }) - .toBeGreaterThanOrEqual(3_500) + await page.clock.fastForward(3_500) await project.gotoSession(sessionA.id) await dockA.expectState({ dock: true, completing: true, count: 1, states: ["completed"] }) } finally { @@ -864,6 +847,7 @@ test("todo dock does not leak a pending hide timeout across sessions", async ({ test("todo dock restarts completing delay after same-count terminal session switch", async ({ page, project }) => { await project.open() + await page.clock.install() await withDockSession( project.sdk, "e2e composer dock terminal switch source", @@ -880,26 +864,16 @@ test("todo dock restarts completing delay after same-count terminal session swit await dockA.open([{ content: "source done", status: "completed", priority: "high" }]) await dockA.expectState({ dock: true, completing: true, count: 1, states: ["completed"] }) - const sourceCompletedAt = Date.now() - await expect - .poll(() => elapsedSince(sourceCompletedAt), { intervals: [100], timeout: 2_800 }) - .toBeGreaterThanOrEqual(2_400) + await page.clock.fastForward(2_400) await dockB.open([{ content: "target done", status: "completed", priority: "high" }]) await project.gotoSession(sessionB.id) await dockB.expectState({ dock: true, completing: true, count: 1, states: ["completed"] }) - const targetCompletedAt = Date.now() - await expect - .poll(async () => { - const state = await page.evaluate((sessionID: string) => { - const win = window as ComposerWindow - return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.stateProbe ?? null - }, sessionB.id) - return afterElapsed(targetCompletedAt, 900) && state?.dock === true && state.completing === true - }, { timeout: 2_000 }) - .toBe(true) - await dockB.expectState({ dock: false, completing: false, count: 1, states: ["completed"] }, 4_000) + await page.clock.fastForward(900) + await dockB.expectState({ dock: true, completing: true, count: 1, states: ["completed"] }) + await page.clock.fastForward(2_100) + await dockB.expectState({ dock: false, completing: false, count: 1, states: ["completed"] }) } finally { await dockA.clear() await dockB.clear() From c8f7fd9c0b37613991b47a4df147c0a393b8d52a Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 29 Apr 2026 17:31:19 +0800 Subject: [PATCH 3/4] test: derive todo cancelled label --- packages/app/e2e/session/session-composer-dock.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index ccf97402c..c3db2dc76 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -16,6 +16,7 @@ import { } from "../selectors" import { modKey } from "../utils" import { inputMatch } from "../prompt/mock" +import { dict as enDict } from "../../src/i18n/en" type Sdk = Parameters[0] type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" } @@ -703,7 +704,10 @@ test("todo dock treats cancelled todos as terminal and labels all-cancelled prog { content: "first task", status: "cancelled", priority: "high" }, { content: "second task", status: "cancelled", priority: "medium" }, ]) - await expect(page.locator('[data-slot="session-todo-progress"]')).toHaveAttribute("aria-label", /cancelled|已取消/i) + await expect(page.locator('[data-slot="session-todo-progress"]')).toHaveAttribute( + "aria-label", + enDict["session.todo.cancelled"], + ) await dock.expectState({ dock: true, completing: true, count: 2, states: ["cancelled", "cancelled"] }) await page.clock.fastForward(3_000) await dock.expectState({ dock: false, completing: false, count: 2, states: ["cancelled", "cancelled"] }) From 449887ab99f7ad180c2c8b314302d3d957e9847f Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 29 Apr 2026 18:32:46 +0800 Subject: [PATCH 4/4] test: discard empty todo state probe --- .../app/e2e/session/session-composer-dock.spec.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index c3db2dc76..69bcbdf74 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -119,15 +119,25 @@ async function todoDock(page: any, sessionID: string) { if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled") composer.sessions ??= {} const prev = composer.sessions[input.sessionID] ?? {} + const stateProbe = prev.stateProbe + const stateProbeHasValue = + stateProbe && + (stateProbe.dock || + stateProbe.opening || + stateProbe.completing || + stateProbe.count > 0 || + stateProbe.states.length > 0) + const nextStateProbe = stateProbeHasValue ? stateProbe : undefined if (!input.driver) { - if (!prev.probe && !prev.stateProbe) { + if (!prev.probe && !nextStateProbe) { delete composer.sessions[input.sessionID] } else { - composer.sessions[input.sessionID] = { probe: prev.probe, stateProbe: prev.stateProbe } + composer.sessions[input.sessionID] = { probe: prev.probe, stateProbe: nextStateProbe } } } else { composer.sessions[input.sessionID] = { ...prev, + stateProbe: nextStateProbe, driver: input.driver, } }