diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index 7747ff903..69bcbdf74 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" @@ -15,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" } @@ -98,11 +100,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, }, } }) @@ -115,31 +119,69 @@ 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) { + if (!prev.probe && !nextStateProbe) { delete composer.sessions[input.sessionID] } else { - composer.sessions[input.sessionID] = { probe: prev.probe } + composer.sessions[input.sessionID] = { probe: prev.probe, stateProbe: nextStateProbe } } } else { composer.sessions[input.sessionID] = { ...prev, + stateProbe: nextStateProbe, 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 +195,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 +205,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 +659,247 @@ 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", + 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 page.clock.fastForward(3_000) + await dock.expectState({ dock: false, completing: false, count: 2, states: ["completed", "completed"] }) + 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 page.clock.install() + 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", + 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"] }) + 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 page.clock.install() + 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"] }) + + 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 page.clock.fastForward(3_500) + await dock.expectState({ dock: true, completing: false, count: 2, states: ["completed", "pending"] }) + } 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 page.clock.install() + 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"] }) + + 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"] }) + + 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 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() + } + }, + { trackSession: project.trackSession }, + ) +}) + +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", + 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: [] }) + + await page.clock.fastForward(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 page.clock.install() + 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"] }) + + 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"] }) + + 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() + } + }, + { 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: [], + }) + }, + } +}