| 1 | import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; |
| 2 | import { useRuntimeSession } from "./useRuntimeState"; |
| 3 | import { createLegacyRemotePolicyNoticeTracker } from "./legacyRemotePolicyNotice"; |
| 4 | import { app, onRemoteTabEvent, onRemoteTabState } from "./bridge"; |
| 5 | import { onRemoteTabUpdated } from "./remoteTabEvents"; |
| 6 | import { hydrateRemoteTelemetry, loadRemoteStatusSnapshot } from "./remoteTelemetry"; |
| 7 | import { remoteStatusTakenOver, remoteStatusToAction } from "./remoteStatus"; |
| 8 | import { isRemoteTakeoverError } from "./remoteErrors"; |
| 9 | import { useRemoteForkTurn } from "./remoteForkTurn"; |
| 10 | import { useT } from "./i18n"; |
| 11 | import type { CancelOutcome } from "./inboxCancel"; |
| 12 | import type { HistoryMessage } from "./types"; |
| 13 | import { createTurnSubmissionId, initialState, reducer, type ControllerLiveStore, type HistoryLoadOutcome, type HistoryLoadTrigger, type State } from "./useController"; |
| 14 | import { isUnknownSubmissionError } from "./localSubmissionState"; |
| 15 | import { TranscriptSessionFollower } from "./transcriptSessionFollower"; |
| 16 | import { getTranscriptStore } from "./transcriptStore"; |
| 17 | import { historyReplaceAction } from "./sessionTranscriptMode"; |
| 18 | import { isAuthoritativeRemoteStatus, remoteCheckpoints, remoteComposerState, remoteGoalRuntime, remoteGoalView } from "./remoteStatus"; |
| 19 | import type { CollaborationMode, CommandInfo, EffortInfo, GoalLifecycleView, GoalRuntime, GoalStatus, QualityFloor, RemoteTabStateValue, TabMeta, ToolApprovalMode, WireEvent } from "./types"; |
| 20 | import type { RemoteAskAnswer } from "./remoteTypes"; |
| 21 | import type { ForkTargetView } from "./forkTargets"; |
| 22 | |
| 23 | |
| 24 | // The remote session reuses the local transcript pipeline end to end: serve |
| 25 | // frames share the agent event wire form, so they run through the same |
| 26 | // reducer that drives local tabs, and /history hydrates through the same |
| 27 | // history action. The surface and composer therefore consume exactly the |
| 28 | // shapes the local UI consumes. |
| 29 | |
| 30 | // RemoteSessionApi is the surface-facing contract of useRemoteSession. |
| 31 | export interface RemoteSessionApi { |
| 32 | state: RemoteTabStateValue; |
| 33 | error: string; |
| 34 | transcript: State; |
| 35 | liveStore: ControllerLiveStore; |
| 36 | hydrated: boolean; |
| 37 | syncMode?: "v2"; |
| 38 | loadOlderHistory?: (targetTurn?: number, trigger?: HistoryLoadTrigger) => Promise<HistoryLoadOutcome>; |
| 39 | loadNewerHistory?: (latest?: boolean) => Promise<HistoryLoadOutcome>; |
| 40 | running: boolean; |
| 41 | /** The serve's label for the active model, for the composer capsule. */ |
| 42 | modelLabel: string; |
| 43 | commands: CommandInfo[]; |
| 44 | composerProfile?: { |
| 45 | collaborationMode: CollaborationMode; |
| 46 | toolApprovalMode: ToolApprovalMode; |
| 47 | goal: string; |
| 48 | goalStatus?: GoalStatus; |
| 49 | qualityFloor: QualityFloor; |
| 50 | }; |
| 51 | goalRuntime?: GoalRuntime; |
| 52 | goalView?: GoalLifecycleView; |
| 53 | effort?: EffortInfo; |
| 54 | /** Changes whenever the tab adopts a new/reconnected Serve session snapshot. */ |
| 55 | surfaceGeneration: number; |
| 56 | promptError: string; |
| 57 | submit: (text: string, displayText?: string) => Promise<void>; |
| 58 | runManagementCommand: (text: string, rehydrate?: boolean) => Promise<void>; |
| 59 | compact: (instructions: string) => Promise<void>; |
| 60 | cancelTurn: () => Promise<void>; |
| 61 | approve: (callId: string, decision: string) => Promise<void>; |
| 62 | resolvePlanDecision: (callId: string, action: "start_execution" | "revise_plan" | "exit_plan", feedback?: string) => Promise<void>; |
| 63 | answer: (callId: string, answers: RemoteAskAnswer[]) => Promise<void>; |
| 64 | clearExtensionForm: (pluginId: string, surfaceId: string, formInstanceId?: string) => void; |
| 65 | rewind: (turn: number, scope: string) => Promise<void>; |
| 66 | /** Creates the child session for one turn; returns its id, or undefined with the reason in promptError. */ |
| 67 | forkTurn: (target: ForkTargetView) => Promise<{ sessionId: string; operationId: string } | undefined>; |
| 68 | acknowledgeFork: (operationId: string) => Promise<void>; |
| 69 | setModel: (ref: string) => Promise<void>; |
| 70 | setEffort: (level: string) => Promise<void>; |
| 71 | setQualityFloor: (floor: QualityFloor) => Promise<void>; |
| 72 | pauseGoal: () => Promise<void>; |
| 73 | resumeGoal: () => Promise<void>; |
| 74 | editGoal: (objective: string, maxGoalRounds: number | null) => Promise<void>; |
| 75 | steer: (input: string) => Promise<void>; |
| 76 | cancelJob: (jobId: string) => Promise<boolean>; |
| 77 | drainApprovals: (ids: string[]) => void; |
| 78 | retryHydration: () => Promise<void>; |
| 79 | } |
| 80 | |
| 81 | export function useRemoteComposer( |
| 82 | session: RemoteSessionApi, |
| 83 | showToast: (message: string, level: "warn" | "error") => void, |
| 84 | ) { |
| 85 | const onSend = useCallback(async (displayText: string, submitText = displayText) => { |
| 86 | const text = (submitText || displayText).trim(); |
| 87 | if (!text) return; |
| 88 | try { |
| 89 | await session.submit(text); |
| 90 | } catch (error) { |
| 91 | showToast(error instanceof Error ? error.message : String(error), "error"); |
| 92 | } |
| 93 | }, [session, showToast]); |
| 94 | const onCancel = useCallback(async (_queuedItemIDs?: string[]): Promise<CancelOutcome> => { |
| 95 | void session.cancelTurn().catch((error) => { |
| 96 | showToast(error instanceof Error ? error.message : String(error), "error"); |
| 97 | }); |
| 98 | return { discardedItemIds: [] }; |
| 99 | }, [session, showToast]); |
| 100 | return { onSend, onCancel }; |
| 101 | } |
| 102 | |
| 103 | export function useActiveRemoteSession( |
| 104 | activeTab: TabMeta | undefined, |
| 105 | showToast: (message: string, level: "warn" | "error") => void, |
| 106 | ) { |
| 107 | const t = useT(); |
| 108 | const active = Boolean(activeTab?.remote); |
| 109 | const session = useRemoteSession(active && activeTab ? activeTab.id : undefined, activeTab?.remoteState, activeTab?.sessionPath); |
| 110 | const composer = useRemoteComposer(session, showToast); |
| 111 | useEffect(() => { |
| 112 | if (!activeTab?.remote || !activeTab.id) return; |
| 113 | const legacyQuality = session.transcript.items.some(item => item.kind === "notice" && (item.code === "final_readiness" || item.variant === "delivery")); |
| 114 | const key = `${activeTab.id}\u0000${activeTab.sessionPath ?? ""}`; |
| 115 | const notice = legacyRemotePolicyNotice(key, session.composerProfile?.qualityFloor, session.goalRuntime?.stopCause, legacyQuality); |
| 116 | if (notice) showToast(t(notice), "warn"); |
| 117 | }, [activeTab?.remote, activeTab?.id, activeTab?.sessionPath, session.composerProfile?.qualityFloor, session.transcript.items, session.goalRuntime?.stopCause, showToast, t]); |
| 118 | return { active, session, ready: active && session.state === "ready" && session.hydrated && Boolean(session.composerProfile), ...composer }; |
| 119 | } |
| 120 | |
| 121 | const legacyRemotePolicyNotice = createLegacyRemotePolicyNoticeTracker(); |
| 122 | |
| 123 | export function useRemoteSession(tabId: string | undefined, initial?: RemoteTabStateValue, sessionPath?: string): RemoteSessionApi { |
| 124 | const runtimeState = useRuntimeSession(tabId, sessionPath); |
| 125 | const [state, setState] = useState<RemoteTabStateValue>(initial === "disconnected" ? "connecting" : (initial ?? "connecting")); |
| 126 | const [error, setError] = useState(""); |
| 127 | const transcript = useSyncExternalStore( |
| 128 | useCallback(listener => tabId ? getTranscriptStore().subscribeState(tabId, listener) : () => {}, [tabId]), |
| 129 | useCallback(() => (tabId ? getTranscriptStore().states.get(tabId) : undefined) ?? initialState, [tabId]), |
| 130 | ); |
| 131 | const [modelLabel, setModelLabel] = useState(""); |
| 132 | const [commands, setCommands] = useState<CommandInfo[]>([]); |
| 133 | const [composerProfile, setComposerProfile] = useState<RemoteSessionApi["composerProfile"]>(); |
| 134 | const [goalRuntime, setGoalRuntime] = useState<GoalRuntime>(); |
| 135 | const [goalView, setGoalView] = useState<GoalLifecycleView>(); |
| 136 | const [effort, setEffortInfo] = useState<EffortInfo>(); |
| 137 | const [surfaceGeneration, setSurfaceGeneration] = useState(0); |
| 138 | const [promptError, setPromptError] = useState(""); |
| 139 | const [hydrated, setHydrated] = useState(false); |
| 140 | const olderRef = useRef<((trigger?: HistoryLoadTrigger) => Promise<HistoryLoadOutcome>) | undefined>(undefined); |
| 141 | const newerRef = useRef<(() => Promise<HistoryLoadOutcome>) | undefined>(undefined); |
| 142 | const transcriptRef = useRef(transcript); |
| 143 | const submitBindingRef = useRef<object>({}); |
| 144 | const setTranscript = useCallback((update: State | ((state: State) => State)) => { |
| 145 | const owned = (tabId ? getTranscriptStore().states.get(tabId) : undefined) ?? initialState; |
| 146 | const next = typeof update === "function" ? update(owned) : update; |
| 147 | transcriptRef.current = next; |
| 148 | if (tabId) getTranscriptStore().setState(tabId, next); |
| 149 | }, [tabId]); |
| 150 | const { forkTurn, acknowledgeFork, forkTargetsRefreshRef } = useRemoteForkTurn(app, tabId, sessionPath, setTranscript, setPromptError); |
| 151 | const liveListenersRef = useRef(new Set<() => void>()); |
| 152 | const hydratedRef = useRef(false); |
| 153 | const hydratingRef = useRef(false); |
| 154 | const bufferedEventsRef = useRef<WireEvent[]>([]); |
| 155 | // The pre-activation history prime and the follower are never both allowed |
| 156 | // to write the resident store. "retired" is terminal for the mounted |
| 157 | // identity: it is set the moment the follower publishes its first cut (or |
| 158 | // the legacy fallback installs), after which every prime attempt is a no-op. |
| 159 | const primeRef = useRef<"idle" | "loading" | "primed" | "retired">("idle"); |
| 160 | const hydrateRef = useRef<{ tabId: string; run: (force?: boolean) => Promise<void> } | null>(null); |
| 161 | const refreshStatusRef = useRef<{ tabId: string; run: () => Promise<void> } | null>(null); |
| 162 | const reconcileHistoryRef = useRef<(() => Promise<void>) | null>(null); |
| 163 | const activityRevisionRef = useRef(0); |
| 164 | const eventTurnIdRef = useRef<string | undefined>(undefined); |
| 165 | const runtimeAtActivityRef = useRef(runtimeState.state); |
| 166 | // True while the serve reports a local runtime on the host owns this |
| 167 | // session; a spectator surface idles with no other status polling, so this |
| 168 | // flag also drives a slow reconcile loop below. The ref mirrors the last |
| 169 | // observation so the owner-return transition can be detected at the source. |
| 170 | const [spectator, setSpectator] = useState(false); |
| 171 | const spectatorRef = useRef(false); |
| 172 | const noteOwnership = useCallback((takenOver: boolean) => { |
| 173 | const wasSpectator = spectatorRef.current; |
| 174 | spectatorRef.current = takenOver; |
| 175 | setSpectator(takenOver); |
| 176 | // Ownership returning is the only path back to the live transcript: the |
| 177 | // legacy fallback installed a protocol-1 view whose submit() refuses to |
| 178 | // send, and neither the state channel nor the status poll re-attaches the |
| 179 | // follower on its own. |
| 180 | if (wasSpectator && !takenOver) void hydrateRef.current?.run().catch(() => undefined); |
| 181 | }, []); |
| 182 | |
| 183 | useEffect(() => { |
| 184 | for (const listener of liveListenersRef.current) listener(); |
| 185 | }, [transcript]); |
| 186 | |
| 187 | const liveStore = useMemo<ControllerLiveStore>(() => ({ |
| 188 | subscribe(requestedTabId, listener) { |
| 189 | if (!tabId || requestedTabId !== tabId) return () => undefined; |
| 190 | liveListenersRef.current.add(listener); |
| 191 | return () => liveListenersRef.current.delete(listener); |
| 192 | }, |
| 193 | getSnapshot(requestedTabId) { |
| 194 | return requestedTabId === tabId ? transcriptRef.current.live : undefined; |
| 195 | }, |
| 196 | getModelActiveAt(requestedTabId) { |
| 197 | return requestedTabId === tabId ? transcriptRef.current.turnModelActiveAt : undefined; |
| 198 | }, |
| 199 | }), [tabId]); |
| 200 | |
| 201 | const applyRemoteStatus = useCallback((status: unknown) => { |
| 202 | if (!isAuthoritativeRemoteStatus(status)) return; |
| 203 | const next = remoteComposerState(status); |
| 204 | setModelLabel(next.modelLabel); |
| 205 | setComposerProfile(next.composerProfile); |
| 206 | setGoalRuntime(remoteGoalRuntime(status)); |
| 207 | setGoalView(remoteGoalView(status)); |
| 208 | setEffortInfo(next.effort); |
| 209 | noteOwnership(remoteStatusTakenOver(status)); |
| 210 | }, [noteOwnership]); |
| 211 | |
| 212 | useEffect(() => { |
| 213 | if (!tabId) return; |
| 214 | transcriptRef.current = getTranscriptStore().states.get(tabId) ?? initialState; |
| 215 | submitBindingRef.current = {}; |
| 216 | // Restored shells arrive as disconnected shells. Activation must kick the |
| 217 | // backend revive (SetActiveTab → bootstrap) and never park the UI on a |
| 218 | // reconnect placeholder — treat them as connecting until ready/error. |
| 219 | const revivedFromShell = initial === "disconnected"; |
| 220 | const mountedState = revivedFromShell ? "connecting" : (initial ?? "connecting"); |
| 221 | setState(mountedState); |
| 222 | setError(""); |
| 223 | setPromptError(""); |
| 224 | // The store owns mounted content across reconnects and tab switches. |
| 225 | eventTurnIdRef.current = undefined; |
| 226 | setModelLabel(""); |
| 227 | setCommands([]); |
| 228 | setComposerProfile(undefined); |
| 229 | setGoalRuntime(undefined); |
| 230 | setGoalView(undefined); |
| 231 | setEffortInfo(undefined); |
| 232 | hydratedRef.current = false; |
| 233 | hydratingRef.current = false; |
| 234 | bufferedEventsRef.current = []; |
| 235 | primeRef.current = "idle"; |
| 236 | spectatorRef.current = false; |
| 237 | setSpectator(false); |
| 238 | setHydrated(false); |
| 239 | let cancelled = false; |
| 240 | let generation = 0; |
| 241 | let follower: TranscriptSessionFollower | undefined; |
| 242 | const dispatch = (action: import("./useController").Action) => { |
| 243 | if (!cancelled) setTranscript(current => reducer(current, action)); |
| 244 | }; |
| 245 | // History-first: the persisted canonical window is readable before the |
| 246 | // serve activates the runtime, so publish a one-shot durable baseline as |
| 247 | // soon as the tab's identity resolves. This is deliberately not a second |
| 248 | // live transcript owner — once the runtime is ready, hydrate()'s follower |
| 249 | // installs the authoritative protocol-v2 cut and owns everything after it. |
| 250 | // Mirrors the local primeReadableHistoryForTab contract. |
| 251 | const transcriptHasContent = () => { |
| 252 | const mounted = transcriptRef.current; |
| 253 | return mounted.items.length > 0 || Boolean(mounted.live?.text || mounted.live?.reasoning); |
| 254 | }; |
| 255 | const primeEarlyHistory = async () => { |
| 256 | if (primeRef.current !== "idle") return; |
| 257 | // Only a blank transcript may be primed, and the check has to run before |
| 258 | // loadLatest: that call bumps the resident session generation (retiring |
| 259 | // the follower's in-flight reads) and replaces records before it reads |
| 260 | // `current`, so a transcript that already has content must never reach |
| 261 | // it. history_replace has no revision guard either — a stale or empty |
| 262 | // window landing after the follower's cut would wipe the conversation. |
| 263 | if (transcriptHasContent()) { primeRef.current = "retired"; return; } |
| 264 | primeRef.current = "loading"; |
| 265 | // The prime is scoped to this mounted identity and to store ownership, |
| 266 | // not to a hydrate generation: hydrate() bumps the generation the moment |
| 267 | // it starts, and a follower that then fails or stalls must not have |
| 268 | // discarded the only baseline the tab could show. |
| 269 | const current = () => !cancelled && primeRef.current === "loading"; |
| 270 | try { |
| 271 | const projection = await getTranscriptStore().loadLatest(tabId, sessionPath ?? "", { current }); |
| 272 | if (!projection || !current()) return; |
| 273 | if (transcriptHasContent()) { primeRef.current = "retired"; return; } |
| 274 | primeRef.current = "primed"; |
| 275 | setTranscript(current => reducer(current, historyReplaceAction(projection))); |
| 276 | } catch { |
| 277 | // Before the attach handshake lands (or on a legacy serve) the window |
| 278 | // read is unavailable. A miss stays non-fatal: the next attach |
| 279 | // publication retries, and the ready-time hydration takes over. |
| 280 | } finally { |
| 281 | if (primeRef.current === "loading") primeRef.current = "idle"; |
| 282 | } |
| 283 | }; |
| 284 | const refreshStatus = async () => { |
| 285 | const ticket = generation; |
| 286 | const status = await app.RemoteTabStatus(tabId); |
| 287 | if (cancelled || ticket !== generation) return; |
| 288 | applyRemoteStatus(status); |
| 289 | setTranscript(current => hydrateRemoteTelemetry(current, status)); |
| 290 | }; |
| 291 | const hydrate = async () => { |
| 292 | const ticket = ++generation; |
| 293 | follower?.stop(); |
| 294 | follower = new TranscriptSessionFollower(tabId, sessionPath ?? "", true, action => { |
| 295 | if (cancelled || ticket !== generation) return; |
| 296 | // The follower's install cut makes it the store owner (connection |
| 297 | // status frames precede it and own nothing); an early history prime |
| 298 | // still in flight must not land after that cut. |
| 299 | if (action.type === "transcript_v2_snapshot") primeRef.current = "retired"; |
| 300 | dispatch(action); |
| 301 | }); |
| 302 | setHydrated(false); |
| 303 | try { |
| 304 | await follower.start(); |
| 305 | const loaded = await loadRemoteStatusSnapshot(tabId, mountedState === "ready" ? 3 : 60, |
| 306 | () => cancelled || ticket !== generation, isAuthoritativeRemoteStatus, true); |
| 307 | if (!loaded || cancelled || ticket !== generation) return; |
| 308 | const [snapshot, status] = loaded; |
| 309 | applyRemoteStatus(status); |
| 310 | setCommands(Array.isArray(snapshot.commands) ? snapshot.commands as CommandInfo[] : []); |
| 311 | setTranscript(current => hydrateRemoteTelemetry(reducer(current, |
| 312 | { type: "checkpoints", checkpoints: remoteCheckpoints(snapshot.checkpoints) }), status)); |
| 313 | hydratedRef.current = true; |
| 314 | setState("ready"); |
| 315 | setHydrated(true); |
| 316 | setError(""); |
| 317 | setSurfaceGeneration(value => value + 1); |
| 318 | void forkTargetsRefreshRef.current?.(); |
| 319 | } catch (error) { |
| 320 | if (cancelled || ticket !== generation) return; |
| 321 | // The transcript protocol requires the live runtime that owns the |
| 322 | // session. Only a session taken over by a local runtime on the serve |
| 323 | // host (the Follow request answers 409, or status reports the |
| 324 | // take-over) may fall back to the identity/legacy history view; every |
| 325 | // other failure keeps its error and waits for the next ready |
| 326 | // publication or an explicit retry. |
| 327 | const takenOver = isRemoteTakeoverError(error) || await app.RemoteTabStatus(tabId).then(remoteStatusTakenOver, () => false); |
| 328 | if (cancelled || ticket !== generation) return; |
| 329 | if (!takenOver) { setError(String(error)); return; } |
| 330 | try { |
| 331 | const legacyLoaded = await loadRemoteStatusSnapshot(tabId, mountedState === "ready" ? 3 : 60, |
| 332 | () => cancelled || ticket !== generation, isAuthoritativeRemoteStatus, false); |
| 333 | if (!legacyLoaded || cancelled || ticket !== generation) return; |
| 334 | const [snapshot, status] = legacyLoaded; |
| 335 | const messages = Array.isArray(snapshot.history) ? snapshot.history as HistoryMessage[] : []; |
| 336 | const checkpoints = remoteCheckpoints(snapshot.checkpoints); |
| 337 | primeRef.current = "retired"; |
| 338 | applyRemoteStatus(status); |
| 339 | setCommands(Array.isArray(snapshot.commands) ? snapshot.commands as CommandInfo[] : []); |
| 340 | setTranscript(current => { |
| 341 | let next = reducer(current, { type: "history", messages, remote: true }); |
| 342 | next = reducer(next, { type: "checkpoints", checkpoints }); |
| 343 | next = reducer(next, remoteStatusToAction(status, Date.now(), next.running)); |
| 344 | return hydrateRemoteTelemetry(next, status); |
| 345 | }); |
| 346 | hydratedRef.current = true; |
| 347 | setState("ready"); |
| 348 | setHydrated(true); |
| 349 | setError(""); |
| 350 | setSurfaceGeneration(value => value + 1); |
| 351 | void forkTargetsRefreshRef.current?.(); |
| 352 | } catch (fallbackError) { |
| 353 | if (!cancelled && ticket === generation) setError(String(fallbackError)); |
| 354 | } |
| 355 | } |
| 356 | }; |
| 357 | const offContent = getTranscriptStore().subscribe(tabId, change => dispatch({ type: "history_items_patch", patches: change.patches, expected: change.expected })); |
| 358 | olderRef.current = async () => { |
| 359 | if (transcriptRef.current.historyOlderLoading) return "empty"; |
| 360 | dispatch({ type: "history_older_start" }); |
| 361 | try { |
| 362 | const page = await getTranscriptStore().loadOlder(tabId, sessionPath ?? ""); |
| 363 | if (!page || cancelled) return "empty"; |
| 364 | if (page.kind === "reload") { await hydrate(); return "loaded"; } |
| 365 | dispatch({ type: "history_prepend", items: page.prependItems, removeIds: page.removeIds, |
| 366 | startTurn: page.startTurn, endTurn: page.endTurn, totalTurns: page.totalTurns, |
| 367 | hasOlder: page.hasOlder, hasNewer: page.hasNewer, revision: page.revision, digest: page.digest }); |
| 368 | return "loaded"; |
| 369 | } catch (error) { |
| 370 | dispatch({ type: "history_older_error", error: String(error) }); |
| 371 | return "empty"; |
| 372 | } |
| 373 | }; |
| 374 | hydrateRef.current = { tabId, run: hydrate }; |
| 375 | newerRef.current = async () => { |
| 376 | if (transcriptRef.current.historyNewerLoading) return "empty"; |
| 377 | dispatch({ type: "history_newer_start" }); |
| 378 | try { |
| 379 | const page = await getTranscriptStore().loadNewer(tabId, sessionPath ?? ""); |
| 380 | if (!page || cancelled) { dispatch({ type: "history_newer_error", error: "" }); return "empty"; } |
| 381 | if (page.kind === "stale") { await hydrate(); return "loaded"; } |
| 382 | dispatch({ type: "history_append", items: page.items, |
| 383 | startTurn: page.startTurn, endTurn: page.endTurn, totalTurns: page.totalTurns, |
| 384 | hasOlder: page.hasOlder, hasNewer: page.hasNewer, revision: page.revision, digest: page.digest }); |
| 385 | return "loaded"; |
| 386 | } catch (error) { |
| 387 | dispatch({ type: "history_newer_error", error: String(error) }); |
| 388 | return "empty"; |
| 389 | } |
| 390 | }; |
| 391 | refreshStatusRef.current = { tabId, run: refreshStatus }; |
| 392 | reconcileHistoryRef.current = hydrate; |
| 393 | const offState = onRemoteTabState(tabId, next => { |
| 394 | if (cancelled) return; |
| 395 | setState(next.state); |
| 396 | setError(next.error ?? ""); |
| 397 | if (next.state === "ready") void hydrate(); |
| 398 | else if (next.state === "disconnected") { |
| 399 | setHydrated(false); |
| 400 | dispatch({ type: "transcript_connection", status: "disconnected" }); |
| 401 | } |
| 402 | }); |
| 403 | // Ownership flips arrive as tab meta updates (an explicit reclaim clears |
| 404 | // the pin there before any status poll runs); mirror them into the |
| 405 | // spectator flag that drives the reconcile loop below. |
| 406 | const offMeta = onRemoteTabUpdated(meta => { |
| 407 | if (cancelled || meta?.id !== tabId) return; |
| 408 | noteOwnership(Boolean(meta.takenOver)); |
| 409 | // The attach publication is the reliable "identity live, activation |
| 410 | // still in flight" signal — retry the early history read there. |
| 411 | void primeEarlyHistory(); |
| 412 | }); |
| 413 | // The legacy event channel carries ancillary invalidations only. |
| 414 | const offEvent = onRemoteTabEvent(tabId, raw => { |
| 415 | const event = raw as WireEvent; |
| 416 | if (event.kind === "turn_done") { |
| 417 | void refreshStatus().catch(() => undefined); |
| 418 | void forkTargetsRefreshRef.current?.(); |
| 419 | } |
| 420 | }); |
| 421 | if (revivedFromShell) void app.SetActiveTab(tabId).catch(() => undefined); |
| 422 | void primeEarlyHistory(); |
| 423 | void hydrate(); |
| 424 | return () => { |
| 425 | submitBindingRef.current = {}; |
| 426 | cancelled = true; |
| 427 | generation++; |
| 428 | follower?.stop(); |
| 429 | offContent(); |
| 430 | offState(); |
| 431 | offMeta(); |
| 432 | offEvent(); |
| 433 | olderRef.current = undefined; |
| 434 | newerRef.current = undefined; |
| 435 | hydrateRef.current = null; |
| 436 | refreshStatusRef.current = null; |
| 437 | reconcileHistoryRef.current = null; |
| 438 | }; |
| 439 | }, [applyRemoteStatus, noteOwnership, tabId, sessionPath, setTranscript]); |
| 440 | |
| 441 | // A spectator surface idles with no status traffic: the running watchdog |
| 442 | // only reconciles turns, and the read-only composer blocks the sends that |
| 443 | // would otherwise refresh status. A stale ownership observation could pin |
| 444 | // the takeover banner forever, so poll at a slow cadence until the serve |
| 445 | // reports the session free again. |
| 446 | useEffect(() => { |
| 447 | if (!tabId || state !== "ready" || !spectator) return; |
| 448 | const timer = window.setInterval(() => { |
| 449 | void refreshStatusRef.current?.run().catch(() => undefined); |
| 450 | }, 5_000); |
| 451 | return () => window.clearInterval(timer); |
| 452 | }, [tabId, state, spectator]); |
| 453 | |
| 454 | const submit = useCallback(async (text: string, displayText = text) => { |
| 455 | if (!tabId) return; |
| 456 | if (transcriptRef.current.transcriptProtocol !== 2 || transcriptRef.current.transcriptConnection !== "connected") { |
| 457 | throw new Error("Transcript v2 is not synchronized. Upgrade Desktop and Serve together, or reconnect."); |
| 458 | } |
| 459 | const trimmed = text.trim(); |
| 460 | if (!trimmed) return; |
| 461 | // Optimistic user bubble, exactly like the local send path. seq rides |
| 462 | // the reducer's counter; the submission id only needs uniqueness. |
| 463 | const before = getTranscriptStore().states.get(tabId) ?? initialState; |
| 464 | const binding = submitBindingRef.current; |
| 465 | const current = () => submitBindingRef.current === binding && getTranscriptStore().states.get(tabId)?.sessionGen === before.sessionGen; |
| 466 | const submissionId = createTurnSubmissionId(tabId, before.sessionGen, before.seq, before.meta?.runtime?.epoch); |
| 467 | activityRevisionRef.current += 1; |
| 468 | runtimeAtActivityRef.current = runtimeState.state; |
| 469 | setTranscript((s) => reducer(s, { type: "user", text: displayText.trim(), seq: s.seq, submissionId })); |
| 470 | try { |
| 471 | if (app.SubmitRemoteTabWithSubmission) await app.SubmitRemoteTabWithSubmission(tabId, trimmed, submissionId); |
| 472 | else await app.SubmitRemoteTab(tabId, trimmed); |
| 473 | if (current()) setTranscript(s => reducer(s, { type: "send_confirmed", submissionId })); |
| 474 | } catch (e) { |
| 475 | // Roll the optimistic running flag back — a refused/failed submit must |
| 476 | // never leave the pill spinning (same contract as the local send path). |
| 477 | const error = `Send failed: ${e instanceof Error ? e.message : String(e)}`; |
| 478 | if (current()) setTranscript((s) => reducer(s, { type: isUnknownSubmissionError(e) ? "turn_submit_unknown" : "send_failed", submissionId, error })); |
| 479 | throw e; |
| 480 | } |
| 481 | }, [tabId, runtimeState.state, setTranscript]); |
| 482 | |
| 483 | const runManagementCommand = useCallback(async (text: string, rehydrate = false) => { |
| 484 | if (!tabId) return; |
| 485 | const trimmed = text.trim(); |
| 486 | if (!trimmed) return; |
| 487 | // Management verbs produce notices/state changes rather than a model |
| 488 | // turn, so do not create the optimistic conversational bubble used by |
| 489 | // submit(). Refresh the authoritative profile after the command settles. |
| 490 | await app.SubmitRemoteTab(tabId, trimmed); |
| 491 | if (rehydrate) { |
| 492 | const hydration = hydrateRef.current; |
| 493 | if (hydration?.tabId === tabId) await hydration.run(true); |
| 494 | return; |
| 495 | } |
| 496 | const current = refreshStatusRef.current; |
| 497 | if (current?.tabId === tabId) await current.run(); |
| 498 | }, [tabId]); |
| 499 | |
| 500 | const cancelTurn = useCallback(async () => { |
| 501 | if (!tabId) return; |
| 502 | await app.CancelRemoteTab(tabId); |
| 503 | }, [tabId]); |
| 504 | |
| 505 | const approve = useCallback(async (callId: string, decision: string) => { |
| 506 | if (!tabId) return; |
| 507 | setPromptError(""); |
| 508 | try { |
| 509 | await app.ApproveRemoteTab(tabId, callId, decision); |
| 510 | setTranscript((s) => s.approval?.id === callId ? { ...s, approval: undefined } : s); |
| 511 | } catch (error) { |
| 512 | setPromptError(error instanceof Error ? error.message : String(error)); |
| 513 | throw error; |
| 514 | } |
| 515 | }, [tabId]); |
| 516 | |
| 517 | const resolvePlanDecision = useCallback(async ( |
| 518 | callId: string, |
| 519 | action: "start_execution" | "revise_plan" | "exit_plan", |
| 520 | feedback = "", |
| 521 | ) => { |
| 522 | if (!tabId) return; |
| 523 | setPromptError(""); |
| 524 | try { |
| 525 | await app.ResolveRemoteTabPlanDecision(tabId, callId, action, feedback); |
| 526 | setTranscript((s) => s.approval?.id === callId ? { ...s, approval: undefined } : s); |
| 527 | } catch (error) { |
| 528 | setPromptError(error instanceof Error ? error.message : String(error)); |
| 529 | throw error; |
| 530 | } |
| 531 | }, [tabId]); |
| 532 | |
| 533 | const answer = useCallback(async (callId: string, answers: RemoteAskAnswer[]) => { |
| 534 | if (!tabId) return; |
| 535 | setPromptError(""); |
| 536 | try { |
| 537 | await app.AnswerRemoteTab(tabId, callId, answers); |
| 538 | setTranscript((s) => s.ask?.id === callId ? { ...s, ask: undefined } : s); |
| 539 | } catch (error) { |
| 540 | setPromptError(error instanceof Error ? error.message : String(error)); |
| 541 | throw error; |
| 542 | } |
| 543 | }, [tabId]); |
| 544 | |
| 545 | const clearExtensionForm = useCallback((pluginId: string, surfaceId: string, formInstanceId?: string) => { |
| 546 | setTranscript((s) => s.extensionForm?.pluginId === pluginId && s.extensionForm.surfaceId === surfaceId && |
| 547 | (!formInstanceId || s.extensionForm.formInstanceId === formInstanceId) |
| 548 | ? reducer(s, { type: "clearExtensionForm" }) : s); |
| 549 | }, []); |
| 550 | |
| 551 | const retryHydration = useCallback((): Promise<void> => { |
| 552 | setError(""); |
| 553 | const current = hydrateRef.current; |
| 554 | if (!current || current.tabId !== tabId) return Promise.resolve(); |
| 555 | return current.run(true); |
| 556 | }, [tabId]); |
| 557 | |
| 558 | const compact = useCallback(async (instructions: string) => { |
| 559 | if (!tabId) return; |
| 560 | await app.CompactRemoteTab(tabId, instructions); |
| 561 | await retryHydration(); |
| 562 | }, [retryHydration, tabId]); |
| 563 | |
| 564 | const refreshStatus = useCallback((): Promise<void> => { |
| 565 | const current = refreshStatusRef.current; |
| 566 | if (!current || current.tabId !== tabId) return Promise.resolve(); |
| 567 | return current.run(); |
| 568 | }, [tabId]); |
| 569 | |
| 570 | const cancelJob = useCallback(async (jobId: string) => { |
| 571 | if (!tabId) return false; |
| 572 | try { |
| 573 | await app.CancelRemoteTabJobs(tabId, [jobId]); |
| 574 | await refreshStatus(); |
| 575 | return true; |
| 576 | } catch (error) { |
| 577 | setPromptError(String(error)); |
| 578 | return false; |
| 579 | } |
| 580 | }, [refreshStatus, tabId]); |
| 581 | |
| 582 | const rewind = useCallback(async (turn: number, scope: string) => { |
| 583 | if (!tabId) return; |
| 584 | setPromptError(""); |
| 585 | try { |
| 586 | switch (scope) { |
| 587 | // No fork scope: the serve's /fork switches the parent session; forkTurn creates a child instead. |
| 588 | case "summ-from": |
| 589 | await app.SummarizeRemoteTab(tabId, turn, "from"); |
| 590 | break; |
| 591 | case "summ-upto": |
| 592 | await app.SummarizeRemoteTab(tabId, turn, "upto"); |
| 593 | break; |
| 594 | case "code": |
| 595 | case "conversation": |
| 596 | case "both": |
| 597 | await app.RewindRemoteTab(tabId, String(turn), scope); |
| 598 | break; |
| 599 | default: |
| 600 | throw new Error(`Unsupported remote rewind scope: ${scope}`); |
| 601 | } |
| 602 | await retryHydration(); |
| 603 | } catch (error) { |
| 604 | setPromptError(error instanceof Error ? error.message : String(error)); |
| 605 | throw error; |
| 606 | } |
| 607 | }, [retryHydration, tabId]); |
| 608 | |
| 609 | const setEffort = useCallback(async (level: string) => { |
| 610 | if (!tabId) return; |
| 611 | await app.SetRemoteTabEffort(tabId, level); |
| 612 | await refreshStatus(); |
| 613 | }, [refreshStatus, tabId]); |
| 614 | |
| 615 | const setModel = useCallback(async (ref: string) => { |
| 616 | if (!tabId) return; |
| 617 | await app.SetRemoteTabModel(tabId, ref); |
| 618 | await refreshStatus(); |
| 619 | }, [refreshStatus, tabId]); |
| 620 | |
| 621 | const setQualityFloor = useCallback(async (floor: QualityFloor) => { |
| 622 | if (!tabId) return; |
| 623 | // Compatibility only. The new client never asks an old server to change |
| 624 | // policy behind the user's back; its next status remains authoritative. |
| 625 | if (floor !== "standard" && floor !== "delivery") throw new Error(`Unknown retired execution setting: ${floor}`); |
| 626 | await app.SetRemoteTabQualityFloor(tabId, floor); |
| 627 | }, [tabId]); |
| 628 | |
| 629 | const pauseGoal = useCallback(async () => { |
| 630 | if (!tabId) return; |
| 631 | await app.PauseRemoteTabGoal(tabId); |
| 632 | await refreshStatus(); |
| 633 | }, [refreshStatus, tabId]); |
| 634 | |
| 635 | const resumeGoal = useCallback(async () => { |
| 636 | if (!tabId) return; |
| 637 | await app.ResumeRemoteTabGoal(tabId); |
| 638 | await refreshStatus(); |
| 639 | }, [refreshStatus, tabId]); |
| 640 | |
| 641 | const editGoal = useCallback(async (objective: string, maxGoalRounds: number | null) => { |
| 642 | if (!tabId) return; |
| 643 | await app.EditRemoteTabGoal(tabId, objective, maxGoalRounds); |
| 644 | await refreshStatus(); |
| 645 | }, [refreshStatus, tabId]); |
| 646 | |
| 647 | const steer = useCallback(async (input: string) => { |
| 648 | if (!tabId) return; |
| 649 | await app.SteerRemoteTab(tabId, input); |
| 650 | }, [tabId]); |
| 651 | |
| 652 | const drainApprovals = useCallback((ids: string[]) => { |
| 653 | setTranscript((current) => reducer(current, { type: "approval_drained", ids, epoch: current.promptEpoch })); |
| 654 | }, []); |
| 655 | |
| 656 | return { |
| 657 | state, error, transcript, liveStore, hydrated, syncMode: "v2", loadOlderHistory: (_targetTurn?: number, trigger?: HistoryLoadTrigger) => olderRef.current?.(trigger) ?? Promise.resolve("empty"), loadNewerHistory: () => newerRef.current?.() ?? Promise.resolve("empty"), running: transcript.running, modelLabel, commands, |
| 658 | composerProfile, goalRuntime, goalView, effort, surfaceGeneration, promptError, submit, runManagementCommand, compact, cancelTurn, |
| 659 | approve, resolvePlanDecision, answer, clearExtensionForm, rewind, forkTurn, acknowledgeFork, setModel, setEffort, setQualityFloor, pauseGoal, resumeGoal, editGoal, steer, cancelJob, |
| 660 | drainApprovals, retryHydration, |
| 661 | }; |
| 662 | } |
| 663 |