diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index 43bd59fd3..64a7735b6 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -773,6 +773,55 @@ test("todo dock auto-hides after all todos complete", async ({ page, project }) ) }) +test("todo dock keeps the original hide timer during terminal-only refreshes", async ({ page, project }) => { + await project.open() + await page.clock.install() + await withDockSession( + project.sdk, + "e2e composer dock todo unchanged terminal refresh", + 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" }, + { content: "third task", status: "pending", priority: "medium" }, + { content: "fourth task", status: "pending", priority: "low" }, + ]) + await dock.expectCollapsed(["pending", "in_progress", "pending", "pending"]) + + const completed = [ + { 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" }, + ] as const + + await dock.finish([ + { ...completed[0], content: "first task done" }, + { ...completed[1], content: "second task done" }, + { ...completed[2], content: "third task done" }, + { ...completed[3], content: "fourth task done" }, + ]) + await dock.expectState({ dock: true, completing: true, count: 4 }) + await page.clock.fastForward(2_500) + + await dock.finish([...completed]) + await dock.expectState({ dock: true, completing: true, count: 4 }) + await page.clock.fastForward(500) + + await dock.expectState({ dock: false, completing: false, count: 4 }) + await dock.expectDockGone() + } finally { + await dock.clear() + } + }, + { trackSession: project.trackSession }, + ) +}) + test("todo dock appears from real todowrite tool parts", async ({ page, llm, project }) => { await project.open() await withDockSession( diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index e3f13fd93..93c38bd87 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -13,7 +13,7 @@ import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" import { messageAgentColor } from "@/utils/agent" import { sessionTitle } from "@/utils/session-title" -import { sessionPermissionRequest } from "../session/composer/session-request-tree" +import { sessionPermissionRequest } from "../session/blockers/request-tree" import { createSessionRunning } from "../session/session-running-state" import { childSessionOnPath, hasProjectPermissions } from "./helpers" diff --git a/packages/app/src/pages/session/blockers/question-fallback.test.ts b/packages/app/src/pages/session/blockers/question-fallback.test.ts new file mode 100644 index 000000000..7a11b4ed6 --- /dev/null +++ b/packages/app/src/pages/session/blockers/question-fallback.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test" +import type { Message, Part, ToolState } from "@opencode-ai/sdk/v2" +import { findRunningQuestionFallbackSession } from "./question-fallback" + +const message = (id: string): Message => ({ id }) as Message + +const toolState = (status: ToolState["status"]): ToolState => + ({ + status, + input: {}, + title: "", + metadata: {}, + time: { start: 0 }, + }) as ToolState + +const toolPart = (tool: string, status: ToolState["status"] = "running"): Part => + ({ + id: `part-${tool}-${status}`, + type: "tool", + tool, + state: toolState(status), + }) as Part + +describe("findRunningQuestionFallbackSession", () => { + test("returns undefined without a session", () => { + expect(findRunningQuestionFallbackSession({ hasQuestionRequest: false, partsByMessageID: {} })).toBeUndefined() + }) + + test("returns undefined when a question request already exists", () => { + expect( + findRunningQuestionFallbackSession({ + sessionID: "s", + hasQuestionRequest: true, + messages: [message("m")], + partsByMessageID: { m: [toolPart("question")] }, + }), + ).toBeUndefined() + }) + + test("returns the session when a recent running question tool part exists", () => { + expect( + findRunningQuestionFallbackSession({ + sessionID: "s", + hasQuestionRequest: false, + messages: [message("m")], + partsByMessageID: { m: [toolPart("question")] }, + }), + ).toBe("s") + }) + + test("ignores non-running question parts and other tools", () => { + expect( + findRunningQuestionFallbackSession({ + sessionID: "s", + hasQuestionRequest: false, + messages: [message("m1"), message("m2")], + partsByMessageID: { m1: [toolPart("question", "completed")], m2: [toolPart("todowrite", "running")] }, + }), + ).toBeUndefined() + }) + + test("ignores running question parts older than the lookback window", () => { + expect( + findRunningQuestionFallbackSession({ + sessionID: "s", + hasQuestionRequest: false, + lookback: 2, + messages: [message("old"), message("recent-1"), message("recent-2")], + partsByMessageID: { old: [toolPart("question")] }, + }), + ).toBeUndefined() + }) +}) diff --git a/packages/app/src/pages/session/blockers/question-fallback.ts b/packages/app/src/pages/session/blockers/question-fallback.ts new file mode 100644 index 000000000..3c33740d6 --- /dev/null +++ b/packages/app/src/pages/session/blockers/question-fallback.ts @@ -0,0 +1,27 @@ +import type { Message, Part } from "@opencode-ai/sdk/v2" + +export const QUESTION_FALLBACK_LOOKBACK_MESSAGES = 5 + +export function findRunningQuestionFallbackSession(input: { + sessionID?: string + hasQuestionRequest: boolean + messages?: Message[] + partsByMessageID: Record + lookback?: number +}): string | undefined { + if (!input.sessionID) return undefined + if (input.hasQuestionRequest) return undefined + const messages = input.messages + if (!messages?.length) return undefined + + const lookback = input.lookback ?? QUESTION_FALLBACK_LOOKBACK_MESSAGES + for (let i = messages.length - 1; i >= Math.max(0, messages.length - lookback); i--) { + const parts = input.partsByMessageID[messages[i].id] + if (!parts) continue + for (const part of parts) { + if (part.type === "tool" && part.tool === "question" && part.state.status === "running") return input.sessionID + } + } + + return undefined +} diff --git a/packages/app/src/pages/session/blockers/question-reconcile.test.ts b/packages/app/src/pages/session/blockers/question-reconcile.test.ts new file mode 100644 index 000000000..29d8e17bf --- /dev/null +++ b/packages/app/src/pages/session/blockers/question-reconcile.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test" +import type { QuestionRequest } from "@opencode-ai/sdk/v2/client" +import { refetchPendingQuestionsForSession } from "./question-reconcile" + +const question = (id: string, sessionID: string) => + ({ + id, + sessionID, + questions: [], + }) as QuestionRequest + +describe("refetchPendingQuestionsForSession", () => { + test("retries until the target session question appears", async () => { + const pending = question("q-late", "root") + let attempts = 0 + const applied: Record = {} + + const result = await refetchPendingQuestionsForSession({ + sessionID: "root", + maxAttempts: 2, + delayMs: 1, + sleep: async () => {}, + shouldContinue: () => true, + list: async () => { + attempts += 1 + return attempts === 1 ? [] : [pending] + }, + apply(sessionID, questions) { + applied[sessionID] = questions + }, + }) + + expect(result).toBe(true) + expect(attempts).toBe(2) + expect(applied.root?.map((item) => item.id)).toEqual(["q-late"]) + }) + + test("does not stop when another session has a pending question first", async () => { + let attempts = 0 + const applied: Record = {} + + const result = await refetchPendingQuestionsForSession({ + sessionID: "target", + maxAttempts: 2, + delayMs: 1, + sleep: async () => {}, + shouldContinue: () => true, + list: async () => { + attempts += 1 + return attempts === 1 ? [question("q-other", "other")] : [question("q-target", "target")] + }, + apply(sessionID, questions) { + applied[sessionID] = questions + }, + }) + + expect(result).toBe(true) + expect(applied.other).toBeUndefined() + expect(applied.target?.map((item) => item.id)).toEqual(["q-target"]) + }) + + test("does not apply stale results when continuation becomes false after list", async () => { + let shouldContinue = true + const applied: QuestionRequest[] = [] + + const result = await refetchPendingQuestionsForSession({ + sessionID: "root", + maxAttempts: 1, + shouldContinue: () => shouldContinue, + list: async () => { + shouldContinue = false + return [question("q-root", "root")] + }, + apply(_sessionID, questions) { + applied.push(...questions) + }, + }) + + expect(result).toBe(false) + expect(applied).toEqual([]) + }) + + test("filters invalid questions and sorts by id before apply", async () => { + const applied: Record = {} + + const result = await refetchPendingQuestionsForSession({ + sessionID: "root", + maxAttempts: 1, + shouldContinue: () => true, + list: async () => [question("q-b", "root"), { id: "broken" } as QuestionRequest, question("q-a", "root")], + apply(sessionID, questions) { + applied[sessionID] = questions + }, + }) + + expect(result).toBe(true) + expect(applied.root?.map((item) => item.id)).toEqual(["q-a", "q-b"]) + }) + + test("returns false when max attempts are reached", async () => { + expect( + await refetchPendingQuestionsForSession({ + sessionID: "root", + maxAttempts: 2, + delayMs: 1, + sleep: async () => {}, + shouldContinue: () => true, + list: async () => [], + apply() {}, + }), + ).toBe(false) + }) +}) diff --git a/packages/app/src/pages/session/blockers/question-reconcile.ts b/packages/app/src/pages/session/blockers/question-reconcile.ts new file mode 100644 index 000000000..a334c797b --- /dev/null +++ b/packages/app/src/pages/session/blockers/question-reconcile.ts @@ -0,0 +1,43 @@ +import type { QuestionRequest } from "@opencode-ai/sdk/v2/client" + +export const QUESTION_REFETCH_ATTEMPTS = 4 +export const QUESTION_REFETCH_DELAY_MS = 250 + +// Session-scoped and cancellation-safe by design: keep all three +// shouldContinue() checks so stale async question.list() results cannot write +// into the store after the active fallback session changes. +export async function refetchPendingQuestionsForSession(input: { + sessionID: string + maxAttempts?: number + delayMs?: number + sleep?: (ms: number) => Promise + shouldContinue: () => boolean + list: () => Promise> + apply: (sessionID: string, questions: QuestionRequest[]) => void +}): Promise { + const maxAttempts = input.maxAttempts ?? QUESTION_REFETCH_ATTEMPTS + const delayMs = input.delayMs ?? QUESTION_REFETCH_DELAY_MS + const sleep = input.sleep ?? ((ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms))) + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (!input.shouldContinue()) return false + + const questions = await input.list() + + if (!input.shouldContinue()) return false + + const target = questions + .filter((question): question is QuestionRequest => !!question?.id && question.sessionID === input.sessionID) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + + if (target.length > 0) { + if (!input.shouldContinue()) return false + input.apply(input.sessionID, target) + return true + } + + if (attempt < maxAttempts - 1) await sleep(delayMs) + } + + return false +} diff --git a/packages/app/src/pages/session/blockers/question-refetch-runner.test.ts b/packages/app/src/pages/session/blockers/question-refetch-runner.test.ts new file mode 100644 index 000000000..98645bb6a --- /dev/null +++ b/packages/app/src/pages/session/blockers/question-refetch-runner.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test" +import { createQuestionRefetchRunner } from "./question-refetch-runner" + +describe("createQuestionRefetchRunner", () => { + test("starts the current fallback session after another session finishes", async () => { + let fallbackSessionID: string | undefined = "a" + const started: string[] = [] + let resolveA: (() => void) | undefined + + const runner = createQuestionRefetchRunner({ + getFallbackSessionID: () => fallbackSessionID, + queue: (callback) => callback(), + refetch: async (sessionID) => { + started.push(sessionID) + if (sessionID === "a") { + await new Promise((resolve) => { + resolveA = resolve + }) + } + return false + }, + }) + + runner.start("a") + fallbackSessionID = "b" + runner.start("b") + expect(started).toEqual(["a", "b"]) + + resolveA?.() + await Promise.resolve() + + expect(started).toEqual(["a", "b"]) + }) + + test("retries the current fallback when it was skipped by a global inflight gate", async () => { + let fallbackSessionID: string | undefined = "a" + const started: string[] = [] + let resolveA: (() => void) | undefined + + const runner = createQuestionRefetchRunner({ + getFallbackSessionID: () => fallbackSessionID, + queue: (callback) => callback(), + refetch: async (sessionID) => { + started.push(sessionID) + if (sessionID === "a") { + await new Promise((resolve) => { + resolveA = resolve + }) + } + return false + }, + }) + + runner.start("a") + fallbackSessionID = "b" + + resolveA?.() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(started).toEqual(["a", "b"]) + }) + + test("does not queue another refetch after disposal", async () => { + let fallbackSessionID: string | undefined = "a" + const started: string[] = [] + let resolveA: (() => void) | undefined + + const runner = createQuestionRefetchRunner({ + getFallbackSessionID: () => fallbackSessionID, + queue: (callback) => callback(), + refetch: async (sessionID) => { + started.push(sessionID) + await new Promise((resolve) => { + resolveA = resolve + }) + return false + }, + }) + + runner.start("a") + fallbackSessionID = "b" + runner.dispose() + resolveA?.() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(started).toEqual(["a"]) + }) +}) diff --git a/packages/app/src/pages/session/blockers/question-refetch-runner.ts b/packages/app/src/pages/session/blockers/question-refetch-runner.ts new file mode 100644 index 000000000..8336e1846 --- /dev/null +++ b/packages/app/src/pages/session/blockers/question-refetch-runner.ts @@ -0,0 +1,33 @@ +export function createQuestionRefetchRunner(input: { + getFallbackSessionID: () => string | undefined + refetch: (sessionID: string) => Promise + queue?: (callback: () => void) => void +}) { + const inflight = new Set() + const queue = input.queue ?? queueMicrotask + let disposed = false + + const start = (sessionID: string | undefined) => { + if (disposed) return + if (!sessionID || inflight.has(sessionID)) return + + inflight.add(sessionID) + input + .refetch(sessionID) + .catch(() => {}) + .finally(() => { + inflight.delete(sessionID) + if (disposed) return + const next = input.getFallbackSessionID() + if (next && next !== sessionID && !inflight.has(next)) queue(() => start(next)) + }) + } + + return { + start, + dispose: () => { + disposed = true + inflight.clear() + }, + } +} diff --git a/packages/app/src/pages/session/composer/session-composer-state.test.ts b/packages/app/src/pages/session/blockers/request-tree.test.ts similarity index 99% rename from packages/app/src/pages/session/composer/session-composer-state.test.ts rename to packages/app/src/pages/session/blockers/request-tree.test.ts index 934d3152a..d3b46f7b0 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.test.ts +++ b/packages/app/src/pages/session/blockers/request-tree.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" -import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" +import { sessionPermissionRequest, sessionQuestionRequest } from "./request-tree" const session = (input: { id: string; parentID?: string }) => ({ diff --git a/packages/app/src/pages/session/composer/session-request-tree.ts b/packages/app/src/pages/session/blockers/request-tree.ts similarity index 100% rename from packages/app/src/pages/session/composer/session-request-tree.ts rename to packages/app/src/pages/session/blockers/request-tree.ts diff --git a/packages/app/src/pages/session/blockers/use-session-blockers.ts b/packages/app/src/pages/session/blockers/use-session-blockers.ts new file mode 100644 index 000000000..9efbd33bd --- /dev/null +++ b/packages/app/src/pages/session/blockers/use-session-blockers.ts @@ -0,0 +1,107 @@ +import { batch, createEffect, createMemo, on, onCleanup } from "solid-js" +import { createStore, reconcile } from "solid-js/store" +import { showToast } from "@opencode-ai/ui/toast" +import { useLanguage } from "@/context/language" +import { usePermission } from "@/context/permission" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { findRunningQuestionFallbackSession } from "./question-fallback" +import { createQuestionRefetchRunner } from "./question-refetch-runner" +import { refetchPendingQuestionsForSession } from "./question-reconcile" +import { sessionPermissionRequest, sessionQuestionRequest } from "./request-tree" + +export function createSessionBlockers(input: { sessionID: () => string | undefined }) { + const sdk = useSDK() + const sync = useSync() + const language = useLanguage() + const permission = usePermission() + const activeSessionID = input.sessionID + + const [store, setStore] = createStore({ + responding: undefined as string | undefined, + }) + + const questionRequest = createMemo(() => { + return sessionQuestionRequest(sync.data.session, sync.data.question, activeSessionID()) + }) + + const questionFallbackSessionID = createMemo(() => { + const sessionID = activeSessionID() + return findRunningQuestionFallbackSession({ + sessionID, + hasQuestionRequest: !!questionRequest(), + messages: sessionID ? sync.data.message[sessionID] : undefined, + partsByMessageID: sync.data.part, + }) + }) + + let alive = true + const questionRefetch = createQuestionRefetchRunner({ + getFallbackSessionID: questionFallbackSessionID, + refetch: (sessionID) => + refetchPendingQuestionsForSession({ + sessionID, + shouldContinue: () => alive && questionFallbackSessionID() === sessionID, + list: () => sdk.client.question.list().then((result) => result.data ?? []), + apply(sid, questions) { + batch(() => { + sync.set("question", sid, reconcile(questions, { key: "id" })) + }) + }, + }), + }) + onCleanup(() => { + alive = false + questionRefetch.dispose() + }) + + createEffect( + on(questionFallbackSessionID, (sessionID) => { + questionRefetch.start(sessionID) + }), + ) + + const permissionRequest = createMemo(() => { + return sessionPermissionRequest(sync.data.session, sync.data.permission, activeSessionID(), (item) => { + return !permission.autoResponds(item, sdk.directory) + }) + }) + + const blocked = createMemo(() => { + const id = activeSessionID() + if (!id) return false + return !!permissionRequest() || !!questionRequest() + }) + + const permissionResponding = createMemo(() => { + const perm = permissionRequest() + if (!perm) return false + return store.responding === perm.id + }) + + const decide = (response: "once" | "always" | "reject") => { + const perm = permissionRequest() + if (!perm) return + if (store.responding === perm.id) return + + setStore("responding", perm.id) + sdk.client.permission + .respond({ sessionID: perm.sessionID, permissionID: perm.id, response }) + .catch((err: unknown) => { + const description = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description }) + }) + .finally(() => { + setStore("responding", (id) => (id === perm.id ? undefined : id)) + }) + } + + return { + blocked, + recoveringQuestion: () => !!questionFallbackSessionID(), + questionRequest, + permissionRequest, + permissionResponding, + decide, + } +} 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 025bf473d..72ae41494 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -1,295 +1,39 @@ -import { batch, createEffect, createMemo, on, onCleanup, onMount } from "solid-js" -import { createStore, reconcile } from "solid-js/store" -import { makeEventListener } from "@solid-primitives/event-listener" -import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2" -import { showToast } from "@opencode-ai/ui/toast" -import { useGlobalSync } from "@/context/global-sync" -import { useLanguage } from "@/context/language" -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 - -const todoTerminal = (todo: Todo) => todo.status === "completed" || todo.status === "cancelled" - -const todoSignature = (todos: Todo[]) => todos.map((todo) => `${todo.status}:${todo.content}`).join("\u0000") +import { createEffect, onCleanup } from "solid-js" +import { createSessionBlockers } from "@/pages/session/blockers/use-session-blockers" +import { createSessionTodoModel } from "@/pages/session/todos/use-session-todos" +import { composerEnabled, composerStateProbe } from "@/testing/session-composer" export function createSessionComposerState(input: { sessionID: () => string | undefined fallbackSessionID?: () => string | undefined }) { - const sdk = useSDK() - const sync = useSync() - const globalSync = useGlobalSync() - const language = useLanguage() - const permission = usePermission() const activeSessionID = input.sessionID - - const questionRequest = createMemo((): QuestionRequest | undefined => { - return sessionQuestionRequest(sync.data.session, sync.data.question, activeSessionID()) - }) - - // Fallback: if a running "question" tool part exists in message parts but no - // question request was received (e.g. the question.asked SSE event was lost), - // re-fetch the pending question list from the backend. - let questionRefetchInflight = false - createEffect( - on( - () => { - const sessionID = activeSessionID() - if (!sessionID) return undefined - if (questionRequest()) return undefined - const messages = sync.data.message[sessionID] - if (!messages?.length) return undefined - for (let i = messages.length - 1; i >= Math.max(0, messages.length - 5); i--) { - const parts = sync.data.part[messages[i].id] - if (!parts) continue - for (const p of parts) { - if (p.type === "tool" && p.tool === "question" && p.state.status === "running") { - return sessionID - } - } - } - return undefined - }, - (sessionID) => { - if (!sessionID || questionRefetchInflight) return - questionRefetchInflight = true - sdk.client.question - .list() - .then((result) => { - const questions = (result.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID) - if (questions.length === 0) return - const grouped = new Map() - for (const q of questions) { - const list = grouped.get(q.sessionID) - if (list) list.push(q) - else grouped.set(q.sessionID, [q]) - } - batch(() => { - for (const [sid, qs] of grouped) { - sync.set( - "question", - sid, - reconcile( - qs.sort((a, b) => (a.id < b.id ? -1 : 1)), - { key: "id" }, - ), - ) - } - }) - }) - .catch(() => {}) - .finally(() => { - questionRefetchInflight = false - }) - }, - ), - ) - - const permissionRequest = createMemo((): PermissionRequest | undefined => { - return sessionPermissionRequest(sync.data.session, sync.data.permission, activeSessionID(), (item) => { - return !permission.autoResponds(item, sdk.directory) - }) - }) - - const blocked = createMemo(() => { - const id = activeSessionID() - if (!id) return false - return !!permissionRequest() || !!questionRequest() - }) - - const [test, setTest] = createStore({ - on: false, - todos: undefined as Todo[] | undefined, - }) - - const pull = () => { - const id = activeSessionID() - if (!id) { - setTest({ on: false, todos: undefined }) - return - } - - const next = composerDriver(id) - if (!next) { - setTest({ on: false, todos: undefined }) - return - } - - setTest({ - on: true, - todos: next.todos?.map((todo) => ({ ...todo })), - }) - } - - onMount(() => { - if (!composerEnabled()) return - - pull() - createEffect(on(activeSessionID, pull, { defer: true })) - - const onEvent = (event: Event) => { - const detail = (event as CustomEvent<{ sessionID?: string }>).detail - if (detail?.sessionID !== activeSessionID()) return - pull() - } - - makeEventListener(window, composerEvent, onEvent) - }) - - const todos = createMemo((): Todo[] => { - if (test.on && test.todos !== undefined) return test.todos - 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. - 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(() => { - 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(() => { - const perm = permissionRequest() - if (!perm) return false - return store.responding === perm.id - }) - - const decide = (response: "once" | "always" | "reject") => { - const perm = permissionRequest() - if (!perm) return - if (store.responding === perm.id) return - - setStore("responding", perm.id) - sdk.client.permission - .respond({ sessionID: perm.sessionID, permissionID: perm.id, response }) - .catch((err: unknown) => { - const description = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description }) - }) - .finally(() => { - setStore("responding", (id) => (id === perm.id ? undefined : id)) - }) - } - - let raf: number | undefined - let hideTimeout: number | undefined - let lastTodoSessionID: string | undefined - const sessionsWithActiveTodos = new Set() - - const clearHideTimeout = () => { - if (hideTimeout === undefined) return - window.clearTimeout(hideTimeout) - hideTimeout = undefined - } - - createEffect( - on( - () => ({ - allDone: allDone(), - count: todos().length, - sessionID: activeSessionID(), - signature: todoSignature(todos()), - }), - ({ 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(() => { - if (activeSessionID() === expectedSessionID && allDone() && todoSignature(todos()) === signature) { - setStore({ dock: false, opening: false, completing: false }) - } - hideTimeout = undefined - }, TODO_DOCK_COMPLETING_DELAY_MS) - return - } - - if (expectedSessionID) sessionsWithActiveTodos.add(expectedSessionID) - clearHideTimeout() - setStore("completing", false) - - const hidden = !store.dock - setStore("dock", true) - if (hidden) { - setStore("opening", true) - raf = requestAnimationFrame(() => { - setStore("opening", false) - raf = undefined - }) - return - } - setStore("opening", false) - }, - ), - ) + const blockers = createSessionBlockers({ sessionID: activeSessionID }) + const todo = createSessionTodoModel({ sessionID: activeSessionID, fallbackSessionID: input.fallbackSessionID }) createEffect(() => { if (!composerEnabled()) return const probe = composerStateProbe(activeSessionID()) probe.set({ - dock: store.dock, - opening: store.opening, - completing: store.completing, - count: todos().length, - states: todos().map((todo) => todo.status), + dock: todo.dock(), + opening: todo.opening(), + completing: todo.completing(), + count: todo.todos().length, + states: todo.todos().map((todo) => todo.status), }) onCleanup(() => probe.drop()) }) - onCleanup(() => { - if (raf) cancelAnimationFrame(raf) - clearHideTimeout() - }) - return { - blocked, - questionRequest, - permissionRequest, - permissionResponding, - decide, - todos, - dock: () => store.dock, - opening: () => store.opening, + blocked: blockers.blocked, + recoveringQuestion: blockers.recoveringQuestion, + questionRequest: blockers.questionRequest, + permissionRequest: blockers.permissionRequest, + permissionResponding: blockers.permissionResponding, + decide: blockers.decide, + todos: todo.todos, + dock: todo.dock, + opening: todo.opening, } } diff --git a/packages/app/src/pages/session/session-todos.test.ts b/packages/app/src/pages/session/session-todos.test.ts index c71491623..1d9ea8d1d 100644 --- a/packages/app/src/pages/session/session-todos.test.ts +++ b/packages/app/src/pages/session/session-todos.test.ts @@ -33,14 +33,20 @@ const todo = (content: string, status: Todo["status"] = "pending"): Todo => ({ }) describe("selectSessionTodos", () => { - test("prefers backend todos over message-derived todos", () => { + test("prefers message-derived todos over lagging backend 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"), + todo("from parts", "in_progress"), ]) }) + test("returns completed-only historical parts for status summary display", () => { + const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("done from parts", "completed")] } }))] + + expect(selectSessionTodos({ backend: [], parts })).toEqual([todo("done from parts", "completed")]) + }) + test("falls back to latest todowrite parts when backend todos are empty", () => { const parts = [ toolPart("todowrite", completedState({ input: { todos: [todo("old", "pending")] } })), diff --git a/packages/app/src/pages/session/session-todos.ts b/packages/app/src/pages/session/session-todos.ts index 1d16cf0c9..2c6f7e955 100644 --- a/packages/app/src/pages/session/session-todos.ts +++ b/packages/app/src/pages/session/session-todos.ts @@ -1,17 +1,7 @@ -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[] -} +export { + selectSessionTodoDataSnapshot, + selectSessionTodoDockSnapshot, + selectSessionTodoSnapshot, + selectSessionTodos, + type SessionTodoSource, +} from "./todos/todo-source" diff --git a/packages/app/src/pages/session/todos/todo-dock-machine.test.ts b/packages/app/src/pages/session/todos/todo-dock-machine.test.ts new file mode 100644 index 000000000..0420e557c --- /dev/null +++ b/packages/app/src/pages/session/todos/todo-dock-machine.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test } from "bun:test" +import { reduceTodoDockState, todoDockHiddenState, type TodoDockMachineState } from "./todo-dock-machine" + +const active = (sessionID = "s") => ({ sessionID, count: 1, phase: "active" as const, lifecycleSignature: "pending" }) +const terminal = (sessionID = "s", lifecycleSignature = "completed") => ({ + sessionID, + count: 1, + phase: "terminal" as const, + lifecycleSignature, +}) +const empty = (sessionID = "s") => ({ sessionID, count: 0, phase: "empty" as const, lifecycleSignature: "" }) + +describe("reduceTodoDockState", () => { + test("active todos show the dock", () => { + expect(reduceTodoDockState(todoDockHiddenState(), { type: "snapshot", input: active() })).toMatchObject({ + kind: "visible-active", + dock: true, + completing: false, + }) + }) + + test("active to terminal enters completing", () => { + const shown = reduceTodoDockState(todoDockHiddenState(), { type: "snapshot", input: active() }) + + expect(reduceTodoDockState(shown, { type: "snapshot", input: terminal() })).toMatchObject({ + kind: "visible-completing", + dock: true, + completing: true, + }) + }) + + test("active parts to terminal parts enters completing even without backend todos", () => { + const shown = reduceTodoDockState(todoDockHiddenState(), { type: "snapshot", input: active() }) + + expect( + reduceTodoDockState(shown, { + type: "snapshot", + input: { ...terminal(), dockEligible: false, historicalTerminal: true }, + }), + ).toMatchObject({ kind: "visible-completing", dock: true, completing: true }) + }) + + test("hide timer hides only the matching terminal snapshot", () => { + const completing = reduceTodoDockState( + reduceTodoDockState(todoDockHiddenState(), { type: "snapshot", input: active() }), + { type: "snapshot", input: terminal("s", "completed") }, + ) + + expect( + reduceTodoDockState(completing, { type: "hideTimerElapsed", sessionID: "s", lifecycleSignature: "completed" }), + ).toMatchObject({ kind: "hidden-terminal", dock: false }) + expect( + reduceTodoDockState(completing, { + type: "hideTimerElapsed", + sessionID: "other", + lifecycleSignature: "completed", + }), + ).toBe(completing) + }) + + test("terminal content-only refresh keeps the same completing state", () => { + const completing = reduceTodoDockState( + reduceTodoDockState(todoDockHiddenState(), { type: "snapshot", input: active() }), + { type: "snapshot", input: terminal("s", "completed") }, + ) + + expect(reduceTodoDockState(completing, { type: "snapshot", input: terminal("s", "completed") })).toBe(completing) + }) + + test("empty hides immediately", () => { + const shown = reduceTodoDockState(todoDockHiddenState(), { type: "snapshot", input: active() }) + + expect(reduceTodoDockState(shown, { type: "snapshot", input: empty() })).toMatchObject({ + kind: "hidden", + dock: false, + }) + }) + + test("new active cancels completing", () => { + const completing = reduceTodoDockState( + reduceTodoDockState(todoDockHiddenState(), { type: "snapshot", input: active() }), + { type: "snapshot", input: terminal() }, + ) + + expect(reduceTodoDockState(completing, { type: "snapshot", input: active() })).toMatchObject({ + kind: "visible-active", + completing: false, + }) + }) + + test("session switch does not inherit active history", () => { + const activeSession = reduceTodoDockState(todoDockHiddenState(), { type: "snapshot", input: active("s") }) + + expect(reduceTodoDockState(activeSession, { type: "snapshot", input: terminal("other") })).toMatchObject({ + kind: "hidden-terminal", + dock: false, + }) + }) + + test("leaving a completing session consumes its transient active history", () => { + const completing = reduceTodoDockState( + reduceTodoDockState(todoDockHiddenState(), { type: "snapshot", input: active("a") }), + { type: "snapshot", input: terminal("a") }, + ) + const other = reduceTodoDockState(completing, { type: "snapshot", input: empty("b") }) + + expect(reduceTodoDockState(other, { type: "snapshot", input: terminal("a") })).toMatchObject({ + kind: "hidden-terminal", + sessionID: "a", + dock: false, + }) + }) + + test("landing on completed-only historical session stays hidden", () => { + const state: TodoDockMachineState = todoDockHiddenState() + + expect(reduceTodoDockState(state, { type: "snapshot", input: terminal() })).toMatchObject({ + kind: "hidden-terminal", + dock: false, + }) + }) +}) diff --git a/packages/app/src/pages/session/todos/todo-dock-machine.ts b/packages/app/src/pages/session/todos/todo-dock-machine.ts new file mode 100644 index 000000000..6eda151fb --- /dev/null +++ b/packages/app/src/pages/session/todos/todo-dock-machine.ts @@ -0,0 +1,149 @@ +import type { TodoPhase } from "./todo-model" + +export const TODO_DOCK_COMPLETING_DELAY_MS = 3000 + +export type TodoDockMachineState = + | { + kind: "hidden" + dock: false + opening: false + completing: false + activeSessionIDs: ReadonlySet + } + | { + kind: "visible-active" + sessionID?: string + dock: true + opening: boolean + completing: false + activeSessionIDs: ReadonlySet + } + | { + kind: "visible-completing" + sessionID?: string + lifecycleSignature: string + dock: true + opening: false + completing: true + activeSessionIDs: ReadonlySet + } + | { + kind: "hidden-terminal" + sessionID?: string + lifecycleSignature: string + dock: false + opening: false + completing: false + activeSessionIDs: ReadonlySet + } + +export type TodoDockInput = { + sessionID?: string + count: number + phase: TodoPhase + lifecycleSignature: string + dockEligible?: boolean + // Semantic flag from the source selector. The reducer primarily uses active + // session history to decide whether terminal snapshots complete a currently + // active dock or remain hidden historical state. + historicalTerminal?: boolean +} + +export type TodoDockTransition = + | { type: "snapshot"; input: TodoDockInput } + | { type: "hideTimerElapsed"; sessionID?: string; lifecycleSignature: string } + | { type: "animationFrameElapsed" } + +export function todoDockHiddenState(activeSessionIDs: ReadonlySet = new Set()): TodoDockMachineState { + return { kind: "hidden", dock: false, opening: false, completing: false, activeSessionIDs } +} + +const forgetSession = (activeSessionIDs: ReadonlySet, sessionID?: string): ReadonlySet => { + if (!sessionID || !activeSessionIDs.has(sessionID)) return activeSessionIDs + const next = new Set(activeSessionIDs) + next.delete(sessionID) + return next +} + +const rememberSession = (activeSessionIDs: ReadonlySet, sessionID?: string): ReadonlySet => { + if (!sessionID || activeSessionIDs.has(sessionID)) return activeSessionIDs + const next = new Set(activeSessionIDs) + next.add(sessionID) + return next +} + +const stateSessionID = (state: TodoDockMachineState) => { + if (state.kind === "hidden") return undefined + return state.sessionID +} + +export function reduceTodoDockState(state: TodoDockMachineState, transition: TodoDockTransition): TodoDockMachineState { + if (transition.type === "animationFrameElapsed") { + if (state.kind !== "visible-active" || !state.opening) return state + return { ...state, opening: false } + } + + if (transition.type === "hideTimerElapsed") { + if (state.kind !== "visible-completing") return state + if (state.sessionID !== transition.sessionID || state.lifecycleSignature !== transition.lifecycleSignature) + return state + return { + kind: "hidden-terminal", + sessionID: state.sessionID, + lifecycleSignature: state.lifecycleSignature, + dock: false, + opening: false, + completing: false, + activeSessionIDs: forgetSession(state.activeSessionIDs, state.sessionID), + } + } + + const input = transition.input + const previousSessionID = stateSessionID(state) + const activeSessionIDs = + previousSessionID && previousSessionID !== input.sessionID + ? forgetSession(state.activeSessionIDs, previousSessionID) + : state.activeSessionIDs + + if (input.count === 0 || input.phase === "empty") { + return todoDockHiddenState(forgetSession(activeSessionIDs, input.sessionID)) + } + + if (input.phase === "active" && input.dockEligible !== false) { + const hidden = !state.dock + return { + kind: "visible-active", + sessionID: input.sessionID, + dock: true, + opening: hidden, + completing: false, + activeSessionIDs: rememberSession(activeSessionIDs, input.sessionID), + } + } + + if (state.kind === "visible-completing") { + if (state.sessionID === input.sessionID && state.lifecycleSignature === input.lifecycleSignature) return state + } + + if (!input.sessionID || !activeSessionIDs.has(input.sessionID)) { + return { + kind: "hidden-terminal", + sessionID: input.sessionID, + lifecycleSignature: input.lifecycleSignature, + dock: false, + opening: false, + completing: false, + activeSessionIDs, + } + } + + return { + kind: "visible-completing", + sessionID: input.sessionID, + lifecycleSignature: input.lifecycleSignature, + dock: true, + opening: false, + completing: true, + activeSessionIDs, + } +} diff --git a/packages/app/src/pages/session/todos/todo-model.test.ts b/packages/app/src/pages/session/todos/todo-model.test.ts new file mode 100644 index 000000000..8aabf4624 --- /dev/null +++ b/packages/app/src/pages/session/todos/todo-model.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test" +import type { Todo } from "@opencode-ai/sdk/v2/client" +import { todoDisplaySignature, todoLifecycleSignature, todoPhase } from "./todo-model" + +const todo = (content: string, status: Todo["status"] = "pending", priority: Todo["priority"] = "medium"): Todo => ({ + content, + status, + priority, +}) + +describe("todoPhase", () => { + test("classifies an empty list as empty", () => { + expect(todoPhase([])).toBe("empty") + }) + + test("classifies pending and in_progress todos as active", () => { + expect(todoPhase([todo("queued", "pending")])).toBe("active") + expect(todoPhase([todo("working", "in_progress")])).toBe("active") + }) + + test("classifies all completed or cancelled todos as terminal", () => { + expect(todoPhase([todo("done", "completed"), todo("skipped", "cancelled")])).toBe("terminal") + }) +}) + +describe("todoLifecycleSignature", () => { + test("ignores content and priority refreshes", () => { + expect(todoLifecycleSignature([todo("first", "completed", "high")])).toBe( + todoLifecycleSignature([todo("first refreshed", "completed", "low")]), + ) + }) + + test("changes when status or count changes", () => { + expect(todoLifecycleSignature([todo("first", "completed")])).not.toBe( + todoLifecycleSignature([todo("first", "pending")]), + ) + expect(todoLifecycleSignature([todo("first", "completed")])).not.toBe( + todoLifecycleSignature([todo("first", "completed"), todo("second", "completed")]), + ) + }) +}) + +describe("todoDisplaySignature", () => { + test("tracks content, priority, and status for rendering-sensitive comparisons", () => { + expect(todoDisplaySignature([todo("first", "completed", "high")])).not.toBe( + todoDisplaySignature([todo("first refreshed", "completed", "high")]), + ) + expect(todoDisplaySignature([todo("first", "completed", "high")])).not.toBe( + todoDisplaySignature([todo("first", "completed", "low")]), + ) + }) + + test("does not collide when todo content contains old delimiter characters", () => { + expect(todoDisplaySignature([todo("a\u0001completed\u0000medium\u0000b", "completed")])).not.toBe( + todoDisplaySignature([todo("a", "completed"), todo("b", "completed")]), + ) + }) +}) diff --git a/packages/app/src/pages/session/todos/todo-model.ts b/packages/app/src/pages/session/todos/todo-model.ts new file mode 100644 index 000000000..2397b93e0 --- /dev/null +++ b/packages/app/src/pages/session/todos/todo-model.ts @@ -0,0 +1,53 @@ +import type { Todo } from "@opencode-ai/sdk/v2/client" + +export type TodoPhase = "empty" | "active" | "terminal" + +export type TodoSourceKind = "primary-backend" | "primary-parts" | "fallback-backend" | "fallback-parts" | "none" + +export type TodoSnapshot = { + sessionID?: string + source: TodoSourceKind + items: Todo[] + phase: TodoPhase + lifecycleSignature: string + displaySignature: string + dockEligible: boolean + historicalTerminal: boolean +} + +export function isTerminalTodo(todo: Pick): boolean { + return todo.status === "completed" || todo.status === "cancelled" +} + +export function todoPhase(todos: readonly Pick[]): TodoPhase { + if (todos.length === 0) return "empty" + return todos.every(isTerminalTodo) ? "terminal" : "active" +} + +export function todoLifecycleSignature(todos: readonly Pick[]): string { + return JSON.stringify(todos.map((todo) => [todo.status])) +} + +export function todoDisplaySignature(todos: readonly Pick[]): string { + return JSON.stringify(todos.map((todo) => [todo.status, todo.priority, todo.content])) +} + +export function todoSnapshot(input: { + sessionID?: string + source: TodoSourceKind + items: Todo[] + dockEligible?: boolean + historicalTerminal?: boolean +}): TodoSnapshot { + const phase = todoPhase(input.items) + return { + sessionID: input.sessionID, + source: input.source, + items: input.items, + phase, + lifecycleSignature: todoLifecycleSignature(input.items), + displaySignature: todoDisplaySignature(input.items), + dockEligible: input.dockEligible ?? phase === "active", + historicalTerminal: input.historicalTerminal ?? false, + } +} diff --git a/packages/app/src/pages/session/todos/todo-source.test.ts b/packages/app/src/pages/session/todos/todo-source.test.ts new file mode 100644 index 000000000..956355d3a --- /dev/null +++ b/packages/app/src/pages/session/todos/todo-source.test.ts @@ -0,0 +1,131 @@ +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 { selectSessionTodoDataSnapshot, selectSessionTodoDockSnapshot, selectSessionTodos } from "./todo-source" + +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("selectSessionTodoDataSnapshot", () => { + test("returns completed-only parts for status summary display", () => { + const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("done from parts", "completed")] } }))] + + expect(selectSessionTodoDataSnapshot({ primary: { backend: [], parts } })).toMatchObject({ + source: "primary-parts", + items: [todo("done from parts", "completed")], + phase: "terminal", + dockEligible: false, + historicalTerminal: true, + }) + }) +}) + +describe("selectSessionTodoDockSnapshot", () => { + test("prefers active primary message-derived todos over lagging backend todos", () => { + const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("from parts", "in_progress")] } }))] + + expect( + selectSessionTodoDockSnapshot({ primary: { backend: [todo("from backend", "pending")], parts } }), + ).toMatchObject({ + source: "primary-parts", + items: [todo("from parts", "in_progress")], + phase: "active", + dockEligible: true, + }) + }) + + test("prefers terminal primary parts over lagging backend todos", () => { + const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("done from parts", "completed")] } }))] + + expect( + selectSessionTodoDockSnapshot({ primary: { backend: [todo("from backend", "pending")], parts } }), + ).toMatchObject({ + source: "primary-parts", + items: [todo("done from parts", "completed")], + phase: "terminal", + }) + }) + + test("does not reopen completed-only historical parts over an empty backend", () => { + const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("done from parts", "completed")] } }))] + + expect(selectSessionTodoDockSnapshot({ primary: { backend: [], parts } })).toMatchObject({ + source: "primary-parts", + items: [todo("done from parts", "completed")], + phase: "terminal", + dockEligible: false, + historicalTerminal: true, + }) + }) + + test("uses active fallback parts when primary sources are empty", () => { + const fallbackParts = [ + toolPart("todowrite", completedState({ input: { todos: [todo("route todo", "in_progress")] } })), + ] + + expect( + selectSessionTodoDockSnapshot({ + primary: { backend: [], parts: [] }, + fallback: { backend: [], parts: fallbackParts }, + }), + ).toMatchObject({ source: "fallback-parts", items: [todo("route todo", "in_progress")] }) + }) + + test("uses fallback backend when no active fallback parts exist", () => { + expect( + selectSessionTodoDockSnapshot({ + primary: { backend: [], parts: [] }, + fallback: { backend: [todo("fallback backend", "pending")], parts: [] }, + }), + ).toMatchObject({ source: "fallback-backend", items: [todo("fallback backend", "pending")] }) + }) + + test("keeps primary terminal backend ahead of fallback active parts", () => { + const fallbackParts = [ + toolPart("todowrite", completedState({ input: { todos: [todo("fallback active", "in_progress")] } })), + ] + + expect( + selectSessionTodoDockSnapshot({ + primary: { backend: [todo("primary done", "completed")], parts: [] }, + fallback: { backend: [], parts: fallbackParts }, + }), + ).toMatchObject({ source: "primary-backend", items: [todo("primary done", "completed")], phase: "terminal" }) + }) +}) + +describe("selectSessionTodos", () => { + test("keeps the existing items-only wrapper", () => { + const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("from parts", "in_progress")] } }))] + + expect(selectSessionTodos({ backend: [todo("from backend", "pending")], parts })).toEqual([ + todo("from parts", "in_progress"), + ]) + }) +}) diff --git a/packages/app/src/pages/session/todos/todo-source.ts b/packages/app/src/pages/session/todos/todo-source.ts new file mode 100644 index 000000000..3e731ef19 --- /dev/null +++ b/packages/app/src/pages/session/todos/todo-source.ts @@ -0,0 +1,109 @@ +import type { Part, Todo } from "@opencode-ai/sdk/v2" +import { extractTodos } from "@/pages/session/session-status-extractors" +import { todoPhase, todoSnapshot, type TodoSnapshot } from "./todo-model" + +export type SessionTodoSource = { + sessionID?: string + backend?: Todo[] + parts: Part[] +} + +export type SelectSessionTodosInput = { + primary: SessionTodoSource + fallback?: SessionTodoSource +} + +const partTodos = (parts: Part[]) => extractTodos(parts) as Todo[] + +// Data snapshots are for status displays and should preserve the latest todo +// list even when it is terminal. Dock snapshots below apply the stricter UI +// policy that historical terminal tool parts must not reopen the composer dock. +export function selectSessionTodoDataSnapshot(input: SelectSessionTodosInput): TodoSnapshot { + const primaryParts = partTodos(input.primary.parts) + if (primaryParts.length > 0) { + const phase = todoPhase(primaryParts) + return todoSnapshot({ + sessionID: input.primary.sessionID, + source: "primary-parts", + items: primaryParts, + dockEligible: phase === "active", + historicalTerminal: phase === "terminal", + }) + } + + if (input.primary.backend && input.primary.backend.length > 0) { + return todoSnapshot({ sessionID: input.primary.sessionID, source: "primary-backend", items: input.primary.backend }) + } + + const fallbackParts = partTodos(input.fallback?.parts ?? []) + if (fallbackParts.length > 0) { + const phase = todoPhase(fallbackParts) + return todoSnapshot({ + sessionID: input.fallback?.sessionID, + source: "fallback-parts", + items: fallbackParts, + dockEligible: phase === "active", + historicalTerminal: phase === "terminal", + }) + } + + if (input.fallback?.backend && input.fallback.backend.length > 0) { + return todoSnapshot({ + sessionID: input.fallback.sessionID, + source: "fallback-backend", + items: input.fallback.backend, + }) + } + + return todoSnapshot({ sessionID: input.primary.sessionID, source: "none", items: [] }) +} + +export function selectSessionTodoDockSnapshot(input: SelectSessionTodosInput): TodoSnapshot { + // Dock source precedence is intentionally stricter than data precedence: + // tool parts can beat lagging backend state while the dock machine decides + // whether terminal snapshots complete an active dock or stay hidden history. + const primaryParts = partTodos(input.primary.parts) + if (primaryParts.length > 0) { + const phase = todoPhase(primaryParts) + return todoSnapshot({ + sessionID: input.primary.sessionID, + source: "primary-parts", + items: primaryParts, + dockEligible: phase === "active", + historicalTerminal: phase === "terminal", + }) + } + + if (input.primary.backend && input.primary.backend.length > 0) { + return todoSnapshot({ sessionID: input.primary.sessionID, source: "primary-backend", items: input.primary.backend }) + } + + const fallbackParts = partTodos(input.fallback?.parts ?? []) + if (fallbackParts.length > 0) { + const phase = todoPhase(fallbackParts) + return todoSnapshot({ + sessionID: input.fallback?.sessionID, + source: "fallback-parts", + items: fallbackParts, + dockEligible: phase === "active", + historicalTerminal: phase === "terminal", + }) + } + + if (input.fallback?.backend && input.fallback.backend.length > 0) { + return todoSnapshot({ + sessionID: input.fallback.sessionID, + source: "fallback-backend", + items: input.fallback.backend, + }) + } + + return todoSnapshot({ sessionID: input.primary.sessionID, source: "none", items: [], dockEligible: false }) +} + +// Deprecated compatibility alias. Prefer explicit data or dock snapshot names. +export const selectSessionTodoSnapshot = selectSessionTodoDockSnapshot + +export function selectSessionTodos(input: SessionTodoSource & { fallback?: SessionTodoSource }): Todo[] { + return selectSessionTodoDataSnapshot({ primary: input, fallback: input.fallback }).items +} diff --git a/packages/app/src/pages/session/todos/use-session-todos.ts b/packages/app/src/pages/session/todos/use-session-todos.ts new file mode 100644 index 000000000..28f6b7669 --- /dev/null +++ b/packages/app/src/pages/session/todos/use-session-todos.ts @@ -0,0 +1,172 @@ +import { createEffect, createMemo, on, onCleanup, onMount } from "solid-js" +import { createStore } from "solid-js/store" +import type { Todo } from "@opencode-ai/sdk/v2" +import { useGlobalSync } from "@/context/global-sync" +import { useSync } from "@/context/sync" +import { composerDriver, composerEnabled, composerEvent } from "@/testing/session-composer" +import { reduceTodoDockState, TODO_DOCK_COMPLETING_DELAY_MS, todoDockHiddenState } from "./todo-dock-machine" +import { todoSnapshot, type TodoSnapshot } from "./todo-model" +import { selectSessionTodoDockSnapshot } from "./todo-source" + +const dockInput = (snapshot: TodoSnapshot, sessionID?: string) => ({ + sessionID: snapshot.sessionID ?? sessionID, + count: snapshot.items.length, + phase: snapshot.phase, + lifecycleSignature: snapshot.lifecycleSignature, + dockEligible: snapshot.dockEligible, + historicalTerminal: snapshot.historicalTerminal, +}) + +export function createSessionTodoModel(input: { + sessionID: () => string | undefined + fallbackSessionID?: () => string | undefined +}) { + const sync = useSync() + const globalSync = useGlobalSync() + const activeSessionID = input.sessionID + + const [test, setTest] = createStore({ + on: false, + todos: undefined as Todo[] | undefined, + }) + + const pull = () => { + const id = activeSessionID() + if (!id) { + setTest({ on: false, todos: undefined }) + return + } + + const next = composerDriver(id) + if (!next) { + setTest({ on: false, todos: undefined }) + return + } + + setTest({ + on: true, + todos: next.todos?.map((todo) => ({ ...todo })), + }) + } + + onMount(() => { + if (!composerEnabled()) return + + pull() + createEffect(on(activeSessionID, pull, { defer: true })) + + const onEvent = (event: Event) => { + const detail = (event as CustomEvent<{ sessionID?: string }>).detail + if (detail?.sessionID !== activeSessionID()) return + pull() + } + + window.addEventListener(composerEvent, onEvent) + onCleanup(() => window.removeEventListener(composerEvent, onEvent)) + }) + + const snapshot = createMemo((): TodoSnapshot => { + const id = activeSessionID() + if (test.on && test.todos !== undefined) { + return todoSnapshot({ + sessionID: id, + source: test.todos.length > 0 ? "primary-backend" : "none", + items: test.todos, + }) + } + if (!id) return todoSnapshot({ source: "none", items: [] }) + + 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 selectSessionTodoDockSnapshot({ + primary: { sessionID: id, backend: globalSync.data.session_todo[id], parts }, + fallback: fallbackID + ? { sessionID: fallbackID, backend: globalSync.data.session_todo[fallbackID], parts: fallbackParts } + : undefined, + }) + }) + + let machine = reduceTodoDockState(todoDockHiddenState(), { + type: "snapshot", + input: dockInput(snapshot(), activeSessionID()), + }) + const [dock, setDock] = createStore({ + dock: machine.dock, + opening: machine.opening, + completing: machine.completing, + }) + let raf: number | undefined + let hideTimeout: number | undefined + + const clearAnimationFrame = () => { + if (raf === undefined) return + cancelAnimationFrame(raf) + raf = undefined + } + + const clearHideTimeout = () => { + if (hideTimeout === undefined) return + window.clearTimeout(hideTimeout) + hideTimeout = undefined + } + + const publish = () => { + setDock({ dock: machine.dock, opening: machine.opening, completing: machine.completing }) + } + + const dispatch = (transition: Parameters[1]) => { + const previous = machine + const next = reduceTodoDockState(machine, transition) + if (next === previous) return + + machine = next + publish() + + if (next.kind !== "visible-completing") clearHideTimeout() + if (next.kind !== "visible-active" || !next.opening) clearAnimationFrame() + + if (next.kind === "visible-active" && next.opening) { + clearAnimationFrame() + raf = requestAnimationFrame(() => { + raf = undefined + dispatch({ type: "animationFrameElapsed" }) + }) + } + + if (next.kind === "visible-completing") { + clearHideTimeout() + const { sessionID, lifecycleSignature } = next + hideTimeout = window.setTimeout(() => { + hideTimeout = undefined + dispatch({ type: "hideTimerElapsed", sessionID, lifecycleSignature }) + }, TODO_DOCK_COMPLETING_DELAY_MS) + } + } + + createEffect( + on( + () => { + const current = snapshot() + return dockInput(current, activeSessionID()) + }, + (current) => dispatch({ type: "snapshot", input: current }), + ), + ) + + onCleanup(() => { + clearAnimationFrame() + clearHideTimeout() + }) + + return { + snapshot, + todos: () => snapshot().items, + dock: () => dock.dock, + opening: () => dock.opening, + completing: () => dock.completing, + } +} diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index f4baf9b4a..533adb859 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -114,7 +114,7 @@ export const dict: Record = { "ui.tool.worktree.exit.toProject": "Back to {{project}}", "ui.tool.worktree.exit.fromWorktree": "{{previous}} → {{project}}", "ui.tool.shell": "Shell", - "ui.tool.patch": "Patch", + "ui.tool.patch": "Edit files", "ui.tool.todos": "To-dos", "ui.tool.todos.read": "Read to-dos", "ui.tool.questions": "Questions", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index f9adc4439..290429007 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -108,7 +108,7 @@ export const dict = { "ui.tool.worktree.exit.toProject": "回到 {{project}}", "ui.tool.worktree.exit.fromWorktree": "{{previous}} → {{project}}", "ui.tool.shell": "执行命令", - "ui.tool.patch": "批量修改", + "ui.tool.patch": "修改文件", "ui.tool.todos": "待办", "ui.tool.todos.read": "查看待办", "ui.tool.questions": "提出问题", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index 5ec0c53b6..7d531fa7b 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -103,7 +103,7 @@ export const dict = { "ui.tool.websearch": "網頁搜尋", "ui.tool.codesearch": "程式碼搜尋", "ui.tool.shell": "Shell", - "ui.tool.patch": "修補", + "ui.tool.patch": "修改檔案", "ui.tool.todos": "待辦", "ui.tool.todos.read": "讀取待辦", "ui.tool.questions": "問題",