/ src / hooks / useTasksV2.ts
useTasksV2.ts
  1  import { type FSWatcher, watch } from 'fs'
  2  import { useEffect, useSyncExternalStore } from 'react'
  3  import { useAppState, useSetAppState } from '../state/AppState.js'
  4  import { createSignal } from '../utils/signal.js'
  5  import type { Task } from '../utils/tasks.js'
  6  import {
  7    getTaskListId,
  8    getTasksDir,
  9    isTodoV2Enabled,
 10    listTasks,
 11    onTasksUpdated,
 12    resetTaskList,
 13  } from '../utils/tasks.js'
 14  import { isTeamLead } from '../utils/teammate.js'
 15  
 16  const HIDE_DELAY_MS = 5000
 17  const DEBOUNCE_MS = 50
 18  const 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   */
 29  class 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  
201  let _store: TasksV2Store | null = null
202  function 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.
208  const NOOP = (): void => {}
209  const NOOP_SUBSCRIBE = (): (() => void) => NOOP
210  const 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   */
218  export 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   */
236  export 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  }