From c4274049cd4ea33c182eb1b294f0ff8d2dfff2fa Mon Sep 17 00:00:00 2001 From: wuhongji Date: Thu, 14 May 2026 11:34:27 +0800 Subject: [PATCH 1/9] fix(websearch): remove inner Effect.orDie to propagate errors correctly The websearch tool was using Effect.orDie at the tool execution level, which converted recoverable errors (like quota exceeded, invalid API key, network issues) into unrecoverable defects. This caused the generic 'Tool execution aborted' message instead of meaningful error messages. The outer Tool.define already wraps execution with Effect.orDie (tool.ts:124), so the inner orDie was redundant and harmful - it prevented the McpExa error catch handler from properly propagating user-friendly error messages. Fixes #612 --- packages/opencode/src/tool/websearch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index e1742fd34..a3e2456e7 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -77,7 +77,7 @@ export const WebSearchTool = Tool.define( title: `Web search: ${params.query}`, metadata: {}, } - }).pipe(Effect.orDie), + }), } }), ) From 500e46645448cbc0c087f5dc8e1ee14467e1e7e4 Mon Sep 17 00:00:00 2001 From: wuhongji Date: Thu, 14 May 2026 15:27:48 +0800 Subject: [PATCH 2/9] perf(session): optimize UI performance for typing and scrolling ## Problem Session UI experienced typing lag and scroll stuttering, especially with 50+ messages. Root causes identified in #615: 1. No virtualization - all messages rendered in DOM 2. Excessive createMemo calls per message (276+ reactive calls) 3. Unthrottled scroll events updating thumb position every frame 4. Inefficient content-visibility without proper sizing ## Changes ### Virtual Scrolling (message-timeline.tsx) - Replace with virtua/solid - Only render visible messages, reducing DOM nodes from O(n) to O(1) - Remove redundant content-visibility CSS (handled by virtualizer) - Add getKey for stable item identity ### Throttled Scroll Events (scroll-view.tsx) - Throttle onScroll handler with requestAnimationFrame - Reduce CPU usage during scrolling by batching updates - Prevent redundant thumb position calculations ### Memo Optimization (message-part.tsx) - Add custom equals functions to createMemo for Map comparisons - Avoid unnecessary re-renders when Map contents haven't changed - Reduce reactive computation overhead ## Testing - All 1063 unit tests pass - No breaking changes to public API Fixes #615 --- .../app/src/pages/session/message-timeline.tsx | 15 ++++++++------- packages/ui/src/components/message-part.tsx | 4 +++- packages/ui/src/components/scroll-view.tsx | 16 ++++++++++++---- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 8438be308..511c53fea 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -1,4 +1,5 @@ -import { For, createEffect, createMemo, on, onCleanup, onMount, Show, Index, type JSX, createSignal } from "solid-js" +import { For, createEffect, createMemo, on, onCleanup, onMount, Show, Index, type JSX, createSignal, untrack } from "solid-js" +import { VList } from "virtua/solid" import { createStore, produce } from "solid-js/store" import { useNavigate } from "@solidjs/router" import { useMutation } from "@tanstack/solid-query" @@ -1103,7 +1104,11 @@ export function MessageTimeline(props: { - + messageID} + > {(messageID) => { const active = createMemo(() => activeMessageID() === messageID) const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], { @@ -1126,10 +1131,6 @@ export function MessageTimeline(props: { "min-w-0 w-full max-w-full": true, "md:max-w-[800px] 2xl:max-w-[1000px]": props.centered, }} - style={{ - "content-visibility": active() ? undefined : "auto", - "contain-intrinsic-size": active() ? undefined : "auto 500px", - }} > 0}>
@@ -1201,7 +1202,7 @@ export function MessageTimeline(props: {
) }} -
+
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 1b6a823f3..1e09ea635 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -667,6 +667,8 @@ export function AssistantParts(props: { new Map( props.messages.map((message) => [message.id, index(list(data.store.part?.[message.id], emptyParts))] as const), ), + new Map(), + { equals: (a, b) => a === b || (a.size === b.size && [...a.keys()].every((k) => a.get(k) === b.get(k))) }, ) const grouped = createMemo( @@ -905,7 +907,7 @@ export function AssistantMessageDisplay(props: { showReasoningSummaries?: boolean }) { const emptyTools: ToolPart[] = [] - const part = createMemo(() => index(props.parts)) + const part = createMemo(() => index(props.parts), new Map(), { equals: (a, b) => a === b || a.size === b.size }) const grouped = createMemo( () => groupParts( diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx index f2c04fa28..ddc1f82dd 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view.tsx @@ -148,6 +148,17 @@ export function ScrollView(props: ScrollViewProps) { updateThumb() }) + // Throttle scroll events to reduce CPU usage during scrolling + let scrollThrottleFrame: number | undefined + const throttledScroll = (e: Event) => { + if (scrollThrottleFrame !== undefined) return + scrollThrottleFrame = requestAnimationFrame(() => { + scrollThrottleFrame = undefined + updateThumb() + if (typeof events.onScroll === "function") events.onScroll(e as any) + }) + } + let startY = 0 let startScrollTop = 0 @@ -248,10 +259,7 @@ export function ScrollView(props: ScrollViewProps) { ref={viewportRef} data-component="scroll-viewport" class="scroll-view__viewport" - onScroll={(e) => { - updateThumb() - if (typeof events.onScroll === "function") events.onScroll(e as any) - }} + onScroll={throttledScroll} onWheel={events.onWheel as any} onTouchStart={events.onTouchStart as any} onTouchMove={events.onTouchMove as any} From e94bc1c98d83aa5b0c3ffd9a627f18e955b5b888 Mon Sep 17 00:00:00 2001 From: Spongeacer Date: Fri, 15 May 2026 18:17:35 +0800 Subject: [PATCH 3/9] fix(todo): prevent stuck todo status from stale metadata and parts shadowing backend - Replace strict isValidTodo batch rejection with normalizeTodo that fills missing fields (status/priority) with sensible defaults. Prevents entire metadata arrays from being discarded when a single todo is partially malformed, which previously caused fallback to stale tool input. - Let backend terminal state override stale active parts in selectSessionTodoDockSnapshot. When backend todos are all terminal but parts still show active, backend is fresher and should win. - Add tests covering: - partial metadata normalization - multi-part competition with invalid metadata - backend terminal overriding stale active parts - empty/non-array metadata fallback Fixes #TBD --- .../session/session-status-extractors.test.ts | 95 ++++++++++++++++++- .../session/session-status-extractors.ts | 16 +++- .../pages/session/todos/todo-source.test.ts | 21 ++++ .../src/pages/session/todos/todo-source.ts | 22 ++++- 4 files changed, 146 insertions(+), 8 deletions(-) diff --git a/packages/app/src/pages/session/session-status-extractors.test.ts b/packages/app/src/pages/session/session-status-extractors.test.ts index f51f2d46e..010e896d8 100644 --- a/packages/app/src/pages/session/session-status-extractors.test.ts +++ b/packages/app/src/pages/session/session-status-extractors.test.ts @@ -84,16 +84,103 @@ describe("extractTodos", () => { expect(extractTodos([part])).toEqual([{ id: "todo_1", content: "A", status: "pending", priority: "medium" }]) }) - it("falls back to tool input when metadata todos are malformed", () => { + it("normalizes malformed metadata todos with default fields instead of falling back to input", () => { const part = toolPart( "todowrite", completedState({ - input: { todos: [{ content: "A", status: "pending", priority: "medium" }] }, - metadata: { todos: [{ id: "todo_1", content: "A", status: "pending" }] }, + input: { todos: [{ content: "from input", status: "pending", priority: "low" }] }, + metadata: { todos: [{ id: "todo_1", content: "A", status: "pending" }] }, // ← missing priority + }), + ) + + // Missing priority is normalized to "medium" instead of discarding the whole metadata batch. + expect(extractTodos([part])).toEqual([{ id: "todo_1", content: "A", status: "pending", priority: "medium" }]) + }) + + it("normalizes partial metadata todos instead of discarding the whole batch", () => { + const part = toolPart( + "todowrite", + completedState({ + input: { todos: [{ content: "old from input", status: "pending", priority: "low" }] }, + metadata: { + todos: [ + { id: "todo_1", content: "updated", status: "completed", priority: "high" }, + { id: "todo_2", content: "partial", status: "in_progress" }, // ← missing priority + ], + }, + }), + ) + + // Missing fields are normalized with sensible defaults instead of discarding the whole batch. + expect(extractTodos([part])).toEqual([ + { id: "todo_1", content: "updated", status: "completed", priority: "high" }, + { id: "todo_2", content: "partial", status: "in_progress", priority: "medium" }, + ]) + }) + + it("later part normalizes partial metadata instead of falling back to stale input", () => { + const older = toolPart( + "todowrite", + completedState({ + input: { todos: [{ content: "old", status: "pending", priority: "low" }] }, + metadata: { todos: [{ id: "todo_1", content: "updated", status: "completed", priority: "high" }] }, + }), + ) + const newer = toolPart( + "todowrite", + completedState({ + input: { todos: [{ content: "old", status: "pending", priority: "low" }] }, + metadata: { + todos: [ + { id: "todo_1", content: "updated", status: "completed", priority: "high" }, + { id: "todo_2", content: "partial", status: "in_progress" }, // ← missing priority + ], + }, + }), + ) + + // Newer part's metadata is partially invalid, but normalization preserves the valid + // items with defaulted fields instead of falling back to stale input. + expect(extractTodos([older, newer])).toEqual([ + { id: "todo_1", content: "updated", status: "completed", priority: "high" }, + { id: "todo_2", content: "partial", status: "in_progress", priority: "medium" }, + ]) + }) + + it("falls back to input when metadata is an empty object", () => { + const part = toolPart( + "todowrite", + completedState({ + input: { todos: [{ content: "from input", status: "pending", priority: "medium" }] }, + metadata: {}, + }), + ) + + expect(extractTodos([part])).toEqual([{ content: "from input", status: "pending", priority: "medium" }]) + }) + + it("falls back to input when metadata.todos is not an array", () => { + const part = toolPart( + "todowrite", + completedState({ + input: { todos: [{ content: "from input", status: "pending", priority: "medium" }] }, + metadata: { todos: "not-an-array" }, + }), + ) + + expect(extractTodos([part])).toEqual([{ content: "from input", status: "pending", priority: "medium" }]) + }) + + it("returns empty when neither metadata nor input contains valid todos", () => { + const part = toolPart( + "todowrite", + completedState({ + input: { todos: "not-an-array" }, + metadata: { todos: "also-not-an-array" }, }), ) - expect(extractTodos([part])).toEqual([{ content: "A", status: "pending", priority: "medium" }]) + expect(extractTodos([part])).toEqual([]) }) }) diff --git a/packages/app/src/pages/session/session-status-extractors.ts b/packages/app/src/pages/session/session-status-extractors.ts index 72f453e4d..072be1d59 100644 --- a/packages/app/src/pages/session/session-status-extractors.ts +++ b/packages/app/src/pages/session/session-status-extractors.ts @@ -38,12 +38,24 @@ function isValidTodo(value: unknown): value is TodoItem { ) } +function normalizeTodo(value: unknown): TodoItem | undefined { + if (typeof value !== "object" || value === null) return undefined + const v = value as Partial> + if (typeof v.content !== "string") return undefined + return { + id: typeof v.id === "string" ? v.id : undefined, + content: v.content, + status: typeof v.status === "string" ? v.status : "pending", + priority: typeof v.priority === "string" ? v.priority : "medium", + } +} + function todosFromMetadata(part: Extract): TodoItem[] | undefined { const metadata = part.state.status === "completed" ? part.state.metadata : undefined const todos = (metadata as { todos?: unknown } | undefined)?.todos if (!Array.isArray(todos)) return undefined - const valid = todos.filter(isValidTodo) - return valid.length === todos.length ? valid : undefined + const normalized = todos.map(normalizeTodo).filter((t): t is TodoItem => t !== undefined) + return normalized.length > 0 ? normalized : undefined } export function extractTodos(parts: Part[]): TodoItem[] { diff --git a/packages/app/src/pages/session/todos/todo-source.test.ts b/packages/app/src/pages/session/todos/todo-source.test.ts index 7ab6ce240..15c2f197a 100644 --- a/packages/app/src/pages/session/todos/todo-source.test.ts +++ b/packages/app/src/pages/session/todos/todo-source.test.ts @@ -72,6 +72,27 @@ describe("selectSessionTodoDockSnapshot", () => { }) }) + test("backend terminal updates override stale active parts", () => { + // Scenario: LLM called todowrite once marking task as in_progress. + // Later, backend received a todo.updated event marking it completed. + // Backend terminal state should take precedence over stale active parts. + const parts = [ + toolPart("todowrite", completedState({ input: { todos: [todo("task A", "in_progress")] } })), + ] + + expect( + selectSessionTodoDockSnapshot({ + primary: { backend: [todo("task A", "completed")], parts }, + }), + ).toMatchObject({ + source: "primary-backend", + items: [todo("task A", "completed")], + phase: "terminal", + dockEligible: false, + historicalTerminal: true, + }) + }) + test("does not reopen completed-only historical parts over an empty backend", () => { const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("done from parts", "completed")] } }))] diff --git a/packages/app/src/pages/session/todos/todo-source.ts b/packages/app/src/pages/session/todos/todo-source.ts index 737344ee7..7ee0a0fcb 100644 --- a/packages/app/src/pages/session/todos/todo-source.ts +++ b/packages/app/src/pages/session/todos/todo-source.ts @@ -63,6 +63,24 @@ export function selectSessionTodoDockSnapshot(input: SelectSessionTodosInput): T // 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) + const primaryBackend = input.primary.backend ?? [] + + // When backend has reached a terminal state but parts still show active, + // the backend update is fresher — prefer it so the UI does not get stuck. + if (primaryParts.length > 0 && primaryBackend.length > 0) { + const partsPhase = todoPhase(primaryParts) + const backendPhase = todoPhase(primaryBackend) + if (backendPhase === "terminal" && partsPhase === "active") { + return todoSnapshot({ + sessionID: input.primary.sessionID, + source: "primary-backend", + items: primaryBackend, + dockEligible: false, + historicalTerminal: true, + }) + } + } + if (primaryParts.length > 0) { const phase = todoPhase(primaryParts) return todoSnapshot({ @@ -74,8 +92,8 @@ export function selectSessionTodoDockSnapshot(input: SelectSessionTodosInput): T }) } - if (input.primary.backend && input.primary.backend.length > 0) { - return todoSnapshot({ sessionID: input.primary.sessionID, source: "primary-backend", items: input.primary.backend }) + if (primaryBackend.length > 0) { + return todoSnapshot({ sessionID: input.primary.sessionID, source: "primary-backend", items: primaryBackend }) } const fallbackParts = partTodos(input.fallback?.parts ?? []) From ccb9f052c463b1505151f0fc9a64dd65a0519902 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 15 May 2026 19:16:27 +0800 Subject: [PATCH 4/9] fix(todo): unify terminal todo source --- .../e2e/session/session-composer-dock.spec.ts | 62 +++++++++++++- .../session/session-status-panel.tsx | 5 +- .../session/session-status-summary.tsx | 7 +- .../session/session-status-extractors.test.ts | 55 ++----------- .../session/session-status-extractors.ts | 16 +--- .../src/pages/session/session-todos.test.ts | 16 ++++ .../pages/session/todos/todo-source.test.ts | 39 +++++++++ .../src/pages/session/todos/todo-source.ts | 82 +++++++++---------- .../src/server/instance/middleware.ts | 1 + .../opencode/src/server/instance/session.ts | 26 ++++++ packages/ui/src/components/scroll-view.tsx | 16 +--- 11 files changed, 206 insertions(+), 119 deletions(-) diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index 53a10762f..07d5b8263 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -1,5 +1,5 @@ import type { Page } from "@playwright/test" -import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2/client" +import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2/client" import { test, expect } from "../fixtures" import { composerEvent, @@ -17,6 +17,7 @@ import { sessionComposerDockSelector, sessionTurnListSelector, sessionTodoToggleButtonSelector, + titlebarRightSelector, } from "../selectors" import { modKey } from "../utils" import { inputMatch } from "../prompt/mock" @@ -192,6 +193,21 @@ async function e2ePublishQuestionBlocker(project: ProjectQuestionSeed, request: expect(response.status).toBe(204) } +async function e2eUpdateTodos( + project: ProjectQuestionSeed, + input: { sessionID: string; todos: Array & Partial>> }, +) { + const response = await fetch( + `${project.url}/session/__e2e/update-todos?directory=${encodeURIComponent(project.directory)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }, + ) + expect(response.status, await response.text()).toBe(204) +} + async function waitForQuestionSeed(project: ProjectQuestionSeed, sessionID: string) { let current: QuestionRequest | undefined await expect @@ -1237,6 +1253,50 @@ test("todo dock appears from real todowrite tool parts", async ({ page, llm, pro ) }) +test("todo dock and status summary use backend terminal update over stale todowrite parts", async ({ + page, + llm, + project, +}) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock backend terminal todo", + async (session) => { + const content = "backend terminal todo" + await project.gotoSession(session.id) + + await llm.tool("todowrite", { + todos: [{ content, status: "in_progress", priority: "medium" }], + }) + await llm.text("todo started") + await project.prompt("Create a todo and start it.") + + const dockItem = page.locator('[data-slot="session-todo-item"]').filter({ hasText: content }).first() + await expect(dockItem).toHaveAttribute("data-state", "in_progress", { timeout: 30_000 }) + + await e2eUpdateTodos( + { url: project.url, directory: project.directory, sdk: project.sdk }, + { + sessionID: session.id, + todos: [{ content, status: "completed", priority: "medium" }], + }, + ) + + await expect(dockItem).toHaveAttribute("data-state", "completed", { timeout: 10_000 }) + + const rightPanel = page.locator("#right-panel") + if ((await rightPanel.getAttribute("aria-hidden")) !== "false") { + await page.locator(`${titlebarRightSelector} button`).first().click() + } + await expect(rightPanel).toHaveAttribute("aria-hidden", "false") + const summaryTodo = rightPanel.locator('[data-slot="status-summary-todo"]').filter({ hasText: content }).first() + await expect(summaryTodo).toHaveAttribute("data-state", "completed", { timeout: 10_000 }) + }, + { trackSession: project.trackSession }, + ) +}) + test("todo dock appears for the first todowrite in a fresh session", async ({ page, llm, project }) => { await project.open() diff --git a/packages/app/src/components/session/session-status-panel.tsx b/packages/app/src/components/session/session-status-panel.tsx index 4cf077cf5..7c01b9e59 100644 --- a/packages/app/src/components/session/session-status-panel.tsx +++ b/packages/app/src/components/session/session-status-panel.tsx @@ -1,12 +1,14 @@ import { createMemo, type Accessor } from "solid-js" import { useParams } from "@solidjs/router" import type { Part } from "@opencode-ai/sdk/v2" +import { useGlobalSync } from "@/context/global-sync" import { useSync } from "@/context/sync" import { SessionStatusSummary } from "./session-status-summary" import { SessionStatusConnections } from "./session-status-connections" export function SessionStatusPanel(props: { shown: Accessor }) { const params = useParams() + const globalSync = useGlobalSync() const sync = useSync() const parts = createMemo(() => { @@ -14,10 +16,11 @@ export function SessionStatusPanel(props: { shown: Accessor }) { const messages = sync.data.message[params.id] ?? [] return messages.flatMap((message) => sync.data.part[message.id] ?? []) }) + const backend = createMemo(() => (params.id ? globalSync.data.session_todo[params.id] : undefined)) return (
- +
) diff --git a/packages/app/src/components/session/session-status-summary.tsx b/packages/app/src/components/session/session-status-summary.tsx index 623587b1f..c179cfe5d 100644 --- a/packages/app/src/components/session/session-status-summary.tsx +++ b/packages/app/src/components/session/session-status-summary.tsx @@ -1,5 +1,6 @@ import { For, Show, createMemo, type Accessor, type JSX } from "solid-js" import type { Part } from "@opencode-ai/sdk/v2" +import type { Todo } from "@opencode-ai/sdk/v2/client" import { useLanguage } from "@/context/language" import { extractSources, type TodoItem } from "@/pages/session/session-status-extractors" import { selectSessionTodos } from "@/pages/session/session-todos" @@ -27,7 +28,7 @@ function Empty(props: { text: string }) { function TodoRow(props: { todo: TodoItem }) { const style = () => TODO_STATUS_STYLES[props.todo.status] ?? TODO_STATUS_STYLES.pending return ( -
+
{props.todo.content}
@@ -42,9 +43,9 @@ function SourceRow(props: { url: string }) { ) } -export function SessionStatusSummary(props: { parts: Accessor }) { +export function SessionStatusSummary(props: { backend?: Accessor; parts: Accessor }) { const language = useLanguage() - const todos = createMemo(() => selectSessionTodos({ parts: props.parts() })) + const todos = createMemo(() => selectSessionTodos({ backend: props.backend?.(), parts: props.parts() })) const sources = createMemo(() => extractSources(props.parts())) return ( diff --git a/packages/app/src/pages/session/session-status-extractors.test.ts b/packages/app/src/pages/session/session-status-extractors.test.ts index 010e896d8..6a3b66eab 100644 --- a/packages/app/src/pages/session/session-status-extractors.test.ts +++ b/packages/app/src/pages/session/session-status-extractors.test.ts @@ -84,67 +84,28 @@ describe("extractTodos", () => { expect(extractTodos([part])).toEqual([{ id: "todo_1", content: "A", status: "pending", priority: "medium" }]) }) - it("normalizes malformed metadata todos with default fields instead of falling back to input", () => { + it("falls back to tool input when metadata todos are malformed", () => { const part = toolPart( "todowrite", completedState({ - input: { todos: [{ content: "from input", status: "pending", priority: "low" }] }, - metadata: { todos: [{ id: "todo_1", content: "A", status: "pending" }] }, // ← missing priority + input: { todos: [{ content: "A", status: "pending", priority: "medium" }] }, + metadata: { todos: [{ id: "todo_1", content: "A", status: "pending" }] }, }), ) - // Missing priority is normalized to "medium" instead of discarding the whole metadata batch. - expect(extractTodos([part])).toEqual([{ id: "todo_1", content: "A", status: "pending", priority: "medium" }]) + expect(extractTodos([part])).toEqual([{ content: "A", status: "pending", priority: "medium" }]) }) - it("normalizes partial metadata todos instead of discarding the whole batch", () => { + it("does not default missing metadata status to an active todo", () => { const part = toolPart( "todowrite", completedState({ - input: { todos: [{ content: "old from input", status: "pending", priority: "low" }] }, - metadata: { - todos: [ - { id: "todo_1", content: "updated", status: "completed", priority: "high" }, - { id: "todo_2", content: "partial", status: "in_progress" }, // ← missing priority - ], - }, - }), - ) - - // Missing fields are normalized with sensible defaults instead of discarding the whole batch. - expect(extractTodos([part])).toEqual([ - { id: "todo_1", content: "updated", status: "completed", priority: "high" }, - { id: "todo_2", content: "partial", status: "in_progress", priority: "medium" }, - ]) - }) - - it("later part normalizes partial metadata instead of falling back to stale input", () => { - const older = toolPart( - "todowrite", - completedState({ - input: { todos: [{ content: "old", status: "pending", priority: "low" }] }, - metadata: { todos: [{ id: "todo_1", content: "updated", status: "completed", priority: "high" }] }, - }), - ) - const newer = toolPart( - "todowrite", - completedState({ - input: { todos: [{ content: "old", status: "pending", priority: "low" }] }, - metadata: { - todos: [ - { id: "todo_1", content: "updated", status: "completed", priority: "high" }, - { id: "todo_2", content: "partial", status: "in_progress" }, // ← missing priority - ], - }, + input: { todos: [{ content: "from input", status: "completed", priority: "medium" }] }, + metadata: { todos: [{ id: "todo_1", content: "from metadata", priority: "medium" }] }, }), ) - // Newer part's metadata is partially invalid, but normalization preserves the valid - // items with defaulted fields instead of falling back to stale input. - expect(extractTodos([older, newer])).toEqual([ - { id: "todo_1", content: "updated", status: "completed", priority: "high" }, - { id: "todo_2", content: "partial", status: "in_progress", priority: "medium" }, - ]) + expect(extractTodos([part])).toEqual([{ content: "from input", status: "completed", priority: "medium" }]) }) it("falls back to input when metadata is an empty object", () => { diff --git a/packages/app/src/pages/session/session-status-extractors.ts b/packages/app/src/pages/session/session-status-extractors.ts index 072be1d59..72f453e4d 100644 --- a/packages/app/src/pages/session/session-status-extractors.ts +++ b/packages/app/src/pages/session/session-status-extractors.ts @@ -38,24 +38,12 @@ function isValidTodo(value: unknown): value is TodoItem { ) } -function normalizeTodo(value: unknown): TodoItem | undefined { - if (typeof value !== "object" || value === null) return undefined - const v = value as Partial> - if (typeof v.content !== "string") return undefined - return { - id: typeof v.id === "string" ? v.id : undefined, - content: v.content, - status: typeof v.status === "string" ? v.status : "pending", - priority: typeof v.priority === "string" ? v.priority : "medium", - } -} - function todosFromMetadata(part: Extract): TodoItem[] | undefined { const metadata = part.state.status === "completed" ? part.state.metadata : undefined const todos = (metadata as { todos?: unknown } | undefined)?.todos if (!Array.isArray(todos)) return undefined - const normalized = todos.map(normalizeTodo).filter((t): t is TodoItem => t !== undefined) - return normalized.length > 0 ? normalized : undefined + const valid = todos.filter(isValidTodo) + return valid.length === todos.length ? valid : undefined } export function extractTodos(parts: Part[]): TodoItem[] { diff --git a/packages/app/src/pages/session/session-todos.test.ts b/packages/app/src/pages/session/session-todos.test.ts index a5bc85ddb..c60f5cc9c 100644 --- a/packages/app/src/pages/session/session-todos.test.ts +++ b/packages/app/src/pages/session/session-todos.test.ts @@ -49,6 +49,22 @@ describe("selectSessionTodos", () => { ]) }) + test("uses backend terminal todos when matching message-derived todos are stale active", () => { + const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("task A", "in_progress")] } }))] + + expect(selectSessionTodos({ backend: [backendTodo("task A", "completed")], parts })).toEqual([ + backendTodo("task A", "completed"), + ]) + }) + + test("keeps message-derived active todos when terminal backend todos do not match", () => { + const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("new task", "in_progress")] } }))] + + expect(selectSessionTodos({ backend: [backendTodo("old task", "completed")], parts })).toEqual([ + todo("new task", "in_progress"), + ]) + }) + test("returns completed-only historical parts for status summary display", () => { const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("done from parts", "completed")] } }))] diff --git a/packages/app/src/pages/session/todos/todo-source.test.ts b/packages/app/src/pages/session/todos/todo-source.test.ts index 15c2f197a..01cd8b6b5 100644 --- a/packages/app/src/pages/session/todos/todo-source.test.ts +++ b/packages/app/src/pages/session/todos/todo-source.test.ts @@ -44,6 +44,22 @@ describe("selectSessionTodoDataSnapshot", () => { historicalTerminal: true, }) }) + + test("uses matching backend terminal todos over stale active parts", () => { + const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("task A", "in_progress")] } }))] + + expect( + selectSessionTodoDataSnapshot({ + primary: { backend: [todo("task A", "completed")], parts }, + }), + ).toMatchObject({ + source: "primary-backend", + items: [todo("task A", "completed")], + phase: "terminal", + dockEligible: false, + historicalTerminal: true, + }) + }) }) describe("selectSessionTodoDockSnapshot", () => { @@ -93,6 +109,21 @@ describe("selectSessionTodoDockSnapshot", () => { }) }) + test("keeps active parts when terminal backend describes a different todo", () => { + const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("new task", "in_progress")] } }))] + + expect( + selectSessionTodoDockSnapshot({ + primary: { backend: [todo("old task", "completed")], parts }, + }), + ).toMatchObject({ + source: "primary-parts", + items: [todo("new task", "in_progress")], + phase: "active", + dockEligible: true, + }) + }) + test("does not reopen completed-only historical parts over an empty backend", () => { const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("done from parts", "completed")] } }))] @@ -149,4 +180,12 @@ describe("selectSessionTodos", () => { todo("from parts", "in_progress"), ]) }) + + test("returns backend terminal todos when matching parts are stale active", () => { + const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("task A", "in_progress")] } }))] + + expect(selectSessionTodos({ backend: [todo("task A", "completed")], parts })).toEqual([ + todo("task A", "completed"), + ]) + }) }) diff --git a/packages/app/src/pages/session/todos/todo-source.ts b/packages/app/src/pages/session/todos/todo-source.ts index 7ee0a0fcb..0a4c995e1 100644 --- a/packages/app/src/pages/session/todos/todo-source.ts +++ b/packages/app/src/pages/session/todos/todo-source.ts @@ -15,15 +15,38 @@ export type SelectSessionTodosInput = { const partTodos = (parts: Part[]) => extractTodos(parts) -// 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) +const sameTodoList = (backend: SessionTodoItem[], parts: SessionTodoItem[]) => { + if (backend.length !== parts.length) return false + return parts.every((part, index) => { + const fromBackend = backend[index] + if (!fromBackend) return false + if (part.id && fromBackend.id) return part.id === fromBackend.id + return part.content === fromBackend.content + }) +} + +const primaryTodoSnapshot = (input: SessionTodoSource): TodoSnapshot | undefined => { + const primaryParts = partTodos(input.parts) + const primaryBackend = input.backend ?? [] + + if (primaryParts.length > 0 && primaryBackend.length > 0) { + const partsPhase = todoPhase(primaryParts) + const backendPhase = todoPhase(primaryBackend) + if (backendPhase === "terminal" && partsPhase === "active" && sameTodoList(primaryBackend, primaryParts)) { + return todoSnapshot({ + sessionID: input.sessionID, + source: "primary-backend", + items: primaryBackend, + dockEligible: false, + historicalTerminal: true, + }) + } + } + if (primaryParts.length > 0) { const phase = todoPhase(primaryParts) return todoSnapshot({ - sessionID: input.primary.sessionID, + sessionID: input.sessionID, source: "primary-parts", items: primaryParts, dockEligible: phase === "active", @@ -31,9 +54,17 @@ export function selectSessionTodoDataSnapshot(input: SelectSessionTodosInput): T }) } - if (input.primary.backend && input.primary.backend.length > 0) { - return todoSnapshot({ sessionID: input.primary.sessionID, source: "primary-backend", items: input.primary.backend }) + if (primaryBackend.length > 0) { + return todoSnapshot({ sessionID: input.sessionID, source: "primary-backend", items: primaryBackend }) } +} + +// 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 primary = primaryTodoSnapshot(input.primary) + if (primary) return primary const fallbackParts = partTodos(input.fallback?.parts ?? []) if (fallbackParts.length > 0) { @@ -62,39 +93,8 @@ export function selectSessionTodoDockSnapshot(input: SelectSessionTodosInput): T // 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) - const primaryBackend = input.primary.backend ?? [] - - // When backend has reached a terminal state but parts still show active, - // the backend update is fresher — prefer it so the UI does not get stuck. - if (primaryParts.length > 0 && primaryBackend.length > 0) { - const partsPhase = todoPhase(primaryParts) - const backendPhase = todoPhase(primaryBackend) - if (backendPhase === "terminal" && partsPhase === "active") { - return todoSnapshot({ - sessionID: input.primary.sessionID, - source: "primary-backend", - items: primaryBackend, - dockEligible: false, - historicalTerminal: true, - }) - } - } - - 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 (primaryBackend.length > 0) { - return todoSnapshot({ sessionID: input.primary.sessionID, source: "primary-backend", items: primaryBackend }) - } + const primary = primaryTodoSnapshot(input.primary) + if (primary) return primary const fallbackParts = partTodos(input.fallback?.parts ?? []) if (fallbackParts.length > 0) { diff --git a/packages/opencode/src/server/instance/middleware.ts b/packages/opencode/src/server/instance/middleware.ts index 26a7414dd..7dc18ecd6 100644 --- a/packages/opencode/src/server/instance/middleware.ts +++ b/packages/opencode/src/server/instance/middleware.ts @@ -32,6 +32,7 @@ function local(method: string, path: string) { function getSessionID(url: URL) { if (url.pathname === "/session/status") return null + if (url.pathname.startsWith("/session/__e2e/")) return null const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] if (!id) return null diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts index 3b467c33f..50f9d2dbe 100644 --- a/packages/opencode/src/server/instance/session.ts +++ b/packages/opencode/src/server/instance/session.ts @@ -33,9 +33,11 @@ import { TurnChange, type Display as TurnChangeDisplay } from "@/session/turn-ch import { FileWatcher } from "@/file/watcher" import { File } from "@/file" import { LSP } from "@/lsp" +import { Env } from "@/env" const log = Log.create({ service: "server" }) const AbortMode = z.enum(["soft", "hard"]) +const e2eSessionRoutesEnabled = () => Env.get("OPENCODE_E2E_ENABLED") === "true" && !!Env.get("OPENCODE_E2E_LLM_URL") function publishTurnChangeFiles(display: TurnChangeDisplay, mode: "undo" | "redo", mutatedPaths?: string[]) { return Effect.gen(function* () { @@ -136,6 +138,30 @@ export const SessionRoutes = lazy(() => return c.json(Object.fromEntries(result)) }, ) + .post( + "/__e2e/update-todos", + validator( + "json", + z.object({ + sessionID: SessionID.zod, + todos: z.array(Todo.Input), + }), + ), + async (c) => { + if (!e2eSessionRoutesEnabled()) return c.notFound() + + const json = c.req.valid("json") + await AppRuntime.runPromise( + Todo.Service.use((svc) => + svc.update({ + sessionID: json.sessionID, + todos: json.todos, + }), + ), + ) + return c.body(null, 204) + }, + ) .get( "/:sessionID", describeRoute({ diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx index ddc1f82dd..f2c04fa28 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view.tsx @@ -148,17 +148,6 @@ export function ScrollView(props: ScrollViewProps) { updateThumb() }) - // Throttle scroll events to reduce CPU usage during scrolling - let scrollThrottleFrame: number | undefined - const throttledScroll = (e: Event) => { - if (scrollThrottleFrame !== undefined) return - scrollThrottleFrame = requestAnimationFrame(() => { - scrollThrottleFrame = undefined - updateThumb() - if (typeof events.onScroll === "function") events.onScroll(e as any) - }) - } - let startY = 0 let startScrollTop = 0 @@ -259,7 +248,10 @@ export function ScrollView(props: ScrollViewProps) { ref={viewportRef} data-component="scroll-viewport" class="scroll-view__viewport" - onScroll={throttledScroll} + onScroll={(e) => { + updateThumb() + if (typeof events.onScroll === "function") events.onScroll(e as any) + }} onWheel={events.onWheel as any} onTouchStart={events.onTouchStart as any} onTouchMove={events.onTouchMove as any} From 61c83480737a398929a006b0cf99cf14fde68c8b Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 15 May 2026 19:30:43 +0800 Subject: [PATCH 5/9] fix(todo): apply terminal override to fallback source --- .../pages/session/todos/todo-source.test.ts | 33 +++++++ .../src/pages/session/todos/todo-source.ts | 87 +++++++------------ 2 files changed, 63 insertions(+), 57 deletions(-) diff --git a/packages/app/src/pages/session/todos/todo-source.test.ts b/packages/app/src/pages/session/todos/todo-source.test.ts index 01cd8b6b5..566ee326a 100644 --- a/packages/app/src/pages/session/todos/todo-source.test.ts +++ b/packages/app/src/pages/session/todos/todo-source.test.ts @@ -158,6 +158,25 @@ describe("selectSessionTodoDockSnapshot", () => { ).toMatchObject({ source: "fallback-backend", items: [todo("fallback backend", "pending")] }) }) + test("uses matching fallback backend terminal todos over stale fallback active parts", () => { + const fallbackParts = [ + toolPart("todowrite", completedState({ input: { todos: [todo("fallback task", "in_progress")] } })), + ] + + expect( + selectSessionTodoDockSnapshot({ + primary: { backend: [], parts: [] }, + fallback: { backend: [todo("fallback task", "completed")], parts: fallbackParts }, + }), + ).toMatchObject({ + source: "fallback-backend", + items: [todo("fallback task", "completed")], + phase: "terminal", + dockEligible: false, + historicalTerminal: true, + }) + }) + test("keeps primary terminal backend ahead of fallback active parts", () => { const fallbackParts = [ toolPart("todowrite", completedState({ input: { todos: [todo("fallback active", "in_progress")] } })), @@ -188,4 +207,18 @@ describe("selectSessionTodos", () => { todo("task A", "completed"), ]) }) + + test("returns fallback backend terminal todos when matching fallback parts are stale active", () => { + const fallbackParts = [ + toolPart("todowrite", completedState({ input: { todos: [todo("fallback task", "in_progress")] } })), + ] + + expect( + selectSessionTodos({ + backend: [], + parts: [], + fallback: { backend: [todo("fallback task", "completed")], parts: fallbackParts }, + }), + ).toEqual([todo("fallback task", "completed")]) + }) }) diff --git a/packages/app/src/pages/session/todos/todo-source.ts b/packages/app/src/pages/session/todos/todo-source.ts index 0a4c995e1..6ad4a53cf 100644 --- a/packages/app/src/pages/session/todos/todo-source.ts +++ b/packages/app/src/pages/session/todos/todo-source.ts @@ -1,6 +1,6 @@ import type { Part, Todo } from "@opencode-ai/sdk/v2" import { extractTodos } from "@/pages/session/session-status-extractors" -import { todoPhase, todoSnapshot, type SessionTodoItem, type TodoSnapshot } from "./todo-model" +import { todoPhase, todoSnapshot, type SessionTodoItem, type TodoSnapshot, type TodoSourceKind } from "./todo-model" export type SessionTodoSource = { sessionID?: string @@ -25,37 +25,40 @@ const sameTodoList = (backend: SessionTodoItem[], parts: SessionTodoItem[]) => { }) } -const primaryTodoSnapshot = (input: SessionTodoSource): TodoSnapshot | undefined => { - const primaryParts = partTodos(input.parts) - const primaryBackend = input.backend ?? [] - - if (primaryParts.length > 0 && primaryBackend.length > 0) { - const partsPhase = todoPhase(primaryParts) - const backendPhase = todoPhase(primaryBackend) - if (backendPhase === "terminal" && partsPhase === "active" && sameTodoList(primaryBackend, primaryParts)) { +const sourceTodoSnapshot = ( + input: SessionTodoSource, + source: { backend: TodoSourceKind; parts: TodoSourceKind }, +): TodoSnapshot | undefined => { + const sourceParts = partTodos(input.parts) + const sourceBackend = input.backend ?? [] + + if (sourceParts.length > 0 && sourceBackend.length > 0) { + const partsPhase = todoPhase(sourceParts) + const backendPhase = todoPhase(sourceBackend) + if (backendPhase === "terminal" && partsPhase === "active" && sameTodoList(sourceBackend, sourceParts)) { return todoSnapshot({ sessionID: input.sessionID, - source: "primary-backend", - items: primaryBackend, + source: source.backend, + items: sourceBackend, dockEligible: false, historicalTerminal: true, }) } } - if (primaryParts.length > 0) { - const phase = todoPhase(primaryParts) + if (sourceParts.length > 0) { + const phase = todoPhase(sourceParts) return todoSnapshot({ sessionID: input.sessionID, - source: "primary-parts", - items: primaryParts, + source: source.parts, + items: sourceParts, dockEligible: phase === "active", historicalTerminal: phase === "terminal", }) } - if (primaryBackend.length > 0) { - return todoSnapshot({ sessionID: input.sessionID, source: "primary-backend", items: primaryBackend }) + if (sourceBackend.length > 0) { + return todoSnapshot({ sessionID: input.sessionID, source: source.backend, items: sourceBackend }) } } @@ -63,28 +66,13 @@ const primaryTodoSnapshot = (input: SessionTodoSource): TodoSnapshot | undefined // 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 primary = primaryTodoSnapshot(input.primary) + const primary = sourceTodoSnapshot(input.primary, { backend: "primary-backend", parts: "primary-parts" }) if (primary) return primary - 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, - }) - } + const fallback = input.fallback + ? sourceTodoSnapshot(input.fallback, { backend: "fallback-backend", parts: "fallback-parts" }) + : undefined + if (fallback) return fallback return todoSnapshot({ sessionID: input.primary.sessionID, source: "none", items: [] }) } @@ -93,28 +81,13 @@ export function selectSessionTodoDockSnapshot(input: SelectSessionTodosInput): T // 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 primary = primaryTodoSnapshot(input.primary) + const primary = sourceTodoSnapshot(input.primary, { backend: "primary-backend", parts: "primary-parts" }) if (primary) return primary - 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, - }) - } + const fallback = input.fallback + ? sourceTodoSnapshot(input.fallback, { backend: "fallback-backend", parts: "fallback-parts" }) + : undefined + if (fallback) return fallback return todoSnapshot({ sessionID: input.primary.sessionID, source: "none", items: [], dockEligible: false }) } From f57afbac4b34e00da16cf01bb58456513f485df6 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 15 May 2026 19:44:32 +0800 Subject: [PATCH 6/9] fix(todo): clear stale parts for empty backend updates --- .../e2e/session/session-composer-dock.spec.ts | 39 +++++++++++++++++ .../src/pages/session/session-todos.test.ts | 10 ++++- .../pages/session/todos/todo-source.test.ts | 43 ++++++++++++++++++- .../src/pages/session/todos/todo-source.ts | 4 ++ 4 files changed, 92 insertions(+), 4 deletions(-) diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index 07d5b8263..ea7f19ab8 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -1297,6 +1297,45 @@ test("todo dock and status summary use backend terminal update over stale todowr ) }) +test("todo dock and status summary clear when backend todo update is empty", async ({ page, llm, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock backend empty todo", + async (session) => { + const content = "backend cleared todo" + await project.gotoSession(session.id) + + await llm.tool("todowrite", { + todos: [{ content, status: "in_progress", priority: "medium" }], + }) + await llm.text("todo started") + await project.prompt("Create a todo and start it.") + + const dockItem = page.locator('[data-slot="session-todo-item"]').filter({ hasText: content }) + await expect(dockItem.first()).toHaveAttribute("data-state", "in_progress", { timeout: 30_000 }) + + await e2eUpdateTodos( + { url: project.url, directory: project.directory, sdk: project.sdk }, + { + sessionID: session.id, + todos: [], + }, + ) + + await expect(dockItem).toHaveCount(0, { timeout: 10_000 }) + + const rightPanel = page.locator("#right-panel") + if ((await rightPanel.getAttribute("aria-hidden")) !== "false") { + await page.locator(`${titlebarRightSelector} button`).first().click() + } + await expect(rightPanel).toHaveAttribute("aria-hidden", "false") + await expect(rightPanel.locator('[data-slot="status-summary-todo"]').filter({ hasText: content })).toHaveCount(0) + }, + { trackSession: project.trackSession }, + ) +}) + test("todo dock appears for the first todowrite in a fresh session", async ({ page, llm, project }) => { await project.open() diff --git a/packages/app/src/pages/session/session-todos.test.ts b/packages/app/src/pages/session/session-todos.test.ts index c60f5cc9c..e47a0a43f 100644 --- a/packages/app/src/pages/session/session-todos.test.ts +++ b/packages/app/src/pages/session/session-todos.test.ts @@ -71,13 +71,19 @@ describe("selectSessionTodos", () => { expect(selectSessionTodos({ backend: [], parts })).toEqual([todo("done from parts", "completed")]) }) - test("falls back to latest todowrite parts when backend todos are empty", () => { + test("falls back to latest todowrite parts when backend todos are unknown", () => { 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")]) + expect(selectSessionTodos({ backend: undefined, parts })).toEqual([todo("new", "in_progress")]) + }) + + test("returns empty when known backend todos clear stale active parts", () => { + const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("old", "in_progress")] } }))] + + expect(selectSessionTodos({ backend: [], parts })).toEqual([]) }) test("falls back to a secondary session source when the primary source is empty", () => { diff --git a/packages/app/src/pages/session/todos/todo-source.test.ts b/packages/app/src/pages/session/todos/todo-source.test.ts index 566ee326a..8e28534b9 100644 --- a/packages/app/src/pages/session/todos/todo-source.test.ts +++ b/packages/app/src/pages/session/todos/todo-source.test.ts @@ -124,6 +124,21 @@ describe("selectSessionTodoDockSnapshot", () => { }) }) + test("uses known empty backend over stale active parts", () => { + const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("cleared task", "in_progress")] } }))] + + expect( + selectSessionTodoDockSnapshot({ + primary: { backend: [], parts }, + }), + ).toMatchObject({ + source: "primary-backend", + items: [], + phase: "empty", + dockEligible: false, + }) + }) + test("does not reopen completed-only historical parts over an empty backend", () => { const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("done from parts", "completed")] } }))] @@ -144,7 +159,7 @@ describe("selectSessionTodoDockSnapshot", () => { expect( selectSessionTodoDockSnapshot({ primary: { backend: [], parts: [] }, - fallback: { backend: [], parts: fallbackParts }, + fallback: { parts: fallbackParts }, }), ).toMatchObject({ source: "fallback-parts", items: [todo("route todo", "in_progress")] }) }) @@ -177,6 +192,24 @@ describe("selectSessionTodoDockSnapshot", () => { }) }) + test("uses known empty fallback backend over stale fallback active parts", () => { + const fallbackParts = [ + toolPart("todowrite", completedState({ input: { todos: [todo("fallback cleared", "in_progress")] } })), + ] + + expect( + selectSessionTodoDockSnapshot({ + primary: { backend: [], parts: [] }, + fallback: { backend: [], parts: fallbackParts }, + }), + ).toMatchObject({ + source: "fallback-backend", + items: [], + phase: "empty", + dockEligible: false, + }) + }) + test("keeps primary terminal backend ahead of fallback active parts", () => { const fallbackParts = [ toolPart("todowrite", completedState({ input: { todos: [todo("fallback active", "in_progress")] } })), @@ -195,7 +228,7 @@ 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([ + expect(selectSessionTodos({ backend: undefined, parts })).toEqual([ todo("from parts", "in_progress"), ]) }) @@ -221,4 +254,10 @@ describe("selectSessionTodos", () => { }), ).toEqual([todo("fallback task", "completed")]) }) + + test("returns empty todos when known empty backend clears stale active parts", () => { + const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("cleared task", "in_progress")] } }))] + + expect(selectSessionTodos({ backend: [], parts })).toEqual([]) + }) }) diff --git a/packages/app/src/pages/session/todos/todo-source.ts b/packages/app/src/pages/session/todos/todo-source.ts index 6ad4a53cf..d09600ca9 100644 --- a/packages/app/src/pages/session/todos/todo-source.ts +++ b/packages/app/src/pages/session/todos/todo-source.ts @@ -30,6 +30,7 @@ const sourceTodoSnapshot = ( source: { backend: TodoSourceKind; parts: TodoSourceKind }, ): TodoSnapshot | undefined => { const sourceParts = partTodos(input.parts) + const backendKnown = input.backend !== undefined const sourceBackend = input.backend ?? [] if (sourceParts.length > 0 && sourceBackend.length > 0) { @@ -48,6 +49,9 @@ const sourceTodoSnapshot = ( if (sourceParts.length > 0) { const phase = todoPhase(sourceParts) + if (backendKnown && sourceBackend.length === 0 && phase === "active") { + return todoSnapshot({ sessionID: input.sessionID, source: source.backend, items: [], dockEligible: false }) + } return todoSnapshot({ sessionID: input.sessionID, source: source.parts, From f8dd28068119f29752f6b2bf8d2521e04e07b0f1 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 15 May 2026 20:22:19 +0800 Subject: [PATCH 7/9] fix(todo): gate empty backend clears on live updates --- .../session/session-status-panel.tsx | 5 +- .../session/session-status-summary.tsx | 14 ++++- packages/app/src/context/global-sync.tsx | 26 ++++++++- .../app/src/context/global-sync/bootstrap.ts | 3 ++ .../context/global-sync/event-reducer.test.ts | 54 +++++++++++++++++-- .../src/context/global-sync/event-reducer.ts | 25 ++++++--- .../src/pages/session/session-todos.test.ts | 8 ++- .../pages/session/todos/todo-source.test.ts | 45 ++++++++++++++-- .../src/pages/session/todos/todo-source.ts | 4 +- .../pages/session/todos/use-session-todos.ts | 14 ++++- 10 files changed, 176 insertions(+), 22 deletions(-) diff --git a/packages/app/src/components/session/session-status-panel.tsx b/packages/app/src/components/session/session-status-panel.tsx index 7c01b9e59..f44e6272a 100644 --- a/packages/app/src/components/session/session-status-panel.tsx +++ b/packages/app/src/components/session/session-status-panel.tsx @@ -17,10 +17,13 @@ export function SessionStatusPanel(props: { shown: Accessor }) { return messages.flatMap((message) => sync.data.part[message.id] ?? []) }) const backend = createMemo(() => (params.id ? globalSync.data.session_todo[params.id] : undefined)) + const backendClearActiveParts = createMemo(() => + params.id ? globalSync.data.session_todo_clear[params.id] === true : false, + ) return (
- +
) diff --git a/packages/app/src/components/session/session-status-summary.tsx b/packages/app/src/components/session/session-status-summary.tsx index c179cfe5d..34c72c4fb 100644 --- a/packages/app/src/components/session/session-status-summary.tsx +++ b/packages/app/src/components/session/session-status-summary.tsx @@ -43,9 +43,19 @@ function SourceRow(props: { url: string }) { ) } -export function SessionStatusSummary(props: { backend?: Accessor; parts: Accessor }) { +export function SessionStatusSummary(props: { + backend?: Accessor + backendClearActiveParts?: Accessor + parts: Accessor +}) { const language = useLanguage() - const todos = createMemo(() => selectSessionTodos({ backend: props.backend?.(), parts: props.parts() })) + const todos = createMemo(() => + selectSessionTodos({ + backend: props.backend?.(), + backendClearActiveParts: props.backendClearActiveParts?.() === true, + parts: props.parts(), + }), + ) const sources = createMemo(() => extractSources(props.parts())) return ( diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 41867d611..9c01b66c4 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -42,6 +42,9 @@ type GlobalStore = { session_todo: { [sessionID: string]: Todo[] } + session_todo_clear: { + [sessionID: string]: boolean + } provider: ProviderListResponse provider_auth: ProviderAuthResponse config: Config @@ -75,6 +78,7 @@ function createGlobalSync() { path: { state: "", config: "", worktree: "", directory: "", home: "" }, project: projectCache.value, session_todo: {}, + session_todo_clear: {}, provider: { all: [], connected: [], default: {} }, provider_auth: {}, config: {}, @@ -141,7 +145,11 @@ function createGlobalSync() { }) } - const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => { + const setSessionTodo = ( + sessionID: string, + todos: Todo[] | undefined, + options?: { clearActiveParts?: boolean }, + ) => { if (!sessionID) return if (!todos) { setGlobalStore( @@ -150,9 +158,25 @@ function createGlobalSync() { delete draft[sessionID] }), ) + setGlobalStore( + "session_todo_clear", + produce((draft) => { + delete draft[sessionID] + }), + ) return } setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" })) + if (todos.length === 0 && options?.clearActiveParts === true) { + setGlobalStore("session_todo_clear", sessionID, true) + return + } + setGlobalStore( + "session_todo_clear", + produce((draft) => { + delete draft[sessionID] + }), + ) } const paused = () => untrack(() => globalStore.reload) !== undefined diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index ce7f16d66..cbcfe6250 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -28,6 +28,9 @@ type GlobalStore = { session_todo: { [sessionID: string]: Todo[] } + session_todo_clear: { + [sessionID: string]: boolean + } provider: ProviderListResponse provider_auth: ProviderAuthResponse config: Config diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index be6749a8e..dcdccdff7 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -140,17 +140,63 @@ describe("applyGlobalEvent", () => { describe("applyDirectoryEvent", () => { test("caches detached todo updates before a directory child store exists", () => { const todos: Todo[] = [{ id: "todo_1", content: "fresh todo", status: "in_progress", priority: "high" } as Todo] - const writes: Array<{ sessionID: string; todos: Todo[] | undefined }> = [] + const writes: Array<{ + sessionID: string + todos: Todo[] | undefined + options?: { clearActiveParts?: boolean } + }> = [] const handled = applyDetachedDirectoryEvent({ event: { type: "todo.updated", properties: { sessionID: "ses_fresh", todos } }, - setSessionTodo(sessionID, value) { - writes.push({ sessionID, todos: value }) + setSessionTodo(sessionID, value, options) { + writes.push({ sessionID, todos: value, options }) + }, + }) + + expect(handled).toBe(true) + expect(writes).toEqual([{ sessionID: "ses_fresh", todos, options: undefined }]) + }) + + test("marks detached empty todo updates as active-parts clears", () => { + const writes: Array<{ + sessionID: string + todos: Todo[] | undefined + options?: { clearActiveParts?: boolean } + }> = [] + + const handled = applyDetachedDirectoryEvent({ + event: { type: "todo.updated", properties: { sessionID: "ses_clear", todos: [] } }, + setSessionTodo(sessionID, value, options) { + writes.push({ sessionID, todos: value, options }) }, }) expect(handled).toBe(true) - expect(writes).toEqual([{ sessionID: "ses_fresh", todos }]) + expect(writes).toEqual([{ sessionID: "ses_clear", todos: [], options: { clearActiveParts: true } }]) + }) + + test("marks directory empty todo updates as active-parts clears", () => { + const [store, setStore] = createStore(baseState()) + const writes: Array<{ + sessionID: string + todos: Todo[] | undefined + options?: { clearActiveParts?: boolean } + }> = [] + + applyDirectoryEvent({ + event: { type: "todo.updated", properties: { sessionID: "ses_clear", todos: [] } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + setSessionTodo(sessionID, value, options) { + writes.push({ sessionID, todos: value, options }) + }, + }) + + expect(store.todo.ses_clear).toEqual([]) + expect(writes).toEqual([{ sessionID: "ses_clear", todos: [], options: { clearActiveParts: true } }]) }) test("ignores detached events that need a directory child store", () => { diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 955304f46..a13888d73 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -18,6 +18,11 @@ import { diffs as list, message as clean } from "@/utils/diffs" import type { createBlockerTerminalCache } from "./blocker-terminal-cache" const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) +type SetSessionTodo = ( + sessionID: string, + todos: Todo[] | undefined, + options?: { clearActiveParts?: boolean }, +) => void export function applyGlobalEvent(input: { event: { type: string; properties?: unknown } @@ -47,7 +52,7 @@ export function applyGlobalEvent(input: { function cleanupSessionCaches( setStore: SetStoreFunction, sessionID: string, - setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void, + setSessionTodo?: SetSessionTodo, ) { if (!sessionID) return setSessionTodo?.(sessionID, undefined) @@ -62,7 +67,7 @@ export function cleanupDroppedSessionCaches( store: Store, setStore: SetStoreFunction, next: Session[], - setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void, + setSessionTodo?: SetSessionTodo, ) { const keep = new Set(next.map((item) => item.id)) const stale = [ @@ -90,14 +95,18 @@ export function cleanupDroppedSessionCaches( export function applyDetachedDirectoryEvent(input: { event: { type: string; properties?: unknown } - setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void + setSessionTodo?: SetSessionTodo }) { if (!input.event.properties || typeof input.event.properties !== "object") return false switch (input.event.type) { case "todo.updated": { const props = input.event.properties as { sessionID?: string; todos?: Todo[] } if (!props.sessionID || !Array.isArray(props.todos)) return false - input.setSessionTodo?.(props.sessionID, props.todos) + input.setSessionTodo?.( + props.sessionID, + props.todos, + props.todos.length === 0 ? { clearActiveParts: true } : undefined, + ) return true } case "session.deleted": { @@ -125,7 +134,7 @@ export function applyDirectoryEvent(input: { directory: string loadLsp: () => void vcsCache?: VcsCache - setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void + setSessionTodo?: SetSessionTodo blockerTerminals?: ReturnType }) { const event = input.event @@ -197,7 +206,11 @@ export function applyDirectoryEvent(input: { case "todo.updated": { const props = event.properties as { sessionID: string; todos: Todo[] } input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" })) - input.setSessionTodo?.(props.sessionID, props.todos) + input.setSessionTodo?.( + props.sessionID, + props.todos, + props.todos.length === 0 ? { clearActiveParts: true } : undefined, + ) break } case "session.status": { diff --git a/packages/app/src/pages/session/session-todos.test.ts b/packages/app/src/pages/session/session-todos.test.ts index e47a0a43f..743f7f477 100644 --- a/packages/app/src/pages/session/session-todos.test.ts +++ b/packages/app/src/pages/session/session-todos.test.ts @@ -83,7 +83,13 @@ describe("selectSessionTodos", () => { test("returns empty when known backend todos clear stale active parts", () => { const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("old", "in_progress")] } }))] - expect(selectSessionTodos({ backend: [], parts })).toEqual([]) + expect(selectSessionTodos({ backend: [], backendClearActiveParts: true, parts })).toEqual([]) + }) + + test("keeps active parts over ordinary empty backend cache", () => { + const parts = [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", () => { diff --git a/packages/app/src/pages/session/todos/todo-source.test.ts b/packages/app/src/pages/session/todos/todo-source.test.ts index 8e28534b9..ca23649c7 100644 --- a/packages/app/src/pages/session/todos/todo-source.test.ts +++ b/packages/app/src/pages/session/todos/todo-source.test.ts @@ -129,7 +129,7 @@ describe("selectSessionTodoDockSnapshot", () => { expect( selectSessionTodoDockSnapshot({ - primary: { backend: [], parts }, + primary: { backend: [], backendClearActiveParts: true, parts }, }), ).toMatchObject({ source: "primary-backend", @@ -139,6 +139,21 @@ describe("selectSessionTodoDockSnapshot", () => { }) }) + test("keeps active parts over ordinary empty backend cache", () => { + const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("new task", "in_progress")] } }))] + + expect( + selectSessionTodoDockSnapshot({ + primary: { backend: [], parts }, + }), + ).toMatchObject({ + source: "primary-parts", + items: [todo("new task", "in_progress")], + phase: "active", + dockEligible: true, + }) + }) + test("does not reopen completed-only historical parts over an empty backend", () => { const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("done from parts", "completed")] } }))] @@ -200,7 +215,7 @@ describe("selectSessionTodoDockSnapshot", () => { expect( selectSessionTodoDockSnapshot({ primary: { backend: [], parts: [] }, - fallback: { backend: [], parts: fallbackParts }, + fallback: { backend: [], backendClearActiveParts: true, parts: fallbackParts }, }), ).toMatchObject({ source: "fallback-backend", @@ -210,6 +225,24 @@ describe("selectSessionTodoDockSnapshot", () => { }) }) + test("keeps fallback active parts over ordinary empty fallback backend cache", () => { + const fallbackParts = [ + toolPart("todowrite", completedState({ input: { todos: [todo("fallback active", "in_progress")] } })), + ] + + expect( + selectSessionTodoDockSnapshot({ + primary: { backend: [], parts: [] }, + fallback: { backend: [], parts: fallbackParts }, + }), + ).toMatchObject({ + source: "fallback-parts", + items: [todo("fallback active", "in_progress")], + phase: "active", + dockEligible: true, + }) + }) + test("keeps primary terminal backend ahead of fallback active parts", () => { const fallbackParts = [ toolPart("todowrite", completedState({ input: { todos: [todo("fallback active", "in_progress")] } })), @@ -258,6 +291,12 @@ describe("selectSessionTodos", () => { test("returns empty todos when known empty backend clears stale active parts", () => { const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("cleared task", "in_progress")] } }))] - expect(selectSessionTodos({ backend: [], parts })).toEqual([]) + expect(selectSessionTodos({ backend: [], backendClearActiveParts: true, parts })).toEqual([]) + }) + + test("returns active parts when ordinary empty backend cache is older", () => { + const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("new task", "in_progress")] } }))] + + expect(selectSessionTodos({ backend: [], parts })).toEqual([todo("new task", "in_progress")]) }) }) diff --git a/packages/app/src/pages/session/todos/todo-source.ts b/packages/app/src/pages/session/todos/todo-source.ts index d09600ca9..c951e47c3 100644 --- a/packages/app/src/pages/session/todos/todo-source.ts +++ b/packages/app/src/pages/session/todos/todo-source.ts @@ -5,6 +5,7 @@ import { todoPhase, todoSnapshot, type SessionTodoItem, type TodoSnapshot, type export type SessionTodoSource = { sessionID?: string backend?: Todo[] + backendClearActiveParts?: boolean parts: Part[] } @@ -30,7 +31,6 @@ const sourceTodoSnapshot = ( source: { backend: TodoSourceKind; parts: TodoSourceKind }, ): TodoSnapshot | undefined => { const sourceParts = partTodos(input.parts) - const backendKnown = input.backend !== undefined const sourceBackend = input.backend ?? [] if (sourceParts.length > 0 && sourceBackend.length > 0) { @@ -49,7 +49,7 @@ const sourceTodoSnapshot = ( if (sourceParts.length > 0) { const phase = todoPhase(sourceParts) - if (backendKnown && sourceBackend.length === 0 && phase === "active") { + if (input.backendClearActiveParts === true && sourceBackend.length === 0 && phase === "active") { return todoSnapshot({ sessionID: input.sessionID, source: source.backend, items: [], dockEligible: false }) } return todoSnapshot({ diff --git a/packages/app/src/pages/session/todos/use-session-todos.ts b/packages/app/src/pages/session/todos/use-session-todos.ts index 111f6f644..30edd3d92 100644 --- a/packages/app/src/pages/session/todos/use-session-todos.ts +++ b/packages/app/src/pages/session/todos/use-session-todos.ts @@ -82,9 +82,19 @@ export function createSessionTodoModel(input: { const fallbackParts = fallbackMessages.flatMap((message) => sync.data.part[message.id] ?? []) return selectSessionTodoDockSnapshot({ - primary: { sessionID: id, backend: globalSync.data.session_todo[id], parts }, + primary: { + sessionID: id, + backend: globalSync.data.session_todo[id], + backendClearActiveParts: globalSync.data.session_todo_clear[id] === true, + parts, + }, fallback: fallbackID - ? { sessionID: fallbackID, backend: globalSync.data.session_todo[fallbackID], parts: fallbackParts } + ? { + sessionID: fallbackID, + backend: globalSync.data.session_todo[fallbackID], + backendClearActiveParts: globalSync.data.session_todo_clear[fallbackID] === true, + parts: fallbackParts, + } : undefined, }) }) From eca946557c8922e5f98b4087c8c5d8dfda4c22b3 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 15 May 2026 21:00:44 +0800 Subject: [PATCH 8/9] fix(todo): preserve live clear state across refresh --- packages/app/src/context/global-sync.test.ts | 23 ++++++++++ packages/app/src/context/global-sync.tsx | 14 +++++- .../opencode/src/server/instance/session.ts | 6 ++- .../test/server/session-e2e-routes.test.ts | 46 +++++++++++++++++++ 4 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/test/server/session-e2e-routes.test.ts diff --git a/packages/app/src/context/global-sync.test.ts b/packages/app/src/context/global-sync.test.ts index 392f0a561..e4da5a2bd 100644 --- a/packages/app/src/context/global-sync.test.ts +++ b/packages/app/src/context/global-sync.test.ts @@ -1,7 +1,30 @@ import { describe, expect, test } from "bun:test" +import type { Todo } from "@opencode-ai/sdk/v2/client" +import { nextSessionTodoClearFlag } from "./global-sync" import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction" import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" +describe("nextSessionTodoClearFlag", () => { + const todo = { id: "todo_1", content: "work", status: "in_progress", priority: "medium" } as Todo + + test("marks live empty backend updates as active-parts clears", () => { + expect(nextSessionTodoClearFlag(undefined, [], { clearActiveParts: true })).toBe(true) + }) + + test("preserves existing live clear flag across ordinary empty backend refreshes", () => { + expect(nextSessionTodoClearFlag(true, [])).toBe(true) + }) + + test("does not create a clear flag for ordinary empty backend refreshes", () => { + expect(nextSessionTodoClearFlag(undefined, [])).toBeUndefined() + }) + + test("clears the flag on non-empty backend updates and cleanup", () => { + expect(nextSessionTodoClearFlag(true, [todo])).toBeUndefined() + expect(nextSessionTodoClearFlag(true, undefined)).toBeUndefined() + }) +}) + describe("pickDirectoriesToEvict", () => { test("keeps pinned stores and evicts idle stores", () => { const now = 5_000 diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 9c01b66c4..72a493393 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -53,6 +53,17 @@ type GlobalStore = { const inactiveQueryFn = async () => null +export function nextSessionTodoClearFlag( + previous: boolean | undefined, + todos: Todo[] | undefined, + options?: { clearActiveParts?: boolean }, +) { + if (!todos) return undefined + if (todos.length > 0) return undefined + if (options?.clearActiveParts === true) return true + return previous +} + export const loadSessionsQuery = (directory: string) => queryOptions({ queryKey: [directory, "loadSessions"], queryFn: inactiveQueryFn, enabled: false }) @@ -167,7 +178,8 @@ function createGlobalSync() { return } setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" })) - if (todos.length === 0 && options?.clearActiveParts === true) { + const clearFlag = nextSessionTodoClearFlag(globalStore.session_todo_clear[sessionID], todos, options) + if (clearFlag === true) { setGlobalStore("session_todo_clear", sessionID, true) return } diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts index 50f9d2dbe..a2ed737ea 100644 --- a/packages/opencode/src/server/instance/session.ts +++ b/packages/opencode/src/server/instance/session.ts @@ -140,6 +140,10 @@ export const SessionRoutes = lazy(() => ) .post( "/__e2e/update-todos", + async (c, next) => { + if (!e2eSessionRoutesEnabled()) return c.notFound() + await next() + }, validator( "json", z.object({ @@ -148,8 +152,6 @@ export const SessionRoutes = lazy(() => }), ), async (c) => { - if (!e2eSessionRoutesEnabled()) return c.notFound() - const json = c.req.valid("json") await AppRuntime.runPromise( Todo.Service.use((svc) => diff --git a/packages/opencode/test/server/session-e2e-routes.test.ts b/packages/opencode/test/server/session-e2e-routes.test.ts new file mode 100644 index 000000000..8b2188f99 --- /dev/null +++ b/packages/opencode/test/server/session-e2e-routes.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { SessionRoutes } from "../../src/server/instance/session" +import { tmpdir } from "../fixture/fixture" + +const originalE2EEnabled = process.env.OPENCODE_E2E_ENABLED +const originalE2ELlmURL = process.env.OPENCODE_E2E_LLM_URL + +afterEach(() => { + if (originalE2EEnabled === undefined) delete process.env.OPENCODE_E2E_ENABLED + else process.env.OPENCODE_E2E_ENABLED = originalE2EEnabled + if (originalE2ELlmURL === undefined) delete process.env.OPENCODE_E2E_LLM_URL + else process.env.OPENCODE_E2E_LLM_URL = originalE2ELlmURL + return Instance.disposeAll() +}) + +describe("session e2e routes", () => { + test("disabled update-todos route returns 404 before json validation", async () => { + delete process.env.OPENCODE_E2E_ENABLED + delete process.env.OPENCODE_E2E_LLM_URL + + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const app = SessionRoutes() + const malformed = await app.request("/__e2e/update-todos", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{", + }) + const valid = await app.request("/__e2e/update-todos", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionID: "ses_disabled", + todos: [], + }), + }) + + expect(malformed.status).toBe(404) + expect(valid.status).toBe(404) + }, + }) + }) +}) From dcd732a889ee45cb5c1d3cb833e3ac897fd6ee65 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 15 May 2026 21:19:21 +0800 Subject: [PATCH 9/9] fix(todo): let newer parts beat stale clear state --- .../session/session-status-panel.tsx | 6 +-- .../session/session-status-summary.tsx | 4 +- packages/app/src/context/global-sync.test.ts | 8 ++-- packages/app/src/context/global-sync.tsx | 11 +++--- .../app/src/context/global-sync/bootstrap.ts | 2 +- .../src/pages/session/session-todos.test.ts | 15 ++++++- .../pages/session/todos/todo-source.test.ts | 39 +++++++++++++++++-- .../src/pages/session/todos/todo-source.ts | 25 ++++++++++-- .../pages/session/todos/use-session-todos.ts | 4 +- 9 files changed, 88 insertions(+), 26 deletions(-) diff --git a/packages/app/src/components/session/session-status-panel.tsx b/packages/app/src/components/session/session-status-panel.tsx index f44e6272a..7bf4c25c7 100644 --- a/packages/app/src/components/session/session-status-panel.tsx +++ b/packages/app/src/components/session/session-status-panel.tsx @@ -17,13 +17,11 @@ export function SessionStatusPanel(props: { shown: Accessor }) { return messages.flatMap((message) => sync.data.part[message.id] ?? []) }) const backend = createMemo(() => (params.id ? globalSync.data.session_todo[params.id] : undefined)) - const backendClearActiveParts = createMemo(() => - params.id ? globalSync.data.session_todo_clear[params.id] === true : false, - ) + const backendClearActivePartsAt = createMemo(() => (params.id ? globalSync.data.session_todo_clear[params.id] : undefined)) return (
- +
) diff --git a/packages/app/src/components/session/session-status-summary.tsx b/packages/app/src/components/session/session-status-summary.tsx index 34c72c4fb..6badda030 100644 --- a/packages/app/src/components/session/session-status-summary.tsx +++ b/packages/app/src/components/session/session-status-summary.tsx @@ -45,14 +45,14 @@ function SourceRow(props: { url: string }) { export function SessionStatusSummary(props: { backend?: Accessor - backendClearActiveParts?: Accessor + backendClearActivePartsAt?: Accessor parts: Accessor }) { const language = useLanguage() const todos = createMemo(() => selectSessionTodos({ backend: props.backend?.(), - backendClearActiveParts: props.backendClearActiveParts?.() === true, + backendClearActivePartsAt: props.backendClearActivePartsAt?.(), parts: props.parts(), }), ) diff --git a/packages/app/src/context/global-sync.test.ts b/packages/app/src/context/global-sync.test.ts index e4da5a2bd..f1382685d 100644 --- a/packages/app/src/context/global-sync.test.ts +++ b/packages/app/src/context/global-sync.test.ts @@ -8,11 +8,11 @@ describe("nextSessionTodoClearFlag", () => { const todo = { id: "todo_1", content: "work", status: "in_progress", priority: "medium" } as Todo test("marks live empty backend updates as active-parts clears", () => { - expect(nextSessionTodoClearFlag(undefined, [], { clearActiveParts: true })).toBe(true) + expect(nextSessionTodoClearFlag(undefined, [], { clearActiveParts: true }, 10)).toBe(10) }) test("preserves existing live clear flag across ordinary empty backend refreshes", () => { - expect(nextSessionTodoClearFlag(true, [])).toBe(true) + expect(nextSessionTodoClearFlag(10, [])).toBe(10) }) test("does not create a clear flag for ordinary empty backend refreshes", () => { @@ -20,8 +20,8 @@ describe("nextSessionTodoClearFlag", () => { }) test("clears the flag on non-empty backend updates and cleanup", () => { - expect(nextSessionTodoClearFlag(true, [todo])).toBeUndefined() - expect(nextSessionTodoClearFlag(true, undefined)).toBeUndefined() + expect(nextSessionTodoClearFlag(10, [todo])).toBeUndefined() + expect(nextSessionTodoClearFlag(10, undefined)).toBeUndefined() }) }) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 72a493393..265b0f682 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -43,7 +43,7 @@ type GlobalStore = { [sessionID: string]: Todo[] } session_todo_clear: { - [sessionID: string]: boolean + [sessionID: string]: number } provider: ProviderListResponse provider_auth: ProviderAuthResponse @@ -54,13 +54,14 @@ type GlobalStore = { const inactiveQueryFn = async () => null export function nextSessionTodoClearFlag( - previous: boolean | undefined, + previous: number | undefined, todos: Todo[] | undefined, options?: { clearActiveParts?: boolean }, + now = Date.now(), ) { if (!todos) return undefined if (todos.length > 0) return undefined - if (options?.clearActiveParts === true) return true + if (options?.clearActiveParts === true) return now return previous } @@ -179,8 +180,8 @@ function createGlobalSync() { } setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" })) const clearFlag = nextSessionTodoClearFlag(globalStore.session_todo_clear[sessionID], todos, options) - if (clearFlag === true) { - setGlobalStore("session_todo_clear", sessionID, true) + if (clearFlag !== undefined) { + setGlobalStore("session_todo_clear", sessionID, clearFlag) return } setGlobalStore( diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index cbcfe6250..4f1ce4686 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -29,7 +29,7 @@ type GlobalStore = { [sessionID: string]: Todo[] } session_todo_clear: { - [sessionID: string]: boolean + [sessionID: string]: number } provider: ProviderListResponse provider_auth: ProviderAuthResponse diff --git a/packages/app/src/pages/session/session-todos.test.ts b/packages/app/src/pages/session/session-todos.test.ts index 743f7f477..e22ff55d0 100644 --- a/packages/app/src/pages/session/session-todos.test.ts +++ b/packages/app/src/pages/session/session-todos.test.ts @@ -83,7 +83,20 @@ describe("selectSessionTodos", () => { test("returns empty when known backend todos clear stale active parts", () => { const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("old", "in_progress")] } }))] - expect(selectSessionTodos({ backend: [], backendClearActiveParts: true, parts })).toEqual([]) + expect(selectSessionTodos({ backend: [], backendClearActivePartsAt: 1, parts })).toEqual([]) + }) + + test("keeps active parts created after a live empty backend clear", () => { + const parts = [ + toolPart( + "todowrite", + completedState({ input: { todos: [todo("new", "in_progress")] }, time: { start: 2, end: 2 } }), + ), + ] + + expect(selectSessionTodos({ backend: [], backendClearActivePartsAt: 1, parts })).toEqual([ + todo("new", "in_progress"), + ]) }) test("keeps active parts over ordinary empty backend cache", () => { diff --git a/packages/app/src/pages/session/todos/todo-source.test.ts b/packages/app/src/pages/session/todos/todo-source.test.ts index ca23649c7..53b4df2aa 100644 --- a/packages/app/src/pages/session/todos/todo-source.test.ts +++ b/packages/app/src/pages/session/todos/todo-source.test.ts @@ -129,7 +129,7 @@ describe("selectSessionTodoDockSnapshot", () => { expect( selectSessionTodoDockSnapshot({ - primary: { backend: [], backendClearActiveParts: true, parts }, + primary: { backend: [], backendClearActivePartsAt: 1, parts }, }), ).toMatchObject({ source: "primary-backend", @@ -139,6 +139,26 @@ describe("selectSessionTodoDockSnapshot", () => { }) }) + test("keeps active parts created after a live empty backend clear", () => { + const parts = [ + toolPart( + "todowrite", + completedState({ input: { todos: [todo("new task", "in_progress")] }, time: { start: 2, end: 2 } }), + ), + ] + + expect( + selectSessionTodoDockSnapshot({ + primary: { backend: [], backendClearActivePartsAt: 1, parts }, + }), + ).toMatchObject({ + source: "primary-parts", + items: [todo("new task", "in_progress")], + phase: "active", + dockEligible: true, + }) + }) + test("keeps active parts over ordinary empty backend cache", () => { const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("new task", "in_progress")] } }))] @@ -215,7 +235,7 @@ describe("selectSessionTodoDockSnapshot", () => { expect( selectSessionTodoDockSnapshot({ primary: { backend: [], parts: [] }, - fallback: { backend: [], backendClearActiveParts: true, parts: fallbackParts }, + fallback: { backend: [], backendClearActivePartsAt: 1, parts: fallbackParts }, }), ).toMatchObject({ source: "fallback-backend", @@ -291,7 +311,20 @@ describe("selectSessionTodos", () => { test("returns empty todos when known empty backend clears stale active parts", () => { const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("cleared task", "in_progress")] } }))] - expect(selectSessionTodos({ backend: [], backendClearActiveParts: true, parts })).toEqual([]) + expect(selectSessionTodos({ backend: [], backendClearActivePartsAt: 1, parts })).toEqual([]) + }) + + test("returns active parts created after a live empty backend clear", () => { + const parts = [ + toolPart( + "todowrite", + completedState({ input: { todos: [todo("new task", "in_progress")] }, time: { start: 2, end: 2 } }), + ), + ] + + expect(selectSessionTodos({ backend: [], backendClearActivePartsAt: 1, parts })).toEqual([ + todo("new task", "in_progress"), + ]) }) test("returns active parts when ordinary empty backend cache is older", () => { diff --git a/packages/app/src/pages/session/todos/todo-source.ts b/packages/app/src/pages/session/todos/todo-source.ts index c951e47c3..9e8db55e5 100644 --- a/packages/app/src/pages/session/todos/todo-source.ts +++ b/packages/app/src/pages/session/todos/todo-source.ts @@ -1,11 +1,11 @@ import type { Part, Todo } from "@opencode-ai/sdk/v2" -import { extractTodos } from "@/pages/session/session-status-extractors" +import { extractTodos, TOOL_TODOWRITE } from "@/pages/session/session-status-extractors" import { todoPhase, todoSnapshot, type SessionTodoItem, type TodoSnapshot, type TodoSourceKind } from "./todo-model" export type SessionTodoSource = { sessionID?: string backend?: Todo[] - backendClearActiveParts?: boolean + backendClearActivePartsAt?: number parts: Part[] } @@ -16,6 +16,20 @@ export type SelectSessionTodosInput = { const partTodos = (parts: Part[]) => extractTodos(parts) +const latestTodoWriteTime = (parts: Part[]) => { + let latest: number | undefined + for (const part of parts) { + if (part.type !== "tool") continue + if (part.tool !== TOOL_TODOWRITE) continue + if (part.state.status !== "completed") continue + const time = part.state.time + const value = typeof time.end === "number" ? time.end : typeof time.start === "number" ? time.start : undefined + if (value === undefined) continue + latest = latest === undefined ? value : Math.max(latest, value) + } + return latest +} + const sameTodoList = (backend: SessionTodoItem[], parts: SessionTodoItem[]) => { if (backend.length !== parts.length) return false return parts.every((part, index) => { @@ -49,8 +63,11 @@ const sourceTodoSnapshot = ( if (sourceParts.length > 0) { const phase = todoPhase(sourceParts) - if (input.backendClearActiveParts === true && sourceBackend.length === 0 && phase === "active") { - return todoSnapshot({ sessionID: input.sessionID, source: source.backend, items: [], dockEligible: false }) + if (input.backendClearActivePartsAt !== undefined && sourceBackend.length === 0 && phase === "active") { + const partsTime = latestTodoWriteTime(input.parts) + if (partsTime === undefined || partsTime <= input.backendClearActivePartsAt) { + return todoSnapshot({ sessionID: input.sessionID, source: source.backend, items: [], dockEligible: false }) + } } return todoSnapshot({ sessionID: input.sessionID, diff --git a/packages/app/src/pages/session/todos/use-session-todos.ts b/packages/app/src/pages/session/todos/use-session-todos.ts index 30edd3d92..d5ab60a7d 100644 --- a/packages/app/src/pages/session/todos/use-session-todos.ts +++ b/packages/app/src/pages/session/todos/use-session-todos.ts @@ -85,14 +85,14 @@ export function createSessionTodoModel(input: { primary: { sessionID: id, backend: globalSync.data.session_todo[id], - backendClearActiveParts: globalSync.data.session_todo_clear[id] === true, + backendClearActivePartsAt: globalSync.data.session_todo_clear[id], parts, }, fallback: fallbackID ? { sessionID: fallbackID, backend: globalSync.data.session_todo[fallbackID], - backendClearActiveParts: globalSync.data.session_todo_clear[fallbackID] === true, + backendClearActivePartsAt: globalSync.data.session_todo_clear[fallbackID], parts: fallbackParts, } : undefined,