/ src / hooks / useAwaySummary.ts
useAwaySummary.ts
  1  import { feature } from 'bun:bundle'
  2  import { useEffect, useRef } from 'react'
  3  import {
  4    getTerminalFocusState,
  5    subscribeTerminalFocus,
  6  } from '../ink/terminal-focus-state.js'
  7  import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
  8  import { generateAwaySummary } from '../services/awaySummary.js'
  9  import type { Message } from '../types/message.js'
 10  import { createAwaySummaryMessage } from '../utils/messages.js'
 11  
 12  const BLUR_DELAY_MS = 5 * 60_000
 13  
 14  type SetMessages = (updater: (prev: Message[]) => Message[]) => void
 15  
 16  function hasSummarySinceLastUserTurn(messages: readonly Message[]): boolean {
 17    for (let i = messages.length - 1; i >= 0; i--) {
 18      const m = messages[i]!
 19      if (m.type === 'user' && !m.isMeta && !m.isCompactSummary) return false
 20      if (m.type === 'system' && m.subtype === 'away_summary') return true
 21    }
 22    return false
 23  }
 24  
 25  /**
 26   * Appends a "while you were away" summary message after the terminal has been
 27   * blurred for 5 minutes. Fires only when (a) 5min since blur, (b) no turn in
 28   * progress, and (c) no existing away_summary since the last user message.
 29   *
 30   * Focus state 'unknown' (terminal doesn't support DECSET 1004) is a no-op.
 31   */
 32  export function useAwaySummary(
 33    messages: readonly Message[],
 34    setMessages: SetMessages,
 35    isLoading: boolean,
 36  ): void {
 37    const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
 38    const abortRef = useRef<AbortController | null>(null)
 39    const messagesRef = useRef(messages)
 40    const isLoadingRef = useRef(isLoading)
 41    const pendingRef = useRef(false)
 42    const generateRef = useRef<(() => Promise<void>) | null>(null)
 43  
 44    messagesRef.current = messages
 45    isLoadingRef.current = isLoading
 46  
 47    // 3P default: false
 48    const gbEnabled = getFeatureValue_CACHED_MAY_BE_STALE(
 49      'tengu_sedge_lantern',
 50      false,
 51    )
 52  
 53    useEffect(() => {
 54      if (!feature('AWAY_SUMMARY')) return
 55      if (!gbEnabled) return
 56  
 57      function clearTimer(): void {
 58        if (timerRef.current !== null) {
 59          clearTimeout(timerRef.current)
 60          timerRef.current = null
 61        }
 62      }
 63  
 64      function abortInFlight(): void {
 65        abortRef.current?.abort()
 66        abortRef.current = null
 67      }
 68  
 69      async function generate(): Promise<void> {
 70        pendingRef.current = false
 71        if (hasSummarySinceLastUserTurn(messagesRef.current)) return
 72        abortInFlight()
 73        const controller = new AbortController()
 74        abortRef.current = controller
 75        const text = await generateAwaySummary(
 76          messagesRef.current,
 77          controller.signal,
 78        )
 79        if (controller.signal.aborted || text === null) return
 80        setMessages(prev => [...prev, createAwaySummaryMessage(text)])
 81      }
 82  
 83      function onBlurTimerFire(): void {
 84        timerRef.current = null
 85        if (isLoadingRef.current) {
 86          pendingRef.current = true
 87          return
 88        }
 89        void generate()
 90      }
 91  
 92      function onFocusChange(): void {
 93        const state = getTerminalFocusState()
 94        if (state === 'blurred') {
 95          clearTimer()
 96          timerRef.current = setTimeout(onBlurTimerFire, BLUR_DELAY_MS)
 97        } else if (state === 'focused') {
 98          clearTimer()
 99          abortInFlight()
100          pendingRef.current = false
101        }
102        // 'unknown' → no-op
103      }
104  
105      const unsubscribe = subscribeTerminalFocus(onFocusChange)
106      // Handle the case where we're already blurred when the effect mounts
107      onFocusChange()
108      generateRef.current = generate
109  
110      return () => {
111        unsubscribe()
112        clearTimer()
113        abortInFlight()
114        generateRef.current = null
115      }
116    }, [gbEnabled, setMessages])
117  
118    // Timer fired mid-turn → fire when turn ends (if still blurred)
119    useEffect(() => {
120      if (isLoading) return
121      if (!pendingRef.current) return
122      if (getTerminalFocusState() !== 'blurred') return
123      void generateRef.current?.()
124    }, [isLoading])
125  }