| import { useEffect, useEffectEvent, useLayoutEffect } from 'react'; |
| import { useHotkeys } from '@astryxdesign/core/hooks'; |
| import type { |
| ConnectionEvent, |
| PlanReminder, |
| SessionChangedEvent, |
| SessionEvent, |
| SessionEventStreamSnapshot, |
| SessionSummary, |
| StoredMessage, |
| ThemePalette, |
| ThemePreference, |
| UiLocale, |
| } from '@maka/core'; |
| import { |
| ShellRunUpdateBuffer, |
| generalizedErrorMessageChinese, |
| sessionExpectsEventStream, |
| type ShellRunUpdate, |
| } from '@maka/core'; |
| import type { LiveTurnProjection, NavSelection } from '@maka/ui'; |
| import { messageReadErrorMessage } from './app-shell-copy'; |
| import { getDesktopConversationCopy } from './locales/conversation-copy.js'; |
| import { getShellRemainingCopy } from './locales/shell-remaining-copy.js'; |
| import { applyTheme, applyThemePalette } from './theme'; |
| import { safeLocalStorageSet } from './browser-storage'; |
| import type { NavigationState } from './nav-selection.js'; |
| import { |
| createSessionEventStreamSubscription, |
| evaluateSessionEventStreamSnapshot, |
| recordSessionEventStreamChange, |
| recordSessionEventStreamEvent, |
| } from './session-event-health'; |
| import { settledSessionTransientIds } from './settled-session-transients.js'; |
| import type { SessionWorkbarTab } from './session-workbar-layout.js'; |
| import type { WindowCommand } from '../preload/bridge-contract.js'; |
| import { |
| mergeShellRunNotification, |
| mergeShellRunUpdates, |
| type ShellRunUpdatesBySession, |
| } from './shell-run-update-state.js'; |
| |
| type RefBox<T> = { current: T }; |
| const LAYOUT_PERSIST_DEBOUNCE_MS = 200; |
| |
| type SessionEventHealthUpdater = ( |
| updater: (current: Record<string, SessionEventStreamSnapshot>) => Record<string, SessionEventStreamSnapshot>, |
| ) => void; |
| |
| type ToastApi = { |
| error(title: string, description?: string): void; |
| info(title: string, description?: string): void; |
| toast(options: { |
| title: string; |
| description?: string; |
| variant?: 'info' | 'error' | 'success' | 'warning'; |
| duration?: number; |
| action?: { label: string; onClick: () => void }; |
| }): void; |
| }; |
| |
| export function useAppShellNavRefSync(options: { navSelection: NavSelection; navSelectionRef: RefBox<NavSelection> }) { |
| useEffect(() => { |
| options.navSelectionRef.current = options.navSelection; |
| }, [options.navSelection]); |
| } |
| |
| export function useAppShellHostEffects(options: { |
| activeId: string | undefined; |
| hasModalOpen: boolean; |
| setLiveBrowserSessionIds: (sessionIds: string[]) => void; |
| }) { |
| // Tag the document with the host OS so glass-material CSS rules |
| // (sidebar vibrancy passthrough) |
| // can light up only on macOS, where `BrowserWindow({ vibrancy: 'sidebar' })` |
| // paints the native blur material behind the renderer. Other platforms |
| // keep their opaque chrome since vibrancy is a no-op there. |
| useEffect(() => { |
| let cancelled = false; |
| void window.maka.app |
| .info() |
| .then((info) => { |
| if (cancelled) return; |
| document.documentElement.setAttribute('data-os', info.platform); |
| }) |
| .catch(() => { |
| /* swallow — leaves data-os unset, CSS falls back to opaque chrome */ |
| }); |
| return () => { |
| cancelled = true; |
| }; |
| }, []); |
| |
| // P3 embedded browser: track which sessions have a live view (panel mounts |
| // only for those) and tell main which session this window shows (so it can |
| // validate browser:* IPC targets). |
| useEffect(() => { |
| const off = window.maka.browser.onLive((payload) => options.setLiveBrowserSessionIds(payload.sessionIds)); |
| return off; |
| }, []); |
| |
| useEffect(() => { |
| window.maka.browser.setActiveSession(options.activeId ?? null); |
| }, [options.activeId]); |
| |
| useEffect(() => { |
| void window.maka.appWindow.setTitlebarControlsVisible(!options.hasModalOpen).catch(() => {}); |
| return () => { |
| void window.maka.appWindow.setTitlebarControlsVisible(true).catch(() => {}); |
| }; |
| }, [options.hasModalOpen]); |
| } |
| |
| export function useAppShellPersistenceEffects(options: { |
| navigationState: NavigationState; |
| sessionListCollapsed: boolean; |
| sessionListWidth: number; |
| workbarCollapsed: boolean; |
| workbarWidth: number; |
| workbarTab: SessionWorkbarTab; |
| themePalette: ThemePalette; |
| themePref: ThemePreference; |
| }) { |
| // Keep <html class="dark"> in sync with the active preference. The Settings |
| // modal also calls applyTheme on local change so the effect is immediate, |
| // but this keeps the listener for 'auto' alive at the app level. |
| useEffect(() => { |
| const unsubscribe = applyTheme(options.themePref); |
| return unsubscribe; |
| }, [options.themePref]); |
| |
| // PR-THEME-APPLY-AND-DONE-POLISH-0 (WAWQAQ msg `dec85e5b`): re-apply the |
| // palette data attribute whenever the persisted setting changes, so |
| // switching themes in Settings is immediately visible. Previously the |
| // attribute was only set once at mount, so a palette change required a |
| // restart before the new colors took effect. |
| useEffect(() => { |
| applyThemePalette(options.themePalette); |
| }, [options.themePalette]); |
| |
| // PR-FE-BUG-HUNT-5 (kenji bug-hunt 2026-06-24 LOW): pointer drag on |
| // the sidebar resizer fires `setSessionListWidth` on every move |
| // event — at ~60Hz over a long drag, that's a couple hundred |
| // localStorage writes for a single resize gesture. The setting |
| // converges to the user's final width at rest; intermediate |
| // values aren't load-bearing. 200ms trailing debounce keeps the |
| // last-render value in storage without flushing every pixel. |
| useEffect(() => { |
| const handle = window.setTimeout(() => { |
| safeLocalStorageSet('maka-chat-list-width-v1', String(options.sessionListWidth)); |
| }, LAYOUT_PERSIST_DEBOUNCE_MS); |
| return () => window.clearTimeout(handle); |
| }, [options.sessionListWidth]); |
| |
| useEffect(() => { |
| safeLocalStorageSet('maka-chat-list-collapsed-v1', options.sessionListCollapsed ? 'true' : 'false'); |
| }, [options.sessionListCollapsed]); |
| |
| useEffect(() => { |
| const handle = window.setTimeout(() => { |
| safeLocalStorageSet('maka-session-workbar-width-v1', String(options.workbarWidth)); |
| }, LAYOUT_PERSIST_DEBOUNCE_MS); |
| return () => window.clearTimeout(handle); |
| }, [options.workbarWidth]); |
| |
| useEffect(() => { |
| safeLocalStorageSet('maka-session-workbar-collapsed-v1', options.workbarCollapsed ? 'true' : 'false'); |
| }, [options.workbarCollapsed]); |
| |
| useEffect(() => { |
| safeLocalStorageSet('maka-session-workbar-tab-v1', options.workbarTab); |
| }, [options.workbarTab]); |
| |
| // Persist the active destination and each hub's last selected module. |
| // Strict localStorage availability check — Vite dev sometimes runs through |
| // a worker where it isn't defined. |
| useEffect(() => { |
| safeLocalStorageSet('maka-nav-selection-v1', JSON.stringify(options.navigationState)); |
| }, [options.navigationState]); |
| } |
| |
| export function useAppShellBootstrapSubscriptions(options: { |
| uiLocale: UiLocale; |
| activeIdRef: RefBox<string | undefined>; |
| applyE2eFixture: () => Promise<void>; |
| bootstrapSessions: () => Promise<void>; |
| clearPendingTurnActionsForSession: (sessionId: string) => void; |
| /** Releases a send's pending claim once the authority names that turn. */ |
| confirmLiveTurn: (sessionId: string, turnId: string) => void; |
| clearSessionRendererState: (sessionId: string) => void; |
| createSession: () => Promise<void> | void; |
| handleConnectionEvent: (event: ConnectionEvent) => void; |
| openHelp: () => void; |
| openSettings: () => void; |
| pendingPermissionModeChangesRef: RefBox<Set<string>>; |
| pendingSessionModelChangesRef: RefBox<Set<string>>; |
| pendingTurnActionTimersRef: RefBox<Map<string, ReturnType<typeof setTimeout>>>; |
| pendingTurnActionsRef: RefBox<Set<string>>; |
| projectPickerPendingRef: RefBox<boolean>; |
| projectPickerRequestRef: RefBox<number>; |
| refreshAppInfo: () => Promise<void>; |
| refreshConnections: () => Promise<void>; |
| refreshMemoryActive: (failureContext?: 'load') => Promise<void>; |
| refreshMessages: (sessionId: string) => Promise<boolean>; |
| refreshPlanReminders: (options?: { shouldShowError?: () => boolean }) => Promise<void>; |
| refreshProjects: () => Promise<unknown>; |
| refreshShellSettings: () => Promise<void>; |
| refreshSkills: (options?: { shouldShowError?: () => boolean }) => Promise<void>; |
| refreshManagedSkillSources: (options?: { shouldShowError?: () => boolean }) => Promise<void>; |
| refreshBundledSkillCatalog: (options?: { shouldShowError?: () => boolean }) => Promise<void>; |
| refreshSessions: () => Promise<SessionSummary[]>; |
| rendererMountedRef: RefBox<boolean>; |
| setActiveId: (sessionId: string | undefined) => void; |
| setMessages: (messages: StoredMessage[]) => void; |
| setNavSelection: (selection: NavSelection) => void; |
| setSessionEventHealthBySession: SessionEventHealthUpdater; |
| toastApi: ToastApi; |
| }) { |
| const runDeferredStartupRefreshes = useEffectEvent(() => { |
| void options.refreshAppInfo(); |
| void options.refreshMemoryActive('load'); |
| void options.refreshSkills(); |
| void options.refreshManagedSkillSources(); |
| void options.refreshBundledSkillCatalog(); |
| void options.refreshPlanReminders(); |
| void options.applyE2eFixture(); |
| }); |
| const handleConnectionSubscriptionEvent = useEffectEvent((event: ConnectionEvent) => { |
| options.handleConnectionEvent(event); |
| }); |
| // PR-2088: the macOS application menu routes New Task / Settings / Keyboard |
| // Shortcuts here through one channel. The renderer already owns these |
| // implementations; the menu is only a second entry surface. The keydown |
| // path (useHotkeys below) stays active on every platform: on macOS AppKit |
| // resolves the menu accelerator before the web contents sees the keydown, |
| // so a real keypress dispatches exactly once, while CDP-injected test keys |
| // still reach this handler for the renderer path. |
| const handleWindowCommand = useEffectEvent((command: WindowCommand) => { |
| if (command.id === 'newTask') void options.createSession(); |
| else if (command.id === 'openSettings') options.openSettings(); |
| else if (command.id === 'openHelp') options.openHelp(); |
| }); |
| const handleSessionChange = useEffectEvent( |
| (event: SessionChangedEvent) => { |
| // The authority has spoken about a specific turn — whether it started, |
| // failed to start, or ended. That confirms the send's arm, and the |
| // session's status becomes readable as an answer about it again. |
| if (event.sessionId && event.turnId) { |
| options.confirmLiveTurn(event.sessionId, event.turnId); |
| } |
| void options.refreshSessions(); |
| if (event.reason === 'created' || event.reason === 'migrated') { |
| void options.refreshProjects(); |
| } |
| if (event.sessionId) { |
| options.setSessionEventHealthBySession((current) => { |
| const previous = current[event.sessionId!]; |
| if (!previous) return current; |
| return { |
| ...current, |
| [event.sessionId!]: recordSessionEventStreamChange(previous, event.ts), |
| }; |
| }); |
| } |
| if ( |
| event.sessionId && |
| (event.reason === 'turn-status-change' || event.reason === 'message-appended' || event.reason === 'deleted') |
| ) { |
| options.clearPendingTurnActionsForSession(event.sessionId); |
| } |
| const changedSessionId = event.sessionId; |
| if (event.reason === 'message-appended' && changedSessionId && changedSessionId === options.activeIdRef.current) { |
| void options.refreshMessages(changedSessionId); |
| } |
| if (event.reason === 'rebound') { |
| const copy = getDesktopConversationCopy(options.uiLocale).actions; |
| options.toastApi.info(copy.modelReboundTitle, copy.modelReboundDescription(event.modelId)); |
| } |
| if (event.reason === 'deleted' && event.sessionId && event.sessionId === options.activeIdRef.current) { |
| const deletedSessionId = event.sessionId; |
| options.setActiveId(undefined); |
| options.setMessages([]); |
| options.clearSessionRendererState(deletedSessionId); |
| } |
| }, |
| ); |
| const handlePlanChange = useEffectEvent(() => { |
| void options.refreshPlanReminders(); |
| }); |
| const handlePlanDue = useEffectEvent((reminder: PlanReminder) => { |
| const copy = getShellRemainingCopy(options.uiLocale).notifications; |
| void options.refreshPlanReminders(); |
| options.toastApi.toast({ |
| title: copy.planReminder, |
| description: reminder.title, |
| variant: 'info', |
| duration: 8000, |
| action: { |
| label: copy.viewScheduledTasks, |
| onClick: () => options.setNavSelection({ section: 'automations', module: 'plan-reminders' }), |
| }, |
| }); |
| }); |
| // Both shortcuts fire while the composer has focus — they always did, and |
| // that is the point of a global new-task / settings key — so both opt out of |
| // the hook's default "stay silent while typing" rule. |
| // |
| // The shiftKey bail keeps the original "plain N only" contract: useHotkeys |
| // ignores shift state unless the combo names it, and there is no way to spell |
| // "must NOT be shifted", so the entry matches ⇧⌘N and the handler declines |
| // it. Net app behavior is unchanged (⇧⌘N did nothing before and does nothing |
| // now); the only residual difference is that the hook has already called |
| // preventDefault() by the time we decline. |
| useHotkeys([ |
| { |
| keys: 'mod+,', |
| allowInInputs: true, |
| onPress: () => options.openSettings(), |
| }, |
| { |
| keys: 'mod+n', |
| allowInInputs: true, |
| onPress: (event) => { |
| if (event.shiftKey) return; |
| void options.createSession(); |
| }, |
| }, |
| ]); |
| const markRendererMounted = useEffectEvent(() => { |
| options.rendererMountedRef.current = true; |
| }); |
| const cleanupPendingRefs = useEffectEvent(() => { |
| options.rendererMountedRef.current = false; |
| options.projectPickerRequestRef.current += 1; |
| options.projectPickerPendingRef.current = false; |
| for (const timeoutHandle of options.pendingTurnActionTimersRef.current.values()) { |
| clearTimeout(timeoutHandle); |
| } |
| options.pendingTurnActionTimersRef.current.clear(); |
| options.pendingTurnActionsRef.current.clear(); |
| options.pendingPermissionModeChangesRef.current.clear(); |
| options.pendingSessionModelChangesRef.current.clear(); |
| }); |
| |
| useEffect(() => { |
| // Critical data: sessions + connections are seeded from the onboarding |
| // snapshot (see AppShell useEffect above). `refreshShellSettings` is |
| // waited because it drives theme + locale before first paint settles. |
| // Everything else is fire-and-forget on a rAF to keep the critical |
| // render path as short as possible. |
| void options.refreshShellSettings(); |
| // Non-critical: defer to next frame so the first paint isn't blocked. |
| requestAnimationFrame(runDeferredStartupRefreshes); |
| const unsubscribeConnections = window.maka.connections.subscribeEvents(handleConnectionSubscriptionEvent); |
| const unsubscribeSettingsExternal = window.maka.settings.subscribeExternalChanged(() => { |
| void options.refreshShellSettings(); |
| void options.refreshConnections(); |
| }); |
| const unsubscribeSessionChanges = window.maka.sessions.subscribeChanges(handleSessionChange); |
| const unsubscribePlanChanges = window.maka.plans.subscribeChanges(handlePlanChange); |
| const unsubscribePlanDue = window.maka.plans.subscribeDue(handlePlanDue); |
| const unsubscribeWindowCommand = window.maka.appWindow.subscribeCommand(handleWindowCommand); |
| markRendererMounted(); |
| return () => { |
| cleanupPendingRefs(); |
| unsubscribeConnections(); |
| unsubscribeSettingsExternal(); |
| unsubscribeSessionChanges(); |
| unsubscribePlanChanges(); |
| unsubscribePlanDue(); |
| unsubscribeWindowCommand(); |
| }; |
| }, []); |
| } |
| |
| export function useActiveSessionEvents(options: { |
| uiLocale: UiLocale; |
| activeId: string | undefined; |
| activeIdRef: RefBox<string | undefined>; |
| handleEvent: (sessionId: string, event: SessionEvent) => void; |
| markSessionReadLocally: (sessionId: string, readMessages: readonly StoredMessage[]) => void; |
| setMessageLoadErrorBySession: (updater: (current: Record<string, string>) => Record<string, string>) => void; |
| setMessageLoadPending: (pending: boolean) => void; |
| setMessages: (messages: StoredMessage[]) => void; |
| setSessionEventHealthBySession: SessionEventHealthUpdater; |
| toastApi: Pick<ToastApi, 'error'>; |
| }) { |
| const activeId = options.activeId; |
| const applyReadMessages = useEffectEvent((sessionId: string, next: StoredMessage[], isDisposed: () => boolean) => { |
| if (!isDisposed() && options.activeIdRef.current === sessionId) { |
| options.markSessionReadLocally(sessionId, next); |
| // Ignore an empty read: it can race a just-sent message's save and wipe |
| // the optimistic copy shown to the user. length is enough only because |
| // sends are serialized (one optimistic per session); parallel sends |
| // would need a merge instead. |
| if (next.length > 0) options.setMessages(next); |
| options.setMessageLoadPending(false); |
| } |
| }); |
| const applyReadError = useEffectEvent((sessionId: string, error: unknown, isDisposed: () => boolean) => { |
| if (!isDisposed() && options.activeIdRef.current === sessionId) { |
| const message = messageReadErrorMessage(error, options.uiLocale); |
| options.setMessageLoadErrorBySession((current) => ({ |
| ...current, |
| [sessionId]: message, |
| })); |
| options.setMessageLoadPending(false); |
| options.toastApi.error(getDesktopConversationCopy(options.uiLocale).actions.messageReadFailedTitle, message); |
| } |
| }); |
| const handleSessionEvent = useEffectEvent((sessionId: string, event: SessionEvent) => { |
| options.setSessionEventHealthBySession((current) => { |
| const previous = current[sessionId]; |
| if (!previous) return current; |
| return { |
| ...current, |
| [sessionId]: recordSessionEventStreamEvent(previous, Date.now()), |
| }; |
| }); |
| options.handleEvent(sessionId, event); |
| }); |
| const markSessionEventStreamClosed = useEffectEvent((sessionId: string) => { |
| options.setSessionEventHealthBySession((current) => { |
| const previous = current[sessionId]; |
| if (!previous) return current; |
| return { |
| ...current, |
| [sessionId]: { |
| ...previous, |
| status: 'closed', |
| checkedAt: Date.now(), |
| staleSince: undefined, |
| }, |
| }; |
| }); |
| }); |
| |
| useLayoutEffect(() => { |
| if (!activeId) return; |
| let disposed = false; |
| const subscribedAt = Date.now(); |
| options.setMessageLoadErrorBySession((current) => { |
| if (!current[activeId]) return current; |
| const next = { ...current }; |
| delete next[activeId]; |
| return next; |
| }); |
| options.setSessionEventHealthBySession((current) => ({ |
| ...current, |
| [activeId]: createSessionEventStreamSubscription({ |
| sessionId: activeId, |
| now: subscribedAt, |
| }), |
| })); |
| void window.maka.sessions |
| .readMessages(activeId) |
| .then((next) => { |
| applyReadMessages(activeId, next, () => disposed); |
| }) |
| .catch((error) => { |
| applyReadError(activeId, error, () => disposed); |
| }); |
| const unsubscribe = window.maka.sessions.subscribeEvents(activeId, (event) => { |
| handleSessionEvent(activeId, event); |
| }); |
| return () => { |
| disposed = true; |
| unsubscribe(); |
| markSessionEventStreamClosed(activeId); |
| }; |
| }, [activeId]); |
| } |
| |
| export function useShellRunUpdates(options: { |
| activeId: string | undefined; |
| setShellRunUpdatesBySession: (updater: (current: ShellRunUpdatesBySession) => ShellRunUpdatesBySession) => void; |
| }) { |
| const applyUpdates = useEffectEvent( |
| (sessionId: string, updates: Awaited<ReturnType<typeof window.maka.shellRuns.list>>) => { |
| options.setShellRunUpdatesBySession((current) => { |
| const active = current[sessionId]; |
| const retained = active ? { [sessionId]: active } : {}; |
| return mergeShellRunUpdates( |
| retained, |
| updates.filter((update) => update.sessionId === sessionId), |
| ); |
| }); |
| }, |
| ); |
| |
| useEffect(() => { |
| const sessionId = options.activeId; |
| options.setShellRunUpdatesBySession((current) => { |
| if (!sessionId) return {}; |
| const active = current[sessionId]; |
| return active ? { [sessionId]: active } : {}; |
| }); |
| if (!sessionId) return; |
| |
| let disposed = false; |
| let hydrated = false; |
| let retryTimer: ReturnType<typeof globalThis.setTimeout> | undefined; |
| let retryDelayMs = 250; |
| const pending = new ShellRunUpdateBuffer('desktop.shell-run-hydration-buffer'); |
| const unsubscribe = window.maka.shellRuns.subscribeUpdates((update) => { |
| if (disposed) return; |
| if (!hydrated) { |
| pending.add(update); |
| return; |
| } |
| options.setShellRunUpdatesBySession((current) => mergeShellRunNotification(current, sessionId, update)); |
| }); |
| const hydrate = () => { |
| void window.maka.shellRuns |
| .list(sessionId) |
| .then((updates) => { |
| if (disposed) return; |
| applyUpdates(sessionId, updates); |
| retryDelayMs = 250; |
| const buffered = pending.drain(); |
| for (const update of buffered.updates) { |
| options.setShellRunUpdatesBySession((current) => mergeShellRunNotification(current, sessionId, update)); |
| } |
| if (buffered.overflowed) { |
| hydrate(); |
| return; |
| } |
| hydrated = true; |
| }) |
| .catch(() => { |
| if (disposed) return; |
| retryTimer = globalThis.setTimeout(() => { |
| retryTimer = undefined; |
| hydrate(); |
| }, retryDelayMs); |
| retryDelayMs = Math.min(retryDelayMs * 2, 5_000); |
| }); |
| }; |
| hydrate(); |
| return () => { |
| disposed = true; |
| if (retryTimer !== undefined) globalThis.clearTimeout(retryTimer); |
| unsubscribe(); |
| }; |
| }, [options.activeId]); |
| } |
| |
| export function useSessionEventHealthPolling(options: { |
| activeId: string | undefined; |
| activeInteraction: { requestId: string } | undefined; |
| activeSession: SessionSummary | undefined; |
| activeStreamingLive: boolean; |
| hasInFlightLiveTools: boolean; |
| refreshMessages: (sessionId: string) => Promise<boolean>; |
| refreshSessions: () => Promise<SessionSummary[]>; |
| sessionEventHealthBySessionRef: RefBox<Record<string, SessionEventStreamSnapshot>>; |
| setSessionEventHealthBySession: SessionEventHealthUpdater; |
| }) { |
| const { |
| activeId, |
| activeInteraction, |
| activeSession, |
| activeStreamingLive, |
| hasInFlightLiveTools, |
| refreshMessages, |
| refreshSessions, |
| sessionEventHealthBySessionRef, |
| setSessionEventHealthBySession, |
| } = options; |
| |
| useEffect(() => { |
| if (!activeId) return; |
| const hasLiveActivity = activeStreamingLive || hasInFlightLiveTools || Boolean(activeInteraction); |
| const evaluate = () => { |
| const result = evaluateSessionEventStreamSnapshot({ |
| previous: sessionEventHealthBySessionRef.current[activeId], |
| now: Date.now(), |
| sessionStatus: activeSession?.status, |
| hasLiveActivity, |
| }); |
| if (!result.snapshot) return; |
| setSessionEventHealthBySession((current) => ({ |
| ...current, |
| [activeId]: result.snapshot!, |
| })); |
| if (result.shouldRefresh) { |
| void refreshSessions(); |
| void refreshMessages(activeId); |
| } |
| }; |
| // #1979: a stream nobody expects has nothing to observe — `evaluate` can only |
| // derive `closed` and can never ask for a refresh, and no one renders either |
| // field. So an idle session gets no probe at all, not merely a cheaper one. |
| // Both inputs to `expected` are deps of this effect, so a session that starts |
| // running re-arms on its own; `markSessionEventStreamClosed` still records the |
| // closed stream when the subscription itself goes away. |
| if (!sessionExpectsEventStream(activeSession?.status, hasLiveActivity)) return; |
| evaluate(); |
| const interval = window.setInterval(evaluate, 5_000); |
| const onVisibilityChange = () => { |
| if (document.visibilityState === 'visible') evaluate(); |
| }; |
| document.addEventListener('visibilitychange', onVisibilityChange); |
| return () => { |
| window.clearInterval(interval); |
| document.removeEventListener('visibilitychange', onVisibilityChange); |
| }; |
| }, [activeId, activeSession?.status, activeStreamingLive, hasInFlightLiveTools, activeInteraction?.requestId]); |
| } |
| |
| // #646: transient live state is only |
| // advanced and cleared by the ACTIVE session's SessionEvent stream (subscribeEvents |
| // follows activeId only, with no replay of missed events). So any session that |
| // reaches a terminal status while backgrounded — or whose terminal status only |
| // lands after the user has switched back — leaves that transient frozen mid-turn, |
| // surfacing a stuck Stop (via the ungated `activeStreamingLive`) and a half-streamed |
| // bubble. Heal it against the authoritative status, not against an event or a switch |
| // (both fire before the terminal status is known): whenever the sessions list |
| // settles, drop the turn transient of every session that is no longer running / |
| // waiting_for_user. Because it keys off the status landing in `sessions`, it closes |
| // the hole regardless of which path or timing delivers that status. |
| // |
| // Except while a send is still awaiting its answer: an arm carries `unconfirmed` |
| // until a `sessions:changed` names its turn back, and the pre-send status is |
| // indistinguishable from the post-turn one. Reading a list refreshed in that |
| // window as a settle would drop the arm the send just created |
| // (settled-session-transients.ts). |
| // |
| // An active terminal projection is left to its text handoff callback, so this |
| // reconcile cannot cut in front of the committed message landing. Background |
| // terminal projections have no mounted streaming renderer and are safe to clear. |
| // It drops ONLY the turn transient (`clearTurnTransientState`), never the |
| // independently-scoped message-load-error / retry / pending-toggle / permission / |
| // health state — those survive a mere settle. The clear is idempotent (referentially |
| // stable when there's nothing to drop), so the common "terminal session with no |
| // transient" case triggers no re-render. |
| export function useSettledSessionTransientReconcile(options: { |
| activeId?: string; |
| sessions: readonly SessionSummary[]; |
| liveTurnBySessionRef: RefBox<Record<string, LiveTurnProjection>>; |
| clearTurnTransientState: (sessionId: string) => void; |
| }) { |
| const reconcile = useEffectEvent(() => { |
| const sessionIds = settledSessionTransientIds({ |
| activeId: options.activeId, |
| sessions: options.sessions, |
| liveTurnBySession: options.liveTurnBySessionRef.current, |
| }); |
| for (const sessionId of sessionIds) { |
| options.clearTurnTransientState(sessionId); |
| } |
| }); |
| useEffect(() => { |
| reconcile(); |
| }, [options.activeId, options.sessions]); |
| } |