Adam 6 месяцев назад
Родитель
Сommit
c37f7b9d99

+ 10 - 0
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"
+}

+ 23 - 0
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")
+  })
+})

+ 43 - 15
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()
       },
     ),
   )