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