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