From c37f7b9d997fcbbf4f331e1965425375e7606ab6 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:42:29 -0500 Subject: [PATCH] fix(app): todos not clearing --- .../composer/session-composer-helpers.ts | 10 ++++ .../composer/session-composer-state.test.ts | 23 ++++++++ .../composer/session-composer-state.ts | 58 ++++++++++++++----- 3 files changed, 76 insertions(+), 15 deletions(-) create mode 100644 packages/app/src/pages/session/composer/session-composer-helpers.ts diff --git a/packages/app/src/pages/session/composer/session-composer-helpers.ts b/packages/app/src/pages/session/composer/session-composer-helpers.ts new file mode 100644 index 000000000..90c238af4 --- /dev/null +++ b/packages/app/src/pages/session/composer/session-composer-helpers.ts @@ -0,0 +1,10 @@ +export const todoState = (input: { + count: number + done: boolean + live: boolean +}): "hide" | "clear" | "open" | "close" => { + if (input.count === 0) return "hide" + if (!input.live) return "clear" + if (!input.done) return "open" + return "close" +} diff --git a/packages/app/src/pages/session/composer/session-composer-state.test.ts b/packages/app/src/pages/session/composer/session-composer-state.test.ts index 934d3152a..f7c11715c 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.test.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" +import { todoState } from "./session-composer-helpers" import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" const session = (input: { id: string; parentID?: string }) => @@ -103,3 +104,25 @@ describe("sessionQuestionRequest", () => { expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-grand") }) }) + +describe("todoState", () => { + test("hides when there are no todos", () => { + expect(todoState({ count: 0, done: false, live: true })).toBe("hide") + }) + + test("opens while the session is still working", () => { + expect(todoState({ count: 2, done: false, live: true })).toBe("open") + }) + + test("closes completed todos after a running turn", () => { + expect(todoState({ count: 2, done: true, live: true })).toBe("close") + }) + + test("clears stale todos when the turn ends", () => { + expect(todoState({ count: 2, done: false, live: false })).toBe("clear") + }) + + test("clears completed todos when the session is no longer live", () => { + expect(todoState({ count: 2, done: true, live: false })).toBe("clear") + }) +}) diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index f70bc4bbd..a007e4c84 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -8,8 +8,11 @@ import { useLanguage } from "@/context/language" import { usePermission } from "@/context/permission" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" +import { todoState } from "./session-composer-helpers" import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" +const idle = { type: "idle" as const } + export function createSessionComposerBlocked() { const params = useParams() const permission = usePermission() @@ -59,9 +62,22 @@ export function createSessionComposerState(options?: { closeMs?: number | (() => return globalSync.data.session_todo[id] ?? [] }) + const done = createMemo( + () => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"), + ) + + const status = createMemo(() => { + const id = params.id + if (!id) return idle + return sync.data.session_status[id] ?? idle + }) + + const busy = createMemo(() => status().type !== "idle") + const live = createMemo(() => busy() || blocked()) + const [store, setStore] = createStore({ responding: undefined as string | undefined, - dock: todos().length > 0, + dock: todos().length > 0 && live(), closing: false, opening: false, }) @@ -89,10 +105,6 @@ export function createSessionComposerState(options?: { closeMs?: number | (() => }) } - const done = createMemo( - () => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"), - ) - let timer: number | undefined let raf: number | undefined @@ -111,21 +123,42 @@ export function createSessionComposerState(options?: { closeMs?: number | (() => }, closeMs()) } + // Keep stale turn todos from reopening if the model never clears them. + const clear = () => { + const id = params.id + if (!id) return + globalSync.todo.set(id, []) + sync.set("todo", id, []) + } + createEffect( on( - () => [todos().length, done()] as const, - ([count, complete], prev) => { + () => [todos().length, done(), live()] as const, + ([count, complete, active]) => { if (raf) cancelAnimationFrame(raf) raf = undefined - if (count === 0) { + const next = todoState({ + count, + done: complete, + live: active, + }) + + if (next === "hide") { if (timer) window.clearTimeout(timer) timer = undefined setStore({ dock: false, closing: false, opening: false }) return } - if (!complete) { + if (next === "clear") { + if (timer) window.clearTimeout(timer) + timer = undefined + clear() + return + } + + if (next === "open") { if (timer) window.clearTimeout(timer) timer = undefined const hidden = !store.dock || store.closing @@ -142,13 +175,8 @@ export function createSessionComposerState(options?: { closeMs?: number | (() => return } - if (prev && prev[1]) { - if (store.closing && !timer) scheduleClose() - return - } - setStore({ dock: true, opening: false, closing: true }) - scheduleClose() + if (!timer) scheduleClose() }, ), )