From c3e3028ce9161559de1a96ff62ce138e950d6afe Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 6 May 2026 23:40:56 +0800 Subject: [PATCH 1/2] fix(app): cache detached todo updates --- .../e2e/session/session-composer-dock.spec.ts | 37 +++++++++++++++++ packages/app/src/context/global-sync.tsx | 12 +++++- .../context/global-sync/event-reducer.test.ts | 41 ++++++++++++++++++- .../src/context/global-sync/event-reducer.ts | 12 ++++++ 4 files changed, 98 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 a3275f70b..86b53be0b 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -213,6 +213,17 @@ async function submitVisiblePrompt(page: Page, text: string) { await page.keyboard.press("Enter") } +async function readPromptSend(page: Page) { + return page.evaluate(() => { + const win = window as ComposerWindow + const sent = win.__opencode_e2e?.prompt?.sent + return { + count: sent?.count ?? 0, + sessionID: sent?.sessionID, + } + }) +} + async function scrollTimelineToBottom(page: Page) { await page.evaluate(() => { const viewport = document.querySelector('[data-component="scroll-viewport"]') @@ -1070,6 +1081,32 @@ test("todo dock appears from real todowrite tool parts", async ({ page, llm, pro ) }) +test("todo dock appears for the first todowrite in a fresh session", async ({ page, llm, project }) => { + await project.open() + + await llm.tool("todowrite", { + todos: [ + { content: "fresh first todo", status: "in_progress", priority: "high" }, + { content: "fresh follow-up todo", status: "pending", priority: "medium" }, + ], + }) + await llm.text("fresh todo started") + + const before = await readPromptSend(page) + await submitVisiblePrompt(page, "Create todos in a fresh session.") + const sent = await expect + .poll(async () => readPromptSend(page), { timeout: 30_000 }) + .toMatchObject({ count: before.count + 1 }) + .then(() => readPromptSend(page)) + if (!sent.sessionID) throw new Error("Prompt submission did not expose a session id") + + const dock = page.locator('[data-component="session-todo-dock"]') + + await expect(dock).toHaveCount(1, { timeout: 30_000 }) + await expect(dock).toContainText("fresh first todo", { timeout: 30_000 }) + project.trackSession(sent.sessionID) +}) + test("todo dock recovers after missed todowrite via SSE replay", async ({ page, llm, project }) => { await project.open() await withDockSession( diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 4751d0688..3d6599094 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -18,7 +18,12 @@ import { useGlobalSDK } from "./global-sdk" import { bootstrapDirectory, bootstrapGlobal, clearProviderRev } from "./global-sync/bootstrap" import { createBlockerTerminalCache } from "./global-sync/blocker-terminal-cache" import { createChildStoreManager } from "./global-sync/child-store" -import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer" +import { + applyDetachedDirectoryEvent, + applyDirectoryEvent, + applyGlobalEvent, + cleanupDroppedSessionCaches, +} from "./global-sync/event-reducer" import { createRefreshQueue } from "./global-sync/queue" import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch" import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" @@ -323,7 +328,10 @@ function createGlobalSync() { } const existing = children.children[directory] - if (!existing) return + if (!existing) { + applyDetachedDirectoryEvent({ event, setSessionTodo }) + return + } children.mark(directory) const [store, setStore] = existing applyDirectoryEvent({ 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 f7f52fcc7..642830d1e 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test" -import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" +import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session, Todo } from "@opencode-ai/sdk/v2/client" import { createStore } from "solid-js/store" import type { State } from "./types" import { createBlockerTerminalCache } from "./blocker-terminal-cache" -import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer" +import { applyDetachedDirectoryEvent, applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer" const rootSession = (input: { id: string; parentID?: string; archived?: number; created?: number; updated?: number }) => ({ @@ -137,6 +137,43 @@ 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 handled = applyDetachedDirectoryEvent({ + event: { type: "todo.updated", properties: { sessionID: "ses_fresh", todos } }, + setSessionTodo(sessionID, value) { + writes.push({ sessionID, todos: value }) + }, + }) + + expect(handled).toBe(true) + expect(writes).toEqual([{ sessionID: "ses_fresh", todos }]) + }) + + test("ignores detached events that need a directory child store", () => { + const handled = applyDetachedDirectoryEvent({ + event: { type: "message.updated", properties: { info: userMessage("msg_1", "ses_1") } }, + setSessionTodo() { + throw new Error("should not write detached todo cache") + }, + }) + + expect(handled).toBe(false) + }) + + test("ignores malformed detached todo updates", () => { + const handled = applyDetachedDirectoryEvent({ + event: { type: "todo.updated" }, + setSessionTodo() { + throw new Error("should not write detached todo cache") + }, + }) + + expect(handled).toBe(false) + }) + test("inserts root sessions in sorted order and updates sessionTotal", () => { const [store, setStore] = createStore( baseState({ diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 791c192c4..5c74f4d51 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -88,6 +88,18 @@ export function cleanupDroppedSessionCaches( ) } +export function applyDetachedDirectoryEvent(input: { + event: { type: string; properties?: unknown } + setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void +}) { + if (input.event.type !== "todo.updated") return false + if (!input.event.properties || typeof input.event.properties !== "object") return false + 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) + return true +} + export function applyDirectoryEvent(input: { event: { type: string; properties?: unknown } store: Store From 5afd68b5c6780af312ab87ec233c981087b7debb Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 6 May 2026 23:50:28 +0800 Subject: [PATCH 2/2] fix(app): clear detached todo cache on session removal --- .../context/global-sync/event-reducer.test.ts | 28 +++++++++++++++++++ .../src/context/global-sync/event-reducer.ts | 27 ++++++++++++++---- 2 files changed, 50 insertions(+), 5 deletions(-) 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 642830d1e..ff22811ee 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -174,6 +174,34 @@ describe("applyDirectoryEvent", () => { expect(handled).toBe(false) }) + test("clears detached todo cache for deleted and archived sessions", () => { + const writes: Array<{ sessionID: string; todos: Todo[] | undefined }> = [] + const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => { + writes.push({ sessionID, todos }) + } + + const deleted = applyDetachedDirectoryEvent({ + event: { type: "session.deleted", properties: { info: rootSession({ id: "ses_deleted" }) } }, + setSessionTodo, + }) + const archived = applyDetachedDirectoryEvent({ + event: { type: "session.updated", properties: { info: rootSession({ id: "ses_archived", archived: 2 }) } }, + setSessionTodo, + }) + const activeUpdate = applyDetachedDirectoryEvent({ + event: { type: "session.updated", properties: { info: rootSession({ id: "ses_active" }) } }, + setSessionTodo, + }) + + expect(deleted).toBe(true) + expect(archived).toBe(true) + expect(activeUpdate).toBe(false) + expect(writes).toEqual([ + { sessionID: "ses_deleted", todos: undefined }, + { sessionID: "ses_archived", todos: undefined }, + ]) + }) + test("inserts root sessions in sorted order and updates sessionTotal", () => { const [store, setStore] = createStore( baseState({ diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 5c74f4d51..5a2d1be2a 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -92,12 +92,29 @@ export function applyDetachedDirectoryEvent(input: { event: { type: string; properties?: unknown } setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void }) { - if (input.event.type !== "todo.updated") return false if (!input.event.properties || typeof input.event.properties !== "object") return false - 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) - return true + 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) + return true + } + case "session.deleted": { + const info = (input.event.properties as { info?: Session }).info + if (!info?.id) return false + input.setSessionTodo?.(info.id, undefined) + return true + } + case "session.updated": { + const info = (input.event.properties as { info?: Session }).info + if (!info?.id || !info.time?.archived) return false + input.setSessionTodo?.(info.id, undefined) + return true + } + default: + return false + } } export function applyDirectoryEvent(input: {