source dump of claude code
at main 250 lines 8.8 kB view raw
1import { type FSWatcher, watch } from 'fs' 2import { useEffect, useSyncExternalStore } from 'react' 3import { useAppState, useSetAppState } from '../state/AppState.js' 4import { createSignal } from '../utils/signal.js' 5import type { Task } from '../utils/tasks.js' 6import { 7 getTaskListId, 8 getTasksDir, 9 isTodoV2Enabled, 10 listTasks, 11 onTasksUpdated, 12 resetTaskList, 13} from '../utils/tasks.js' 14import { isTeamLead } from '../utils/teammate.js' 15 16const HIDE_DELAY_MS = 5000 17const DEBOUNCE_MS = 50 18const FALLBACK_POLL_MS = 5000 // Fallback in case fs.watch misses events 19 20/** 21 * Singleton store for the TodoV2 task list. Owns the file watcher, timers, 22 * and cached task list. Multiple hook instances (REPL, Spinner, 23 * PromptInputFooterLeftSide) subscribe to one shared store instead of each 24 * setting up their own fs.watch on the same directory. The Spinner mounts/ 25 * unmounts every turn — per-hook watchers caused constant watch/unwatch churn. 26 * 27 * Implements the useSyncExternalStore contract: subscribe/getSnapshot. 28 */ 29class TasksV2Store { 30 /** Stable array reference; replaced only on fetch. undefined until started. */ 31 #tasks: Task[] | undefined = undefined 32 /** 33 * Set when the hide timer has elapsed (all tasks completed for >5s), or 34 * when the task list is empty. Starts false so the first fetch runs the 35 * "all completed → schedule 5s hide" path (matches original behavior: 36 * resuming a session with completed tasks shows them briefly). 37 */ 38 #hidden = false 39 #watcher: FSWatcher | null = null 40 #watchedDir: string | null = null 41 #hideTimer: ReturnType<typeof setTimeout> | null = null 42 #debounceTimer: ReturnType<typeof setTimeout> | null = null 43 #pollTimer: ReturnType<typeof setTimeout> | null = null 44 #unsubscribeTasksUpdated: (() => void) | null = null 45 #changed = createSignal() 46 #subscriberCount = 0 47 #started = false 48 49 /** 50 * useSyncExternalStore snapshot. Returns the same Task[] reference between 51 * updates (required for Object.is stability). Returns undefined when hidden. 52 */ 53 getSnapshot = (): Task[] | undefined => { 54 return this.#hidden ? undefined : this.#tasks 55 } 56 57 subscribe = (fn: () => void): (() => void) => { 58 // Lazy init on first subscriber. useSyncExternalStore calls this 59 // post-commit, so I/O here is safe (no render-phase side effects). 60 // REPL.tsx keeps a subscription alive for the whole session, so 61 // Spinner mount/unmount churn never drives the count to zero. 62 const unsubscribe = this.#changed.subscribe(fn) 63 this.#subscriberCount++ 64 if (!this.#started) { 65 this.#started = true 66 this.#unsubscribeTasksUpdated = onTasksUpdated(this.#debouncedFetch) 67 // Fire-and-forget: subscribe is called post-commit (not in render), 68 // and the store notifies subscribers when the fetch resolves. 69 void this.#fetch() 70 } 71 let unsubscribed = false 72 return () => { 73 if (unsubscribed) return 74 unsubscribed = true 75 unsubscribe() 76 this.#subscriberCount-- 77 if (this.#subscriberCount === 0) this.#stop() 78 } 79 } 80 81 #notify(): void { 82 this.#changed.emit() 83 } 84 85 /** 86 * Point the file watcher at the current tasks directory. Called on start 87 * and whenever #fetch detects the task list ID has changed (e.g. when 88 * TeamCreateTool sets leaderTeamName mid-session). 89 */ 90 #rewatch(dir: string): void { 91 // Retry even on same dir if the previous watch attempt failed (dir 92 // didn't exist yet). Once the watcher is established, same-dir is a no-op. 93 if (dir === this.#watchedDir && this.#watcher !== null) return 94 this.#watcher?.close() 95 this.#watcher = null 96 this.#watchedDir = dir 97 try { 98 this.#watcher = watch(dir, this.#debouncedFetch) 99 this.#watcher.unref() 100 } catch { 101 // Directory may not exist yet (ensureTasksDir is called by writers). 102 // Not critical — onTasksUpdated covers in-process updates and the 103 // poll timer covers cross-process updates. 104 } 105 } 106 107 #debouncedFetch = (): void => { 108 if (this.#debounceTimer) clearTimeout(this.#debounceTimer) 109 this.#debounceTimer = setTimeout(() => void this.#fetch(), DEBOUNCE_MS) 110 this.#debounceTimer.unref() 111 } 112 113 #fetch = async (): Promise<void> => { 114 const taskListId = getTaskListId() 115 // Task list ID can change mid-session (TeamCreateTool sets 116 // leaderTeamName) — point the watcher at the current dir. 117 this.#rewatch(getTasksDir(taskListId)) 118 const current = (await listTasks(taskListId)).filter( 119 t => !t.metadata?._internal, 120 ) 121 this.#tasks = current 122 123 const hasIncomplete = current.some(t => t.status !== 'completed') 124 125 if (hasIncomplete || current.length === 0) { 126 // Has unresolved tasks (open/in_progress) or empty — reset hide state 127 this.#hidden = current.length === 0 128 this.#clearHideTimer() 129 } else if (this.#hideTimer === null && !this.#hidden) { 130 // All tasks just became completed — schedule clear 131 this.#hideTimer = setTimeout( 132 this.#onHideTimerFired.bind(this, taskListId), 133 HIDE_DELAY_MS, 134 ) 135 this.#hideTimer.unref() 136 } 137 138 this.#notify() 139 140 // Schedule fallback poll only when there are incomplete tasks that 141 // need monitoring. When all tasks are completed (or there are none), 142 // the fs.watch watcher and onTasksUpdated callback are sufficient to 143 // detect new activity — no need to keep polling and re-rendering. 144 if (this.#pollTimer) { 145 clearTimeout(this.#pollTimer) 146 this.#pollTimer = null 147 } 148 if (hasIncomplete) { 149 this.#pollTimer = setTimeout(this.#debouncedFetch, FALLBACK_POLL_MS) 150 this.#pollTimer.unref() 151 } 152 } 153 154 #onHideTimerFired(scheduledForTaskListId: string): void { 155 this.#hideTimer = null 156 // Bail if the task list ID changed since scheduling (team created/deleted 157 // during the 5s window) — don't reset the wrong list. 158 const currentId = getTaskListId() 159 if (currentId !== scheduledForTaskListId) return 160 // Verify all tasks are still completed before clearing 161 void listTasks(currentId).then(async tasksToCheck => { 162 const allStillCompleted = 163 tasksToCheck.length > 0 && 164 tasksToCheck.every(t => t.status === 'completed') 165 if (allStillCompleted) { 166 await resetTaskList(currentId) 167 this.#tasks = [] 168 this.#hidden = true 169 } 170 this.#notify() 171 }) 172 } 173 174 #clearHideTimer(): void { 175 if (this.#hideTimer) { 176 clearTimeout(this.#hideTimer) 177 this.#hideTimer = null 178 } 179 } 180 181 /** 182 * Tear down the watcher, timers, and in-process subscription. Called when 183 * the last subscriber unsubscribes. Preserves #tasks/#hidden cache so a 184 * subsequent re-subscribe renders the last known state immediately. 185 */ 186 #stop(): void { 187 this.#watcher?.close() 188 this.#watcher = null 189 this.#watchedDir = null 190 this.#unsubscribeTasksUpdated?.() 191 this.#unsubscribeTasksUpdated = null 192 this.#clearHideTimer() 193 if (this.#debounceTimer) clearTimeout(this.#debounceTimer) 194 if (this.#pollTimer) clearTimeout(this.#pollTimer) 195 this.#debounceTimer = null 196 this.#pollTimer = null 197 this.#started = false 198 } 199} 200 201let _store: TasksV2Store | null = null 202function getStore(): TasksV2Store { 203 return (_store ??= new TasksV2Store()) 204} 205 206// Stable no-ops for the disabled path so useSyncExternalStore doesn't 207// churn its subscription on every render. 208const NOOP = (): void => {} 209const NOOP_SUBSCRIBE = (): (() => void) => NOOP 210const NOOP_SNAPSHOT = (): undefined => undefined 211 212/** 213 * Hook to get the current task list for the persistent UI display. 214 * Returns tasks when TodoV2 is enabled, otherwise returns undefined. 215 * All hook instances share a single file watcher via TasksV2Store. 216 * Hides the list after 5 seconds if there are no open tasks. 217 */ 218export function useTasksV2(): Task[] | undefined { 219 const teamContext = useAppState(s => s.teamContext) 220 221 const enabled = isTodoV2Enabled() && (!teamContext || isTeamLead(teamContext)) 222 223 const store = enabled ? getStore() : null 224 225 return useSyncExternalStore( 226 store ? store.subscribe : NOOP_SUBSCRIBE, 227 store ? store.getSnapshot : NOOP_SNAPSHOT, 228 ) 229} 230 231/** 232 * Same as useTasksV2, plus collapses the expanded task view when the list 233 * becomes hidden. Call this from exactly one always-mounted component (REPL) 234 * so the collapse effect runs once instead of N× per consumer. 235 */ 236export function useTasksV2WithCollapseEffect(): Task[] | undefined { 237 const tasks = useTasksV2() 238 const setAppState = useSetAppState() 239 240 const hidden = tasks === undefined 241 useEffect(() => { 242 if (!hidden) return 243 setAppState(prev => { 244 if (prev.expandedView !== 'tasks') return prev 245 return { ...prev, expandedView: 'none' as const } 246 }) 247 }, [hidden, setAppState]) 248 249 return tasks 250}