| 1 | import { useEffect, useMemo, useState } from "react"; |
| 2 | import { useCommittedCommand } from "../lib/useCommittedCommand"; |
| 3 | import type { Todo } from "../lib/tools"; |
| 4 | import { |
| 5 | dismissedTodoKeyForScope, |
| 6 | resolveTodoPanelTodos, |
| 7 | scopedTodoBatchKey, |
| 8 | scopedTodoDismissalKey, |
| 9 | shouldShowTodoPanel, |
| 10 | todoBatchKey, |
| 11 | todoContinueTarget, |
| 12 | todoDismissalKey, |
| 13 | todoPanelScope, |
| 14 | } from "../lib/todoVisibility"; |
| 15 | import type { Translator } from "../lib/i18n"; |
| 16 | import type { Item } from "../lib/useController"; |
| 17 | import type { TabMeta } from "../lib/types"; |
| 18 | import type { useSessionOperations } from "./useSessionOperations"; |
| 19 | |
| 20 | export type TodoPanelCommandsInput = { |
| 21 | items: readonly Item[]; |
| 22 | running: boolean; |
| 23 | pendingPrompt: boolean; |
| 24 | meta: { |
| 25 | canonicalTodos?: Todo[] | null; |
| 26 | sessionPath?: string; |
| 27 | eventChannel?: string; |
| 28 | } | undefined | null; |
| 29 | activeTab: TabMeta | undefined; |
| 30 | activeTabId: string | undefined; |
| 31 | remote: boolean; |
| 32 | remoteReady: boolean; |
| 33 | controllerReady: boolean; |
| 34 | sessionKey: string; |
| 35 | operations: ReturnType<typeof useSessionOperations>; |
| 36 | t: Translator; |
| 37 | ports: { |
| 38 | remoteSend(text: string): Promise<void>; |
| 39 | sendToTab(tabId: string, text: string): Promise<void>; |
| 40 | }; |
| 41 | }; |
| 42 | |
| 43 | /** |
| 44 | * Owns the pinned task list above the composer: the canonical todo_write |
| 45 | * projection and the dismiss/continue commands. The shared runtime snapshot |
| 46 | * is the only current-state source; transcript tool cards remain history and |
| 47 | * are never replayed into the live panel. Dismissal is local presentation state |
| 48 | * for this mounted UI and cannot outlive a real turn boundary. |
| 49 | */ |
| 50 | export function useTodoPanelCommands(input: TodoPanelCommandsInput) { |
| 51 | const { activeTab, activeTabId, remote, t, ports } = input; |
| 52 | const metaTodos = input.meta?.canonicalTodos; |
| 53 | const todos = useMemo(() => resolveTodoPanelTodos(metaTodos), [metaTodos]); |
| 54 | // Dismissal is a view preference for this mounted UI only. Persisting a todo |
| 55 | // fingerprint made stale progress survive real turn boundaries and become a |
| 56 | // second business-state store. |
| 57 | const [dismissedTodoKeys, setDismissedTodoKeys] = useState<Set<string>>(() => new Set()); |
| 58 | |
| 59 | useEffect(() => { |
| 60 | // turn_started publishes an authoritative empty list before any new |
| 61 | // todo_write. Dropping presentation keys here prevents an identical list |
| 62 | // in the next turn from inheriting the previous turn's dismissal. |
| 63 | if (todos.length === 0) setDismissedTodoKeys(new Set()); |
| 64 | }, [todos]); |
| 65 | const todoKey = useMemo(() => todoDismissalKey(todos), [todos]); |
| 66 | const todoBatch = useMemo(() => todoBatchKey(todos), [todos]); |
| 67 | const todoScope = useMemo( |
| 68 | () => todoPanelScope({ activeTab, activeTabId, eventChannel: remote ? undefined : input.meta?.eventChannel }), |
| 69 | [activeTab, activeTabId, remote, input.meta?.eventChannel], |
| 70 | ); |
| 71 | const dismissedTodo = useMemo( |
| 72 | () => dismissedTodoKeyForScope(todoScope, dismissedTodoKeys, todoKey), |
| 73 | [dismissedTodoKeys, todoKey, todoScope], |
| 74 | ); |
| 75 | const scopedTodoKey = useMemo(() => scopedTodoDismissalKey(todoScope, todoKey), [todoKey, todoScope]); |
| 76 | const scopedTodoBatch = useMemo(() => scopedTodoBatchKey(todoScope, todoBatch), [todoBatch, todoScope]); |
| 77 | const showTodos = shouldShowTodoPanel(todoKey, dismissedTodo, todos); |
| 78 | const dismissTodos = useCommittedCommand(() => { |
| 79 | if (!scopedTodoKey) return; |
| 80 | setDismissedTodoKeys((current) => { |
| 81 | if (current.has(scopedTodoKey)) return current; |
| 82 | const next = new Set(current); |
| 83 | next.add(scopedTodoKey); |
| 84 | return next; |
| 85 | }); |
| 86 | }); |
| 87 | const handleTodoContinue = useCommittedCommand(() => { |
| 88 | const targetTabId = todoContinueTarget(activeTabId, activeTabId, { |
| 89 | ready: remote ? input.remoteReady : input.controllerReady, |
| 90 | readOnly: Boolean(activeTab?.readOnly), |
| 91 | running: input.running, |
| 92 | pendingPrompt: input.pendingPrompt, |
| 93 | }); |
| 94 | if (!targetTabId) return; |
| 95 | const prompt = t("todo.continue"); |
| 96 | if (remote) { |
| 97 | void ports.remoteSend(prompt); |
| 98 | return; |
| 99 | } |
| 100 | void ports.sendToTab(targetTabId, prompt); |
| 101 | }); |
| 102 | |
| 103 | return { showTodos, scopedTodoBatch, todos, dismissTodos, handleTodoContinue }; |
| 104 | } |
| 105 |