diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index c34412f75..43bd59fd3 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -773,6 +773,77 @@ test("todo dock auto-hides after all todos complete", async ({ page, project }) ) }) +test("todo dock appears from real todowrite tool parts", async ({ page, llm, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock real todowrite", + async (session) => { + const dock = await todoDock(page, session.id) + await project.gotoSession(session.id) + + await llm.tool("todowrite", { + todos: [ + { content: "count to 0", status: "completed", priority: "high" }, + { content: "count to 1", status: "in_progress", priority: "medium" }, + { content: "count to 2", status: "pending", priority: "medium" }, + ], + }) + await llm.text("counting started") + + await project.prompt("Create a todo list and start counting.") + + await dock.expectCollapsed(["completed", "in_progress", "pending"]) + }, + { trackSession: project.trackSession }, + ) +}) + +test("todo dock stays hidden when landing on an already completed session", async ({ page, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock todo completed landing source", + async (sessionA) => { + await withDockSession( + project.sdk, + "e2e composer dock todo completed landing target", + async (sessionB) => { + const dockA = await todoDock(page, sessionA.id) + const dockB = await todoDock(page, sessionB.id) + await project.gotoSession(sessionA.id) + + try { + await dockB.finish([ + { content: "first task", status: "completed", priority: "high" }, + { content: "second task", status: "completed", priority: "medium" }, + { content: "third task", status: "completed", priority: "medium" }, + { content: "fourth task", status: "completed", priority: "low" }, + ]) + await project.gotoSession(sessionB.id) + + await dockB.expectState( + { + dock: false, + completing: false, + count: 4, + states: ["completed", "completed", "completed", "completed"], + }, + 1_000, + ) + await dockB.expectDockGone(1_000) + } finally { + await dockA.clear() + await dockB.clear() + } + }, + { trackSession: project.trackSession }, + ) + }, + { 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() @@ -835,6 +906,33 @@ test("todo dock hides immediately when todos become empty", async ({ page, proje ) }) +test("todo dock does not treat completed-only todos as recent after clearing", async ({ page, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock todo empty clears active history", + 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.expectState({ dock: true, completing: false, count: 1, states: ["in_progress"] }) + + await dock.finish([]) + await dock.expectState({ dock: false, completing: false, count: 0, states: [] }, 1_000) + + await dock.finish([{ content: "historical done task", status: "completed", priority: "high" }]) + await dock.expectState({ dock: false, completing: false, count: 1, states: ["completed"] }, 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() @@ -846,7 +944,10 @@ test("todo dock cancels pending hide when a new active todo arrives", async ({ p await project.gotoSession(session.id) try { - await dock.open([{ content: "done task", status: "completed", priority: "high" }]) + await dock.open([{ content: "done task", status: "in_progress", priority: "high" }]) + await dock.expectState({ dock: true, completing: false, count: 1, states: ["in_progress"] }) + + await dock.finish([{ content: "done task", status: "completed", priority: "high" }]) await dock.expectState({ dock: true, completing: true, count: 1, states: ["completed"] }) await dock.finish([ @@ -875,7 +976,10 @@ test("todo dock restarts the hide timer when todos re-complete", async ({ page, await project.gotoSession(session.id) try { - await dock.open([{ content: "first task", status: "completed", priority: "high" }]) + await dock.open([{ content: "first task", status: "in_progress", priority: "high" }]) + await dock.expectState({ dock: true, completing: false, count: 1, states: ["in_progress"] }) + + await dock.finish([{ content: "first task", status: "completed", priority: "high" }]) await dock.expectState({ dock: true, completing: true, count: 1, states: ["completed"] }) await page.clock.fastForward(2_400) @@ -918,7 +1022,10 @@ test("todo dock does not leak a pending hide timeout across sessions", async ({ await project.gotoSession(sessionA.id) try { - await dockA.open([{ content: "done task", status: "completed", priority: "high" }]) + await dockA.open([{ content: "done task", status: "in_progress", priority: "high" }]) + await dockA.expectState({ dock: true, completing: false, count: 1, states: ["in_progress"] }) + + await dockA.finish([{ content: "done task", status: "completed", priority: "high" }]) await dockA.expectState({ dock: true, completing: true, count: 1, states: ["completed"] }) await project.gotoSession(sessionB.id) @@ -926,7 +1033,7 @@ test("todo dock does not leak a pending hide timeout across sessions", async ({ await page.clock.fastForward(3_500) await project.gotoSession(sessionA.id) - await dockA.expectState({ dock: true, completing: true, count: 1, states: ["completed"] }) + await dockA.expectState({ dock: false, completing: false, count: 1, states: ["completed"] }) } finally { await dockA.clear() await dockB.clear() @@ -939,7 +1046,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 }) => { +test("todo dock stays hidden after same-count terminal session switch", async ({ page, project }) => { await project.open() await page.clock.install() await withDockSession( @@ -955,17 +1062,20 @@ test("todo dock restarts completing delay after same-count terminal session swit await project.gotoSession(sessionA.id) try { - await dockA.open([{ content: "source done", status: "completed", priority: "high" }]) + await dockA.open([{ content: "source done", status: "in_progress", priority: "high" }]) + await dockA.expectState({ dock: true, completing: false, count: 1, states: ["in_progress"] }) + + await dockA.finish([{ 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 dockB.expectState({ dock: false, completing: false, count: 1, states: ["completed"] }) await page.clock.fastForward(900) - await dockB.expectState({ dock: true, completing: true, count: 1, states: ["completed"] }) + await dockB.expectState({ dock: false, completing: false, count: 1, states: ["completed"] }) await page.clock.fastForward(2_100) await dockB.expectState({ dock: false, completing: false, count: 1, states: ["completed"] }) } finally { @@ -1017,7 +1127,11 @@ test("e2e composer dock keeps latest turn visible when dock height changes", asy const viewport = document.querySelector('[data-component="scroll-viewport"]') const composer = document.querySelector('[data-component="session-prompt-dock"]') const last = [...document.querySelectorAll("[data-message-id]")].at(-1) - if (!(viewport instanceof HTMLElement) || !(composer instanceof HTMLElement) || !(last instanceof HTMLElement)) { + if ( + !(viewport instanceof HTMLElement) || + !(composer instanceof HTMLElement) || + !(last instanceof HTMLElement) + ) { return null } viewport.scrollTop = viewport.scrollHeight diff --git a/packages/app/src/components/session/session-status-summary.tsx b/packages/app/src/components/session/session-status-summary.tsx index ddabb40a4..80234a76f 100644 --- a/packages/app/src/components/session/session-status-summary.tsx +++ b/packages/app/src/components/session/session-status-summary.tsx @@ -1,7 +1,8 @@ import { For, Show, createMemo, type Accessor, type JSX } from "solid-js" import type { Part } from "@opencode-ai/sdk/v2" import { useLanguage } from "@/context/language" -import { extractTodos, extractSources, type TodoItem } from "@/pages/session/session-status-extractors" +import { extractSources, type TodoItem } from "@/pages/session/session-status-extractors" +import { selectSessionTodos } from "@/pages/session/session-todos" const TODO_STATUS_STYLES: Record = { completed: { dot: "bg-icon-success-base", text: "" }, @@ -43,7 +44,7 @@ function SourceRow(props: { url: string }) { export function SessionStatusSummary(props: { parts: Accessor }) { const language = useLanguage() - const todos = createMemo(() => extractTodos(props.parts())) + const todos = createMemo(() => selectSessionTodos({ parts: props.parts() })) const sources = createMemo(() => extractSources(props.parts())) return ( diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index b0504300e..6f35c58ed 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,13 +1,5 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" -import { - createMemo, - createEffect, - createComputed, - createSignal, - on, - onCleanup, - untrack, -} from "solid-js" +import { createMemo, createEffect, createComputed, createSignal, on, onCleanup, untrack } from "solid-js" import { createMediaQuery } from "@solid-primitives/media" import { useLocal } from "@/context/local" import { useFile } from "@/context/file" @@ -118,7 +110,7 @@ export default function Page() { const timelineSessionID = timeline.sessionID const timelineSessionKey = timeline.sessionKey const timelineIsChildSession = timeline.isChildSession - const composer = createSessionComposerState({ sessionID: timelineSessionID }) + const composer = createSessionComposerState({ sessionID: timelineSessionID, fallbackSessionID: () => params.id }) const timelineMessages = timeline.messages const timelineMessagesReady = timeline.messagesReady const timelineDiffs = timeline.diffs 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 40b377b2d..66691d4f2 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -9,6 +9,7 @@ import { usePermission } from "@/context/permission" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { composerDriver, composerEnabled, composerEvent, composerStateProbe } from "@/testing/session-composer" +import { selectSessionTodos } from "@/pages/session/session-todos" import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" const TODO_DOCK_COMPLETING_DELAY_MS = 3000 @@ -17,7 +18,10 @@ const todoTerminal = (todo: Todo) => todo.status === "completed" || todo.status const todoSignature = (todos: Todo[]) => todos.map((todo) => `${todo.status}:${todo.content}`).join("\u0000") -export function createSessionComposerState(input: { sessionID: () => string | undefined }) { +export function createSessionComposerState(input: { + sessionID: () => string | undefined + fallbackSessionID?: () => string | undefined +}) { const sdk = useSDK() const sync = useSync() const globalSync = useGlobalSync() @@ -85,7 +89,16 @@ export function createSessionComposerState(input: { sessionID: () => string | un const id = activeSessionID() if (!id) return [] // 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 messages = sync.data.message[id] ?? [] + const parts = messages.flatMap((message) => sync.data.part[message.id] ?? []) + const fallbackID = input.fallbackSessionID?.() + const fallbackMessages = fallbackID && fallbackID !== id ? (sync.data.message[fallbackID] ?? []) : [] + const fallbackParts = fallbackMessages.flatMap((message) => sync.data.part[message.id] ?? []) + return selectSessionTodos({ + backend: globalSync.data.session_todo[id], + parts, + fallback: { backend: fallbackID ? globalSync.data.session_todo[fallbackID] : undefined, parts: fallbackParts }, + }) }) const allDone = createMemo(() => { @@ -125,6 +138,8 @@ export function createSessionComposerState(input: { sessionID: () => string | un let raf: number | undefined let hideTimeout: number | undefined + let lastTodoSessionID: string | undefined + const sessionsWithActiveTodos = new Set() const clearHideTimeout = () => { if (hideTimeout === undefined) return @@ -143,14 +158,23 @@ export function createSessionComposerState(input: { sessionID: () => string | un ({ allDone: done, count, sessionID: expectedSessionID, signature }) => { if (raf) cancelAnimationFrame(raf) raf = undefined + const sessionChanged = expectedSessionID !== lastTodoSessionID + lastTodoSessionID = expectedSessionID if (count === 0) { + if (expectedSessionID) sessionsWithActiveTodos.delete(expectedSessionID) clearHideTimeout() setStore({ dock: false, opening: false, completing: false }) return } if (done) { + if (sessionChanged || !expectedSessionID || !sessionsWithActiveTodos.has(expectedSessionID)) { + clearHideTimeout() + setStore({ dock: false, opening: false, completing: false }) + return + } + setStore({ dock: true, opening: false, completing: true }) clearHideTimeout() hideTimeout = window.setTimeout(() => { @@ -162,6 +186,7 @@ export function createSessionComposerState(input: { sessionID: () => string | un return } + if (expectedSessionID) sessionsWithActiveTodos.add(expectedSessionID) clearHideTimeout() setStore("completing", false) diff --git a/packages/app/src/pages/session/session-todos.test.ts b/packages/app/src/pages/session/session-todos.test.ts new file mode 100644 index 000000000..c71491623 --- /dev/null +++ b/packages/app/src/pages/session/session-todos.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test" +import type { Part, ToolState } from "@opencode-ai/sdk/v2" +import type { Todo } from "@opencode-ai/sdk/v2/client" +import { selectSessionTodos } from "./session-todos" + +const completedState = ( + overrides: Partial> = {}, +): Extract => ({ + status: "completed", + input: {}, + output: "", + title: "", + metadata: {}, + time: { start: 0, end: 0 }, + ...overrides, +}) + +const toolPart = (tool: string, state: ToolState = completedState()): Part => + ({ + id: "p", + sessionID: "s", + messageID: "m", + type: "tool", + callID: "c", + tool, + state, + }) as Part + +const todo = (content: string, status: Todo["status"] = "pending"): Todo => ({ + content, + status, + priority: "medium", +}) + +describe("selectSessionTodos", () => { + test("prefers backend todos over message-derived todos", () => { + const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("from parts", "in_progress")] } }))] + + expect(selectSessionTodos({ backend: [todo("from backend", "pending")], parts })).toEqual([ + todo("from backend", "pending"), + ]) + }) + + test("falls back to latest todowrite parts when backend todos are empty", () => { + const parts = [ + toolPart("todowrite", completedState({ input: { todos: [todo("old", "pending")] } })), + toolPart("todowrite", completedState({ input: { todos: [todo("new", "in_progress")] } })), + ] + + expect(selectSessionTodos({ backend: [], parts })).toEqual([todo("new", "in_progress")]) + }) + + test("falls back to a secondary session source when the primary source is empty", () => { + const fallbackParts = [ + toolPart("todowrite", completedState({ input: { todos: [todo("route todo", "in_progress")] } })), + ] + + expect(selectSessionTodos({ backend: [], parts: [], fallback: { parts: fallbackParts } })).toEqual([ + todo("route todo", "in_progress"), + ]) + }) +}) diff --git a/packages/app/src/pages/session/session-todos.ts b/packages/app/src/pages/session/session-todos.ts new file mode 100644 index 000000000..1d16cf0c9 --- /dev/null +++ b/packages/app/src/pages/session/session-todos.ts @@ -0,0 +1,17 @@ +import type { Part, Todo } from "@opencode-ai/sdk/v2" +import { extractTodos } from "@/pages/session/session-status-extractors" + +export type SessionTodoSource = { + backend?: Todo[] + parts: Part[] +} + +export function selectSessionTodos(input: SessionTodoSource & { fallback?: SessionTodoSource }): Todo[] { + if (input.backend && input.backend.length > 0) return input.backend + + const fromParts = extractTodos(input.parts) + if (fromParts.length > 0) return fromParts as Todo[] + + if (input.fallback?.backend && input.fallback.backend.length > 0) return input.fallback.backend + return extractTodos(input.fallback?.parts ?? []) as Todo[] +}