返回 DeepSeek-Reasonix
useSessionDraftSurface.ts
根目录 / desktop / frontend / src / app-runtime / useSessionDraftSurface.ts
1 import { useCallback, useEffect, useRef, useState } from "react";
2
3 import type {
4 SessionDraftSettings,
5 SessionDraftSubmissionView,
6 SessionDraftSummary,
7 SessionDraftView,
8 ServerView,
9 SessionRef,
10 } from "../generated/desktopContract.generated";
11 import { app } from "../lib/bridge";
12 import type { StructuredInvocationSubmit } from "../lib/invocationDisplay";
13 import type { CommandInfo, CollaborationMode, ToolApprovalMode } from "../lib/types";
14 import type { PersistentComposerDraft } from "../components/Composer";
15 import { applyInheritedModel, canonicalJSON, draftSubmissionLocksEditing, sameDraftSettings, useInheritedDraftModels } from "./draftModelInheritance";
16 import { cloneDraftContent, cloneDraftSettings, contentJSON, parseContent } from "./draftValues";
17 import { buildInitialGoalSubmission } from "./sessionSubmissionOwner";
18
19 export { draftSubmissionLocksEditing } from "./draftModelInheritance";
20
21 declare global {
22 interface Window {
23 __reasonixFlushSessionDraft?: () => Promise<void>;
24 __reasonixResumeSessionDraftEditing?: () => void;
25 }
26 }
27
28 export type DraftSaveState = "saved" | "dirty" | "saving" | "error" | "conflict";
29
30 export type SessionDraftSurface = {
31 kind: "draft";
32 draft: SessionDraftView;
33 content: PersistentComposerDraft;
34 settings: SessionDraftSettings;
35 commands: CommandInfo[];
36 servers: ServerView[];
37 models?: import("../generated/desktopContract.generated").ModelInfo[];
38 generation: number;
39 editVersion: number;
40 pendingTasks: number;
41 preparingSubmission: boolean;
42 discarding?: boolean;
43 saveState: DraftSaveState;
44 submissionError?: string;
45 resumingSubmission?: boolean;
46 error?: string;
47 taskError?: string;
48 conflict?: SessionDraftView;
49 operation?: SessionDraftSubmissionView;
50 };
51
52 export type DraftHandle = Readonly<{ draftId: string; generation: number }>;
53
54 export type DraftSubmissionCapture = Readonly<{
55 handle: DraftHandle;
56 preparationId: string;
57 draftId: string;
58 workspaceId: string;
59 generation: number;
60 editVersion: number;
61 navigationIntent: number;
62 content: PersistentComposerDraft;
63 settings: SessionDraftSettings;
64 }>;
65
66 type DraftEntry = {
67 draft: SessionDraftView;
68 content: PersistentComposerDraft;
69 settings: SessionDraftSettings;
70 commands: CommandInfo[];
71 servers: ServerView[];
72 models?: import("../generated/desktopContract.generated").ModelInfo[];
73 generation: number;
74 visibleIntent: number;
75 editVersion: number;
76 savedEditVersion: number;
77 submissionError?: string;
78 resumingSubmission?: boolean;
79 saving: boolean;
80 error?: string;
81 taskError?: string;
82 conflict?: SessionDraftView;
83 operation?: SessionDraftSubmissionView;
84 lifecycle: "active" | "converted" | "discarded";
85 timer?: number;
86 savePromise?: Promise<SessionDraftView | null>;
87 pendingTasks: Map<string, Promise<unknown>>;
88 preparingSubmission: boolean;
89 preparation?: DraftSubmissionCapture;
90 operationCapture?: DraftSubmissionCapture;
91 discarding?: boolean;
92 deferredContent?: PersistentComposerDraft;
93 lastAccess: number;
94 modelReadVersion: number;
95 };
96
97 type DraftSurfaceOptions = {
98 onAccepted(ref: SessionRef): Promise<void> | void;
99 onChanged(): void;
100 claimNavigationIntent?: () => number; currentNavigationIntent?: () => number;
101 isNavigationIntentCurrent?: (intent: number) => boolean;
102 };
103
104 function saveState(entry: DraftEntry): DraftSaveState {
105 if (entry.conflict) return "conflict";
106 if (entry.error) return "error";
107 if (entry.saving) return "saving";
108 if (entry.savedEditVersion < entry.editVersion) return "dirty";
109 return "saved";
110 }
111
112 function projectEntry(entry: DraftEntry): SessionDraftSurface {
113 return {
114 kind: "draft",
115 draft: entry.draft,
116 content: entry.content,
117 settings: entry.settings,
118 commands: entry.commands,
119 servers: entry.servers,
120 models: entry.models,
121 generation: entry.generation,
122 editVersion: entry.editVersion,
123 pendingTasks: entry.pendingTasks.size,
124 preparingSubmission: entry.preparingSubmission || Boolean(entry.discarding),
125 discarding: Boolean(entry.discarding),
126 saveState: saveState(entry),
127 submissionError: entry.submissionError,
128 resumingSubmission: entry.resumingSubmission,
129 error: entry.error,
130 taskError: entry.taskError,
131 conflict: entry.conflict,
132 operation: entry.operation,
133 };
134 }
135
136 function captureMatches(value: unknown, draftId: string, generation: number): value is DraftSubmissionCapture {
137 if (!value || typeof value !== "object") return false;
138 const capture = value as Partial<DraftSubmissionCapture>;
139 return capture.draftId === draftId && capture.generation === generation;
140 }
141
142 function pruneCleanEntries(entries: Map<string, DraftEntry>, visibleDraftId: string) {
143 const clean = [...entries.entries()]
144 .filter(([id, entry]) => id !== visibleDraftId && entry.lifecycle === "active"
145 && entry.savedEditVersion === entry.editVersion && !entry.saving && !entry.conflict && !entry.error && !entry.taskError
146 && !entry.operation && !entry.preparingSubmission && !entry.discarding && entry.pendingTasks.size === 0)
147 .sort((left, right) => right[1].lastAccess - left[1].lastAccess);
148 for (const [id] of clean.slice(20)) entries.delete(id);
149 }
150
151 export function useSessionDraftSurface(options: DraftSurfaceOptions) {
152 const { onAccepted, onChanged, claimNavigationIntent, isNavigationIntentCurrent } = options;
153 const entriesRef = useRef(new Map<string, DraftEntry>());
154 const visibleDraftIdRef = useRef<string | null>(null);
155 const [surface, setSurface] = useState<SessionDraftSurface | null>(null);
156 const [summaries, setSummaries] = useState<SessionDraftSummary[]>([]);
157 const [summaryError, setSummaryError] = useState<string>();
158 const openSequence = useRef(0);
159 const localIntent = useRef(0);
160 const restoreChain = useRef<Promise<void>>(Promise.resolve());
161 const submissionWaits = useRef(new Map<string, Promise<void>>());
162 const submissionStarts = useRef(new Map<string, Promise<void>>());
163 const convertedOperations = useRef(new Set<string>());
164 const acceptingExit = useRef(false);
165 const allTasks = useRef(new Map<string, Promise<unknown>>());
166 const preparationBarriers = useRef(new Map<string, { promise: Promise<void>; resolve(): void }>());
167 const disposed = useRef(false);
168 const observeOperation = useRef<(operation: SessionDraftSubmissionView, capture: DraftSubmissionCapture) => Promise<void>>(async () => {});
169
170 const intentCurrent = useCallback((intent: number) => (
171 isNavigationIntentCurrent ? isNavigationIntentCurrent(intent) : localIntent.current === intent
172 ), [isNavigationIntentCurrent]);
173
174 const claimIntent = useCallback(() => {
175 const intent = claimNavigationIntent?.() ?? ++localIntent.current;
176 localIntent.current = intent;
177 return intent;
178 }, [claimNavigationIntent]);
179
180 const publish = useCallback((draftId: string | null = visibleDraftIdRef.current) => {
181 if (!draftId) {
182 setSurface(null);
183 return;
184 }
185 const projected = entriesRef.current.get(draftId);
186 if (projected) {
187 setSummaries((current) => current.map((summary) => summary.id === draftId
188 ? { ...summary, state: saveState(projected) }
189 : summary));
190 }
191 if (visibleDraftIdRef.current !== draftId) return;
192 const entry = projected;
193 setSurface(entry && entry.lifecycle !== "discarded" ? projectEntry(entry) : null);
194 }, []);
195
196 const refreshSummaries = useCallback(async () => {
197 try {
198 const records = await app.ListSessionDraftSummaries();
199 setSummaryError(undefined);
200 setSummaries(records.map((summary) => {
201 const entry = entriesRef.current.get(summary.id);
202 return entry ? { ...summary, state: saveState(entry) } : summary;
203 }));
204 } catch (error) {
205 setSummaryError(error instanceof Error ? error.message : String(error));
206 }
207 }, []);
208
209 useEffect(() => { void refreshSummaries(); }, [refreshSummaries]);
210
211 useInheritedDraftModels(entriesRef, disposed, publish);
212
213 const queueRestoreTarget = useCallback((draftId: string, intent: number) => {
214 const next = restoreChain.current.catch(() => undefined).then(async () => {
215 if (!intentCurrent(intent)) return;
216 await app.SetSessionDraftRestoreTarget(draftId);
217 });
218 restoreChain.current = next;
219 return next;
220 }, [intentCurrent]);
221
222 const installDraft = useCallback(async (draft: SessionDraftView, sequence: number, intent: number) => {
223 const loadingEntry = entriesRef.current.get(draft.id);
224 const modelReadVersion = loadingEntry ? ++loadingEntry.modelReadVersion : 0;
225 const context = await app.GetDraftContext(draft.id);
226 if (sequence !== openSequence.current || !intentCurrent(intent)) return;
227 const existing = entriesRef.current.get(draft.id);
228 let entry: DraftEntry;
229 if (existing && existing.lifecycle === "active") {
230 existing.commands = (context.commands ?? []) as CommandInfo[];
231 existing.servers = context.servers ?? [];
232 const latestModelRead = existing === loadingEntry && existing.modelReadVersion === modelReadVersion;
233 if (latestModelRead) existing.models = context.models ?? [];
234 existing.visibleIntent = intent;
235 existing.lastAccess = Date.now();
236 if (existing.savedEditVersion === existing.editVersion && !existing.saving && !existing.conflict && !existing.error) {
237 const model = !latestModelRead && !draftSubmissionLocksEditing(context.operation)
238 && existing.settings.modelSource === "default" && context.draft.settings.modelSource === "default"
239 ? existing.settings.model : context.draft.settings.model;
240 existing.draft = context.draft;
241 existing.content = parseContent(context.draft.contentJson);
242 existing.settings = { ...context.draft.settings, model };
243 } else {
244 applyInheritedModel(existing, context.draft.settings, modelReadVersion);
245 }
246 entry = existing;
247 } else {
248 entry = {
249 draft: context.draft,
250 content: parseContent(context.draft.contentJson),
251 settings: context.draft.settings,
252 commands: (context.commands ?? []) as CommandInfo[],
253 servers: context.servers ?? [],
254 models: context.models ?? [],
255 generation: 1,
256 visibleIntent: intent,
257 editVersion: 0,
258 savedEditVersion: 0,
259 saving: false,
260 lifecycle: "active",
261 pendingTasks: new Map(),
262 preparingSubmission: false,
263 lastAccess: Date.now(),
264 modelReadVersion: 0,
265 };
266 entriesRef.current.set(draft.id, entry);
267 }
268 visibleDraftIdRef.current = draft.id;
269 if (context.operation) {
270 if (!entry.operation || entry.operation.operationId !== context.operation.operationId || entry.operation.revision <= context.operation.revision) entry.operation = context.operation;
271 const capture: DraftSubmissionCapture = {
272 handle: { draftId: draft.id, generation: entry.generation }, preparationId: "",
273 draftId: draft.id, generation: entry.generation, workspaceId: draft.workspaceId,
274 editVersion: entry.editVersion, navigationIntent: intent,
275 content: cloneDraftContent(entry.content), settings: cloneDraftSettings(entry.settings),
276 };
277 entry.operationCapture ??= capture;
278 void observeOperation.current(context.operation, entry.operationCapture).catch(() => undefined);
279 }
280 pruneCleanEntries(entriesRef.current, draft.id);
281 publish(draft.id);
282 await queueRestoreTarget(draft.id, intent);
283 if (sequence !== openSequence.current || !intentCurrent(intent) || visibleDraftIdRef.current !== draft.id) return;
284 window.requestAnimationFrame(() => {
285 if (sequence === openSequence.current && intentCurrent(intent) && visibleDraftIdRef.current === draft.id) document.getElementById("composer-input")?.focus();
286 });
287 const previewGeneration = entry.generation;
288 void Promise.all(entry.content.attachments.map(async (attachment) => {
289 try {
290 const previewUrl = await app.AttachmentDataURLForComposerTarget({ kind: "draft", draftId: draft.id }, attachment.path);
291 const current = entriesRef.current.get(draft.id);
292 if (!current || current.lifecycle !== "active" || current.generation !== previewGeneration) return;
293 current.content = {
294 ...current.content,
295 attachments: current.content.attachments.map((item) => item.path === attachment.path ? { ...item, previewUrl } : item),
296 };
297 publish(draft.id);
298 } catch {
299 // Missing attachments remain visible as repairable references.
300 }
301 }));
302 }, [intentCurrent, publish, queueRestoreTarget]);
303
304 const startSaveLoop = useCallback((draftId: string): Promise<SessionDraftView | null> => {
305 const entry = entriesRef.current.get(draftId);
306 if (!entry || entry.lifecycle !== "active") return Promise.resolve(null);
307 if (entry.timer != null) {
308 window.clearTimeout(entry.timer);
309 entry.timer = undefined;
310 }
311 if (entry.savePromise) return entry.savePromise;
312 const generation = entry.generation;
313 const run = (async () => {
314 while (entry.lifecycle === "active" && entry.generation === generation && !entry.conflict && entry.savedEditVersion < entry.editVersion) {
315 const capturedVersion = entry.editVersion;
316 const capturedRevision = entry.draft.revision;
317 const capturedContent = cloneDraftContent(entry.content);
318 const capturedSettings = cloneDraftSettings(entry.settings);
319 const modelReadVersion = ++entry.modelReadVersion;
320 const capturedJSON = contentJSON(capturedContent);
321 entry.saving = true;
322 entry.error = undefined;
323 publish(draftId);
324 try {
325 const result = await app.SaveSessionDraft({
326 draftId,
327 revision: capturedRevision,
328 contentJson: capturedJSON,
329 settings: capturedSettings,
330 force: false,
331 });
332 if (entry.lifecycle !== "active" || entry.generation !== generation) return null;
333 if (result.outcome === "converted" || result.outcome === "discarded") {
334 entry.lifecycle = result.outcome;
335 entry.generation++;
336 return null;
337 }
338 if (result.conflict || result.outcome === "conflict") {
339 entry.conflict = result.draft;
340 return null;
341 }
342 if (result.outcome === "operation_locked") {
343 entry.error = "This draft is locked by its submission operation.";
344 return null;
345 }
346 entry.draft = result.draft;
347 applyInheritedModel(entry, result.draft.settings, modelReadVersion);
348 entry.savedEditVersion = Math.max(entry.savedEditVersion, capturedVersion);
349 entry.conflict = undefined;
350 entry.error = undefined;
351 void refreshSummaries();
352 } catch (error) {
353 if (entry.lifecycle !== "active" || entry.generation !== generation) return null;
354 try {
355 const confirmed = await app.GetSessionDraft(draftId);
356 if (confirmed.status !== "active") {
357 entry.lifecycle = confirmed.status === "converted" ? "converted" : "discarded";
358 entry.generation++;
359 return null;
360 }
361 if (confirmed.contentJson === capturedJSON && sameDraftSettings(confirmed.settings, capturedSettings)) {
362 entry.draft = confirmed;
363 applyInheritedModel(entry, confirmed.settings, modelReadVersion);
364 entry.savedEditVersion = Math.max(entry.savedEditVersion, capturedVersion);
365 entry.error = undefined;
366 continue;
367 }
368 if (confirmed.revision !== capturedRevision) {
369 entry.conflict = confirmed;
370 return null;
371 }
372 } catch {
373 // Preserve the original error when acknowledgement verification fails.
374 }
375 entry.error = error instanceof Error ? error.message : String(error);
376 return null;
377 } finally {
378 entry.saving = false;
379 publish(draftId);
380 }
381 }
382 return entry.savedEditVersion >= entry.editVersion ? entry.draft : null;
383 })();
384 const tracked = run.finally(() => {
385 if (entry.savePromise === tracked) entry.savePromise = undefined;
386 publish(draftId);
387 });
388 entry.savePromise = tracked;
389 return tracked;
390 }, [publish, refreshSummaries]);
391
392 const flushDraft = useCallback(async (draftId: string, targetVersion?: number): Promise<SessionDraftView | null> => {
393 const entry = entriesRef.current.get(draftId);
394 if (!entry || entry.lifecycle !== "active") return null;
395 const requiredVersion = targetVersion ?? entry.editVersion;
396 while (entry.lifecycle === "active" && entry.savedEditVersion < requiredVersion) {
397 if (entry.conflict || entry.error) return null;
398 await startSaveLoop(draftId);
399 if (entry.savedEditVersion >= requiredVersion) break;
400 if (entry.conflict || entry.error || entry.lifecycle !== "active") return null;
401 }
402 return entry.savedEditVersion >= requiredVersion ? entry.draft : null;
403 }, [startSaveLoop]);
404
405 const scheduleSave = useCallback((entry: DraftEntry) => {
406 if (entry.timer != null) window.clearTimeout(entry.timer);
407 entry.timer = window.setTimeout(() => {
408 entry.timer = undefined;
409 void startSaveLoop(entry.draft.id);
410 }, 250);
411 }, [startSaveLoop]);
412
413 const updateContentFor = useCallback((draftId: string, generation: number, content: PersistentComposerDraft) => {
414 const entry = entriesRef.current.get(draftId);
415 if (!entry || entry.lifecycle !== "active" || entry.generation !== generation) return;
416 if (entry.preparingSubmission || draftSubmissionLocksEditing(entry.operation)) return;
417 if (entry.discarding) { entry.deferredContent = cloneDraftContent(content); return; }
418 // A task registered before the exit barrier may still publish its captured
419 // attachment/reference. Ordinary edits remain frozen while quitting.
420 if (acceptingExit.current && entry.pendingTasks.size === 0) return;
421 if (contentJSON(content) === contentJSON(entry.content)) return;
422 entry.content = cloneDraftContent(content);
423 entry.editVersion++;
424 entry.error = undefined;
425 entry.lastAccess = Date.now();
426 scheduleSave(entry);
427 publish(draftId);
428 }, [publish, scheduleSave]);
429
430 const updateSettingsFor = useCallback((draftId: string, generation: number, patch: Partial<SessionDraftSettings>) => {
431 if (acceptingExit.current) return;
432 const entry = entriesRef.current.get(draftId);
433 if (!entry || entry.lifecycle !== "active" || entry.generation !== generation) return;
434 if (entry.preparingSubmission || entry.discarding || draftSubmissionLocksEditing(entry.operation)) return;
435 const next = { ...entry.settings, ...patch };
436 if (sameDraftSettings(next, entry.settings)) return;
437 entry.settings = next;
438 entry.editVersion++;
439 entry.error = undefined;
440 entry.lastAccess = Date.now();
441 scheduleSave(entry);
442 publish(draftId);
443 }, [publish, scheduleSave]);
444
445 const patchContentFor = useCallback((draftId: string, generation: number, patch: Partial<PersistentComposerDraft> | ((content: PersistentComposerDraft) => PersistentComposerDraft)) => {
446 const entry = entriesRef.current.get(draftId);
447 if (!entry || entry.generation !== generation) return;
448 const base = entry.deferredContent ?? entry.content;
449 updateContentFor(draftId, generation, typeof patch === "function" ? patch(cloneDraftContent(base)) : { ...base, ...patch });
450 }, [updateContentFor]);
451
452 const isCurrentHandle = useCallback((draftId: string, generation: number) => {
453 const entry = entriesRef.current.get(draftId);
454 return Boolean(entry && entry.lifecycle === "active" && entry.generation === generation);
455 }, []);
456
457 const canEditHandle = useCallback((draftId: string, generation: number) => {
458 const entry = entriesRef.current.get(draftId);
459 return Boolean(!acceptingExit.current && entry && entry.lifecycle === "active" && entry.generation === generation && !entry.preparingSubmission && !entry.discarding && !draftSubmissionLocksEditing(entry.operation));
460 }, []);
461
462 const updateContent = useCallback((content: PersistentComposerDraft) => {
463 const id = visibleDraftIdRef.current;
464 const entry = id ? entriesRef.current.get(id) : undefined;
465 if (id && entry) updateContentFor(id, entry.generation, content);
466 }, [updateContentFor]);
467
468 const updateSettings = useCallback((patch: Partial<SessionDraftSettings>) => {
469 const id = visibleDraftIdRef.current;
470 const entry = id ? entriesRef.current.get(id) : undefined;
471 if (id && entry) updateSettingsFor(id, entry.generation, patch);
472 }, [updateSettingsFor]);
473
474 const open = useCallback(async (scope: string, workspaceRoot: string) => {
475 if (acceptingExit.current) return;
476 const intent = claimIntent();
477 const sequence = ++openSequence.current;
478 const sourceId = visibleDraftIdRef.current;
479 if (sourceId) void flushDraft(sourceId);
480 const cached = [...entriesRef.current.values()].find(entry => entry.lifecycle === "active"
481 && entry.draft.scope === scope && (scope !== "project" || entry.draft.workspaceRoot === workspaceRoot));
482 if (cached) {
483 cached.visibleIntent = intent;
484 cached.lastAccess = Date.now();
485 visibleDraftIdRef.current = cached.draft.id;
486 publish(cached.draft.id);
487 void queueRestoreTarget(cached.draft.id, intent);
488 window.requestAnimationFrame(() => {
489 if (sequence === openSequence.current && intentCurrent(intent)) document.getElementById("composer-input")?.focus();
490 });
491 }
492 const draft = await app.OpenSessionDraftForTarget(scope, scope === "project" ? workspaceRoot : "");
493 if (sequence !== openSequence.current || !intentCurrent(intent)) return;
494 await installDraft(draft, sequence, intent);
495 if (sequence === openSequence.current && intentCurrent(intent)) await refreshSummaries();
496 }, [claimIntent, flushDraft, installDraft, intentCurrent, publish, queueRestoreTarget, refreshSummaries]);
497
498 const initializeEmptySurface = useCallback(async () => {
499 await refreshSummaries();
500 }, [refreshSummaries]);
501
502 const dismiss = useCallback(() => {
503 ++openSequence.current;
504 const id = visibleDraftIdRef.current;
505 visibleDraftIdRef.current = null;
506 publish(null);
507 if (id) void flushDraft(id);
508 const next = restoreChain.current.catch(() => undefined).then(async () => {
509 if (id) await app.DismissSessionDraft(id);
510 else await app.SetSessionDraftRestoreTarget("");
511 });
512 restoreChain.current = next;
513 return next;
514 }, [flushDraft, publish]);
515
516 const useSavedConflict = useCallback(() => {
517 const id = visibleDraftIdRef.current;
518 const entry = id ? entriesRef.current.get(id) : undefined;
519 if (!id || !entry?.conflict) return;
520 const saved = entry.conflict;
521 entry.generation++;
522 entry.pendingTasks.clear();
523 entry.draft = saved;
524 entry.content = parseContent(saved.contentJson);
525 entry.settings = saved.settings;
526 entry.editVersion++;
527 entry.savedEditVersion = entry.editVersion;
528 entry.conflict = undefined;
529 entry.error = undefined;
530 publish(id);
531 }, [publish]);
532
533 const keepLocalConflict = useCallback(async () => {
534 const id = visibleDraftIdRef.current;
535 const entry = id ? entriesRef.current.get(id) : undefined;
536 if (!id || !entry?.conflict) return;
537 entry.draft = entry.conflict;
538 entry.conflict = undefined;
539 entry.error = undefined;
540 publish(id);
541 await flushDraft(id, entry.editVersion);
542 }, [flushDraft, publish]);
543
544 const retrySave = useCallback(async () => {
545 const id = visibleDraftIdRef.current;
546 const entry = id ? entriesRef.current.get(id) : undefined;
547 if (!id || !entry || entry.lifecycle !== "active" || entry.conflict) return;
548 entry.error = undefined;
549 publish(id);
550 await flushDraft(id, entry.editVersion);
551 }, [flushDraft, publish]);
552
553 const setMCPEnabled = useCallback((server: ServerView, enabled: boolean) => {
554 const id = visibleDraftIdRef.current;
555 const entry = id ? entriesRef.current.get(id) : undefined;
556 if (!id || !entry) return;
557 const disabledMcp = { ...entry.settings.disabledMcp };
558 if (enabled) delete disabledMcp[server.name];
559 else disabledMcp[server.name] = server;
560 const mcpOrder = entry.settings.mcpOrder.includes(server.name)
561 ? entry.settings.mcpOrder
562 : [...entry.settings.mcpOrder, server.name];
563 updateSettingsFor(id, entry.generation, { disabledMcp, mcpOrder });
564 }, [updateSettingsFor]);
565
566 const trackTask = useCallback(<T,>(draftId: string, generation: number, promise: Promise<T>): Promise<T> => {
567 const entry = entriesRef.current.get(draftId);
568 const taskId = crypto.randomUUID();
569 const tracked = promise.finally(() => {
570 allTasks.current.delete(taskId);
571 const current = entriesRef.current.get(draftId);
572 if (!current) return;
573 current.pendingTasks.delete(taskId);
574 publish(draftId);
575 });
576 allTasks.current.set(taskId, tracked);
577 if (entry?.lifecycle === "active" && entry.generation === generation) entry.pendingTasks.set(taskId, tracked);
578 publish(draftId);
579 return tracked;
580 }, [publish]);
581
582 const reportTaskError = useCallback((draftId: string, generation: number, message: string) => {
583 const entry = entriesRef.current.get(draftId);
584 if (!entry || entry.lifecycle !== "active" || entry.generation !== generation) return;
585 entry.taskError = message || undefined;
586 publish(draftId);
587 }, [publish]);
588
589 const captureSubmission = useCallback((draftId: string, generation: number, content?: PersistentComposerDraft): DraftSubmissionCapture | null => {
590 if (acceptingExit.current) return null;
591 const entry = entriesRef.current.get(draftId);
592 if (!entry || entry.lifecycle !== "active" || entry.generation !== generation) return null;
593 if (entry.preparingSubmission || entry.discarding || entry.conflict || entry.pendingTasks.size || draftSubmissionLocksEditing(entry.operation)) return null;
594 if (content) updateContentFor(draftId, generation, content);
595 const capture = Object.freeze({
596 handle: Object.freeze({ draftId, generation }),
597 preparationId: crypto.randomUUID(),
598 draftId,
599 workspaceId: entry.draft.workspaceId,
600 generation,
601 editVersion: entry.editVersion,
602 navigationIntent: entry.visibleIntent,
603 content: cloneDraftContent(entry.content),
604 settings: cloneDraftSettings(entry.settings),
605 });
606 const commandName = /^\/([^\s]+)/.exec(entry.content.text.trim())?.[1] ?? "";
607 if (["model", "effort", "theme"].includes(commandName) || entry.commands.some((command) => command.name === commandName && command.draftBehavior && command.draftBehavior !== "submit")) return capture;
608 entry.preparation = capture;
609 entry.preparingSubmission = true;
610 let resolve!: () => void;
611 const promise = new Promise<void>((done) => { resolve = done; });
612 preparationBarriers.current.set(capture.preparationId, { promise, resolve });
613 publish(draftId);
614 return capture;
615 }, [publish, updateContentFor]);
616
617 const releasePreparation = useCallback((value: unknown) => {
618 const capture = value as DraftSubmissionCapture | undefined;
619 if (!capture?.preparationId) return;
620 const entry = entriesRef.current.get(capture.draftId);
621 if (entry?.generation === capture.generation && entry.preparation?.preparationId === capture.preparationId) {
622 entry.preparation = undefined;
623 entry.preparingSubmission = false;
624 publish(capture.draftId);
625 }
626 preparationBarriers.current.get(capture.preparationId)?.resolve();
627 preparationBarriers.current.delete(capture.preparationId);
628 }, [publish]);
629
630 const flushPreparation = useCallback(async (value: unknown) => {
631 const capture = value as DraftSubmissionCapture | undefined;
632 if (!capture) return;
633 const entry = entriesRef.current.get(capture.draftId);
634 if (entry?.preparation?.preparationId !== capture.preparationId) return;
635 const saved = await flushDraft(capture.draftId, capture.editVersion);
636 if (!saved || canonicalJSON(parseContent(saved.contentJson)) !== canonicalJSON(parseContent(contentJSON(capture.content))) || !sameDraftSettings(saved.settings, capture.settings)) throw new Error("Save the captured draft before submitting.");
637 }, [flushDraft]);
638
639 const waitForSubmission = useCallback(async (initial: SessionDraftSubmissionView, capture: DraftSubmissionCapture) => {
640 let operation = initial;
641 const started = Date.now();
642 let failures = 0;
643 for (;;) {
644 if (disposed.current) return;
645 if (convertedOperations.current.has(initial.operationId)) return;
646 const entry = entriesRef.current.get(capture.draftId);
647 if (entry?.operation && entry.operation.operationId !== initial.operationId) return;
648 if (entry?.lifecycle === "converted" && operation.phase === "accepted") return;
649 if (entry && entry.generation === capture.generation) {
650 if (entry.operation?.operationId === operation.operationId && (entry.operation.revision ?? 0) > (operation.revision ?? 0)) operation = entry.operation;
651 entry.operation = operation;
652 publish(capture.draftId);
653 }
654 if (operation.phase === "accepted") {
655 convertedOperations.current.add(operation.operationId);
656 await refreshSummaries();
657 const current = entriesRef.current.get(capture.draftId);
658 const ownsPage = Boolean(operation.session && current && current.generation === capture.generation
659 && visibleDraftIdRef.current === capture.draftId && intentCurrent(capture.navigationIntent));
660 if (current && current.generation === capture.generation) {
661 current.lifecycle = "converted";
662 current.pendingTasks.clear();
663 if (visibleDraftIdRef.current !== capture.draftId || ownsPage) entriesRef.current.delete(capture.draftId);
664 }
665 if (ownsPage) {
666 visibleDraftIdRef.current = null;
667 publish(null);
668 } else publish(capture.draftId);
669 if (ownsPage && operation.session) await onAccepted(operation.session);
670 onChanged();
671 return;
672 }
673 if (["terminal_failed", "cancelled", "resume_required", "runtime_failed"].includes(operation.phase)) {
674 if (operation.phase === "terminal_failed") throw new Error(operation.error || "Unable to start this session.");
675 return;
676 }
677 const delay = failures || operation.phase === "dispatch_unknown" ? [1000, 2000, 5000][Math.min(failures, 2)] : Date.now() - started < 5000 ? 250 : 1000;
678 await new Promise((resolve) => window.setTimeout(resolve, delay));
679 if (disposed.current) return;
680 try { operation = await app.GetDraftSubmission(initial.operationId); failures = 0; }
681 catch { failures++; }
682 }
683 }, [intentCurrent, onAccepted, onChanged, publish, refreshSummaries]);
684
685 const waitForSubmissionOnce = useCallback((operation: SessionDraftSubmissionView, capture: DraftSubmissionCapture) => {
686 const existing = submissionWaits.current.get(operation.operationId);
687 if (existing) return existing;
688 const pending = waitForSubmission(operation, capture).finally(() => {
689 if (submissionWaits.current.get(operation.operationId) === pending) submissionWaits.current.delete(operation.operationId);
690 });
691 submissionWaits.current.set(operation.operationId, pending);
692 return pending;
693 }, [waitForSubmission]);
694 observeOperation.current = waitForSubmissionOnce;
695
696 const submitFrom = useCallback(async (
697 draftId: string,
698 generation: number,
699 display: string,
700 input = display,
701 _tabId?: string,
702 structured?: StructuredInvocationSubmit,
703 capturedValue?: unknown,
704 ) => {
705 const capture = captureMatches(capturedValue, draftId, generation) ? capturedValue : captureSubmission(draftId, generation);
706 if (!capture) return;
707 const inFlight = submissionStarts.current.get(draftId);
708 if (inFlight) return inFlight;
709 const pending = (async () => {
710 const source = entriesRef.current.get(draftId);
711 if (!source || source.generation !== generation || source.lifecycle !== "active") return;
712 const trimmedDisplay = display.trim();
713 const commandName = /^\/([^\s]+)/.exec(trimmedDisplay)?.[1] ?? "";
714 const command = source.commands.find((item) => item.name === commandName);
715 if (["model", "effort", "theme"].includes(commandName) || (command?.draftBehavior && command.draftBehavior !== "submit")) releasePreparation(capture);
716 if (command?.draftBehavior === "unavailable") throw new Error("This command needs an existing session.");
717 const model = /^\/model\s+(\S+)$/.exec(trimmedDisplay);
718 if (model) { updateSettingsFor(draftId, generation, { model: model[1], modelSource: "explicit" }); return; }
719 const effort = /^\/effort\s+(\S+)$/.exec(trimmedDisplay);
720 if (effort) { updateSettingsFor(draftId, generation, { effort: effort[1] }); return; }
721 const theme = /^\/theme\s+(\S+)$/.exec(trimmedDisplay);
722 if (theme) {
723 const value = theme[1].toLowerCase();
724 const experience = await import("../lib/themeExperience");
725 if (value === "auto" || value === "light" || value === "dark") {
726 await experience.setThemeMode(value);
727 return;
728 }
729 const themeModule = await import("../lib/theme");
730 const known = new Set(["graphite", "aurora", "slate", "carbon", "nocturne", "amber", "ember", "midnight", "sandstone", "porcelain", "linen", "glacier"]);
731 if (!known.has(value)) throw new Error(`Unknown theme: ${value}`);
732 await experience.activateBaseStyle(themeModule.normalizeThemeStyleForTheme(value));
733 return;
734 }
735 if (command?.draftBehavior === "direct") throw new Error("This command needs an argument and does not create a session.");
736 if (source.pendingTasks.size > 0) throw new Error("Wait for attachments to finish before sending.");
737 source.preparingSubmission = true;
738 publish(draftId);
739 const saved = await flushDraft(draftId, capture.editVersion);
740 if (!saved || saved.id !== draftId) throw new Error("Resolve the draft save conflict before sending.");
741 if (contentJSON(parseContent(saved.contentJson)) !== contentJSON(capture.content) || !sameDraftSettings(saved.settings, capture.settings)) throw new Error("The draft changed after submission was captured. Review it before sending.");
742 const shell = trimmedDisplay.startsWith("!");
743 let requestDisplay = structured?.display ?? display;
744 let requestInput = shell ? trimmedDisplay.slice(1).trim() : structured?.input ?? input;
745 let goal = capture.settings.goal;
746 let collaborationMode = capture.settings.collaborationMode;
747 let toolApprovalMode = capture.settings.toolApprovalMode;
748 if (!shell && collaborationMode === "goal" && !goal) {
749 const initial = buildInitialGoalSubmission(
750 { display: requestDisplay, submit: requestInput, structured },
751 collaborationMode as CollaborationMode,
752 toolApprovalMode as ToolApprovalMode,
753 );
754 requestDisplay = initial.display;
755 requestInput = initial.submit ?? initial.display;
756 goal = initial.initialGoal?.goal ?? "";
757 collaborationMode = initial.initialGoal?.collaborationMode ?? collaborationMode;
758 toolApprovalMode = initial.initialGoal?.toolApprovalMode ?? toolApprovalMode;
759 }
760 const request = {
761 snapshotVersion: 5,
762 requestId: capture.preparationId,
763 sourceDigest: saved.snapshotDigest ?? "",
764 draftId,
765 revision: saved.revision,
766 kind: shell ? "shell" : "turn",
767 display: requestDisplay,
768 input: requestInput,
769 invocations: structured?.invocations ?? [],
770 goal,
771 collaborationMode,
772 toolApprovalMode,
773 workspaceRefs: capture.content.workspaceRefs,
774 settings: capture.settings,
775 };
776 let operation: SessionDraftSubmissionView;
777 try { operation = await app.BeginDraftSubmission(request); }
778 catch (error) {
779 if (String(error).includes("draft submission not admitted:") || String(error).includes("reasonix_error:")) throw error;
780 // A transport error does not prove that Begin failed. Keep the source
781 // frozen while read-only reconciliation is unavailable.
782 for (;;) {
783 if (disposed.current) throw error;
784 let state;
785 try { state = await app.GetSessionDraftState(draftId); }
786 catch {
787 source.error = "Verifying whether the submission was received. Reconnecting…";
788 publish(draftId);
789 await new Promise(resolve => window.setTimeout(resolve, 2000));
790 continue;
791 }
792 if (state.operation && (!state.operation.requestId || state.operation.requestId === capture.preparationId)) {
793 operation = state.operation;
794 } else {
795 // A read may race the original Begin before its transaction. Retry
796 // the exact request ID, whose backend lock serializes the decision.
797 try { operation = await app.BeginDraftSubmission(request); }
798 catch (retryError) {
799 if (String(retryError).includes("draft submission not admitted:") || String(retryError).includes("reasonix_error:")) throw retryError;
800 source.error = "Verifying whether the submission was received. Reconnecting…";
801 publish(draftId);
802 await new Promise(resolve => window.setTimeout(resolve, 2000));
803 continue;
804 }
805 }
806 source.error = undefined;
807 break;
808 }
809 }
810 const current = entriesRef.current.get(draftId);
811 if (current && current.generation === generation) {
812 current.operation = operation;
813 current.operationCapture = capture;
814 publish(draftId);
815 }
816 releasePreparation(capture);
817 await waitForSubmissionOnce(operation, capture);
818 })().finally(() => {
819 releasePreparation(capture);
820 if (submissionStarts.current.get(draftId) === pending) submissionStarts.current.delete(draftId);
821 });
822 submissionStarts.current.set(draftId, pending);
823 return pending;
824 }, [captureSubmission, flushDraft, publish, releasePreparation, updateSettingsFor, waitForSubmissionOnce]);
825
826 const submit = useCallback((display: string, input = display, tabId?: string, structured?: StructuredInvocationSubmit, captured?: unknown) => {
827 const id = visibleDraftIdRef.current;
828 const entry = id ? entriesRef.current.get(id) : undefined;
829 if (!id || !entry) return Promise.resolve();
830 return submitFrom(id, entry.generation, display, input, tabId, structured, captured);
831 }, [submitFrom]);
832
833 const cancelSubmission = useCallback(async () => {
834 const id = visibleDraftIdRef.current;
835 const entry = id ? entriesRef.current.get(id) : undefined;
836 const operation = entry?.operation;
837 if (!id || !entry || !operation) return;
838 const generation = entry.generation;
839 const next = await app.CancelDraftSubmission(operation.operationId);
840 const current = entriesRef.current.get(id);
841 if (current && current.generation === generation && current.operation?.operationId === operation.operationId) {
842 if ((current.operation.revision ?? 0) > (next.revision ?? 0)) return;
843 current.operation = next;
844 publish(id);
845 if (current.operationCapture) void (next.phase === "accepted" ? waitForSubmission(next, current.operationCapture) : waitForSubmissionOnce(next, current.operationCapture)).catch(() => undefined);
846 }
847 }, [publish, waitForSubmission, waitForSubmissionOnce]);
848
849 const resumeSubmission = useCallback(async () => {
850 const id = visibleDraftIdRef.current;
851 const entry = id ? entriesRef.current.get(id) : undefined;
852 if (!id || !entry?.operation?.canResume || entry.resumingSubmission) return;
853 const { operationId, revision } = entry.operation;
854 entry.resumingSubmission = true;
855 entry.submissionError = undefined;
856 publish(id);
857 const capture = { handle: { draftId: id, generation: entry.generation }, preparationId: "", draftId: id, generation: entry.generation, workspaceId: entry.draft.workspaceId, editVersion: entry.editVersion, navigationIntent: entry.visibleIntent, content: cloneDraftContent(entry.content), settings: cloneDraftSettings(entry.settings) };
858 try {
859 const next = await app.ResumeDraftSubmission(operationId, revision);
860 if (entriesRef.current.get(id) !== entry || entry.generation !== capture.generation || entry.operation?.operationId !== operationId) return;
861 entry.operationCapture = capture;
862 await waitForSubmissionOnce(next, capture);
863 } catch (error) {
864 if (entriesRef.current.get(id) === entry && entry.generation === capture.generation
865 && entry.operation?.operationId === operationId && entry.operation.revision <= revision) {
866 entry.submissionError = error instanceof Error ? error.message : String(error);
867 }
868 } finally {
869 entry.resumingSubmission = false;
870 if (entriesRef.current.get(id) === entry) publish(id);
871 }
872 }, [publish, waitForSubmissionOnce]);
873
874 const openAcceptedSession = useCallback(async () => {
875 const entry = entriesRef.current.get(visibleDraftIdRef.current ?? "");
876 if (entry?.operation?.phase === "accepted" && entry.operation.session) await onAccepted(entry.operation.session);
877 }, [onAccepted]);
878
879 const refreshSubmission = useCallback(async () => {
880 const entry = entriesRef.current.get(visibleDraftIdRef.current ?? "");
881 if (!entry?.operation || !entry.operationCapture) return;
882 const operationId = entry.operation.operationId;
883 const generation = entry.generation;
884 try {
885 const next = await app.GetDraftSubmission(operationId);
886 if (entriesRef.current.get(entry.draft.id) !== entry || entry.generation !== generation || entry.operation?.operationId !== operationId) return;
887 if ((entry.operation.revision ?? 0) <= (next.revision ?? 0)) entry.operation = next;
888 publish(entry.draft.id);
889 void waitForSubmissionOnce(entry.operation, entry.operationCapture).catch(() => undefined);
890 } catch { /* Connection state does not replace the durable operation. */ }
891 }, [publish, waitForSubmissionOnce]);
892
893 useEffect(() => {
894 const online = () => {
895 for (const entry of entriesRef.current.values()) {
896 if (entry.lifecycle !== "active" || !entry.operation || !entry.operationCapture || !draftSubmissionLocksEditing(entry.operation)) continue;
897 const id = entry.operation.operationId;
898 void app.GetDraftSubmission(id).then(next => {
899 if (entry.operation?.operationId === id && entry.operation.revision <= next.revision) {
900 entry.operation = next;
901 publish(entry.draft.id);
902 void observeOperation.current(next, entry.operationCapture!).catch(() => undefined);
903 }
904 }).catch(() => undefined);
905 }
906 };
907 window.addEventListener("online", online);
908 return () => window.removeEventListener("online", online);
909 }, [publish]);
910
911 const discard = useCallback(async (handle?: DraftHandle, expectedVersion?: number, expectedRevision?: number, expectedIntent?: number) => {
912 const id = handle?.draftId ?? visibleDraftIdRef.current;
913 const entry = id ? entriesRef.current.get(id) : undefined;
914 if (!id || !entry) return;
915 const generation = handle?.generation ?? entry.generation;
916 const intent = expectedIntent ?? entry.visibleIntent;
917 if (entry.generation !== generation || (expectedVersion != null && entry.editVersion !== expectedVersion)
918 || (expectedRevision != null && (entry.conflict?.revision ?? entry.draft.revision) !== expectedRevision)) throw new Error("The draft changed. Confirm discarding its current contents again.");
919 if (entry.discarding) return;
920 if (entry.preparingSubmission || draftSubmissionLocksEditing(entry.operation)) throw new Error("Cancel the submission before discarding this draft.");
921 entry.discarding = true;
922 publish(id);
923 try { await app.DiscardSessionDraft(id, expectedRevision ?? entry.conflict?.revision ?? entry.draft.revision); }
924 catch (error) {
925 entry.discarding = false;
926 if (entry.deferredContent) { const content = entry.deferredContent; entry.deferredContent = undefined; updateContentFor(id, generation, content); }
927 publish(id);
928 throw error;
929 }
930 entry.lifecycle = "discarded";
931 entry.generation++;
932 if (entry.timer != null) window.clearTimeout(entry.timer);
933 entriesRef.current.delete(id);
934 if (visibleDraftIdRef.current === id && intentCurrent(intent)) {
935 visibleDraftIdRef.current = null;
936 publish(null);
937 }
938 await refreshSummaries();
939 onChanged();
940 }, [intentCurrent, onChanged, publish, refreshSummaries, updateContentFor]);
941 const confirmDiscard = useCallback(async (labels: { title: string; message: string; detail: string; confirmLabel: string; cancelLabel: string }) => {
942 const id = visibleDraftIdRef.current;
943 const entry = id ? entriesRef.current.get(id) : undefined;
944 if (!entry) return;
945 if (entry.savePromise) await entry.savePromise;
946 const handle = { draftId: entry.draft.id, generation: entry.generation };
947 const version = entry.editVersion;
948 const revision = entry.conflict?.revision ?? entry.draft.revision;
949 const intent = entry.visibleIntent;
950 const content = entry.content;
951 const hasContent = Boolean(content.text.trim() || content.attachments.length || content.workspaceRefs.length || content.invocations.length || content.pastedBlocks.length || content.sessionRefs.length || content.selectedTextRefs.length);
952 if (hasContent) {
953 const confirmed = await app.ConfirmAction({ ...labels, detail: `${entry.draft.workspaceRoot || "Global workspace"}\n${labels.detail}`, destructive: true });
954 if (!confirmed) return;
955 }
956 await discard(handle, version, revision, intent);
957 }, [discard]);
958
959 const flush = useCallback(() => {
960 const id = visibleDraftIdRef.current;
961 return id ? flushDraft(id) : Promise.resolve(null);
962 }, [flushDraft]);
963
964 useEffect(() => { disposed.current = false; const entries = entriesRef.current; return () => {
965 disposed.current = true;
966 for (const entry of entries.values()) {
967 if (entry.timer != null) window.clearTimeout(entry.timer);
968 void startSaveLoop(entry.draft.id);
969 }
970 }; }, [startSaveLoop]);
971
972 useEffect(() => {
973 const flushBeforeUnload = () => {
974 for (const entry of entriesRef.current.values()) void startSaveLoop(entry.draft.id);
975 };
976 window.addEventListener("beforeunload", flushBeforeUnload);
977 return () => window.removeEventListener("beforeunload", flushBeforeUnload);
978 }, [startSaveLoop]);
979
980 useEffect(() => {
981 const flushAll = async () => {
982 acceptingExit.current = true;
983 try {
984 await (await import("../lib/sessionComposerPersistence")).flushAllSessionComposers();
985 while (allTasks.current.size) await Promise.allSettled([...allTasks.current.values()]);
986 await Promise.all([...preparationBarriers.current.values()].map((barrier) => barrier.promise));
987 const entries = [...entriesRef.current.values()].filter((entry) => entry.lifecycle === "active");
988 await Promise.all(entries.map(async (entry) => {
989 for (;;) {
990 await Promise.allSettled([...entry.pendingTasks.values()]);
991 const requiredVersion = entry.editVersion;
992 const saved = await flushDraft(entry.draft.id, requiredVersion);
993 if (entry.conflict || entry.error) {
994 throw new Error(`Draft ${entry.draft.id} could not be saved before exit.`);
995 }
996 if (entry.pendingTasks.size === 0 && saved && entry.savedEditVersion >= entry.editVersion) break;
997 if (entry.lifecycle !== "active") break;
998 }
999 }));
1000 await restoreChain.current;
1001 } catch (error) {
1002 acceptingExit.current = false;
1003 (await import("../lib/sessionComposerPersistence")).resumeSessionComposerEditing();
1004 throw error;
1005 }
1006 };
1007 const resumeEditing = () => {
1008 acceptingExit.current = false;
1009 void import("../lib/sessionComposerPersistence").then(module => module.resumeSessionComposerEditing());
1010 };
1011 window.__reasonixFlushSessionDraft = flushAll;
1012 window.__reasonixResumeSessionDraftEditing = resumeEditing;
1013 return () => {
1014 if (window.__reasonixFlushSessionDraft === flushAll) delete window.__reasonixFlushSessionDraft;
1015 if (window.__reasonixResumeSessionDraftEditing === resumeEditing) delete window.__reasonixResumeSessionDraftEditing;
1016 };
1017 }, [flushDraft]);
1018
1019 return {
1020 surface,
1021 summaries,
1022 summaryError,
1023 open,
1024 initializeEmptySurface,
1025 dismiss,
1026 flush,
1027 flushDraft,
1028 updateContent,
1029 updateContentFor,
1030 patchContentFor,
1031 isCurrentHandle,
1032 canEditHandle,
1033 updateSettings,
1034 updateSettingsFor,
1035 useSavedConflict,
1036 keepLocalConflict,
1037 retrySave,
1038 setMCPEnabled,
1039 trackTask,
1040 reportTaskError,
1041 captureSubmission,
1042 beginSubmissionPreparation: captureSubmission,
1043 releasePreparation,
1044 flushPreparation,
1045 submit,
1046 submitFrom,
1047 cancelSubmission,
1048 resumeSubmission,
1049 openAcceptedSession,
1050 refreshSubmission,
1051 discard,
1052 confirmDiscard,
1053 refreshSummaries,
1054 };
1055 }
1056
1056 lines TYPESCRIPT