返回 DeepSeek-Reasonix
useRemoteSession.ts
根目录 / desktop / frontend / src / lib / useRemoteSession.ts
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
663 lines TYPESCRIPT