@lincyaw/workbench 0.0.0-stage → 0.2.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +66 -2
  3. package/package.json +68 -3
  4. package/src/MediaClipEditor.tsx +122 -0
  5. package/src/StepProductionEditor.tsx +130 -0
  6. package/src/approvals.tsx +60 -0
  7. package/src/assistant-mascot.ts +62 -0
  8. package/src/audio-recording-messages.ts +44 -0
  9. package/src/audio-recording.ts +192 -0
  10. package/src/audio-wav.ts +25 -0
  11. package/src/audio-waveform.ts +110 -0
  12. package/src/audition.ts +84 -0
  13. package/src/authoring-client.ts +136 -0
  14. package/src/authoring-draft-fragment.ts +28 -0
  15. package/src/base-controls.tsx +47 -0
  16. package/src/caption-timing-drafts.ts +61 -0
  17. package/src/caption-timing-messages.ts +14 -0
  18. package/src/caption-timing.css +20 -0
  19. package/src/caption-timing.ts +14 -0
  20. package/src/component-draft-actions.ts +54 -0
  21. package/src/component-draft-inspection.ts +35 -0
  22. package/src/component-properties.css +24 -0
  23. package/src/component-properties.ts +45 -0
  24. package/src/component-property-drafts.ts +273 -0
  25. package/src/component-property-messages.ts +31 -0
  26. package/src/components.tsx +631 -0
  27. package/src/context.tsx +27 -0
  28. package/src/diagnostics.ts +113 -0
  29. package/src/document-draft-store.ts +179 -0
  30. package/src/document-drafts.ts +178 -0
  31. package/src/document-editor/BlockNoteSurface.tsx +209 -0
  32. package/src/document-editor/ComponentMaterials.tsx +62 -0
  33. package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
  34. package/src/document-editor/DocumentBlockFallback.tsx +31 -0
  35. package/src/document-editor/DocumentBlockPreview.tsx +239 -0
  36. package/src/document-editor/DocumentComments.tsx +282 -0
  37. package/src/document-editor/DocumentEditor.tsx +192 -0
  38. package/src/document-editor/DocumentStepNarration.tsx +93 -0
  39. package/src/document-editor/NarrationEditor.tsx +110 -0
  40. package/src/document-editor/NewCommentDrafts.tsx +224 -0
  41. package/src/document-editor/authoring.tsx +259 -0
  42. package/src/document-editor/block-playback.ts +90 -0
  43. package/src/document-editor/block-preview-messages.ts +13 -0
  44. package/src/document-editor/blocknote-messages.ts +98 -0
  45. package/src/document-editor/clipboard.ts +40 -0
  46. package/src/document-editor/colors.ts +57 -0
  47. package/src/document-editor/comment-draft-actions.ts +58 -0
  48. package/src/document-editor/comment-draft-inspection.ts +38 -0
  49. package/src/document-editor/comment-drafts.ts +361 -0
  50. package/src/document-editor/comments.ts +410 -0
  51. package/src/document-editor/file-input.ts +71 -0
  52. package/src/document-editor/focus.ts +54 -0
  53. package/src/document-editor/inline.ts +104 -0
  54. package/src/document-editor/math.tsx +63 -0
  55. package/src/document-editor/messages.ts +14 -0
  56. package/src/document-editor/narration-schema.tsx +85 -0
  57. package/src/document-editor/narration.ts +37 -0
  58. package/src/document-editor/popover.tsx +107 -0
  59. package/src/document-editor/projection.ts +423 -0
  60. package/src/document-editor/rich-text.ts +152 -0
  61. package/src/document-editor/schema.tsx +117 -0
  62. package/src/document-editor/selection.ts +152 -0
  63. package/src/document-editor/style.css +9 -0
  64. package/src/document-editor/styles.d.ts +1 -0
  65. package/src/document-editor/table-identities.ts +67 -0
  66. package/src/document-preview.ts +112 -0
  67. package/src/document-return-point.ts +59 -0
  68. package/src/document-session.ts +678 -0
  69. package/src/document-timing-drafts.ts +61 -0
  70. package/src/document-voices.ts +2 -0
  71. package/src/history.ts +167 -0
  72. package/src/index.ts +36 -0
  73. package/src/jobs.ts +111 -0
  74. package/src/mascot-preference.ts +37 -0
  75. package/src/media-drafts.ts +83 -0
  76. package/src/media-messages.ts +53 -0
  77. package/src/media-time.ts +12 -0
  78. package/src/messages.ts +781 -0
  79. package/src/outline.ts +97 -0
  80. package/src/panels/ChangeTrail.tsx +79 -0
  81. package/src/panels/DocumentNarrationPanel.tsx +27 -0
  82. package/src/panels/DocumentView.tsx +462 -0
  83. package/src/panels/DocumentVoiceDialog.tsx +179 -0
  84. package/src/panels/DocumentVoiceField.tsx +35 -0
  85. package/src/panels/ExportPanel.tsx +133 -0
  86. package/src/panels/HistoryPanel.tsx +88 -0
  87. package/src/panels/HomePanel.tsx +184 -0
  88. package/src/panels/PageDeleteDialog.tsx +33 -0
  89. package/src/panels/PageRenameDialog.tsx +27 -0
  90. package/src/panels/PreviewPanel.tsx +79 -0
  91. package/src/panels/ProductionCaptionTiming.tsx +122 -0
  92. package/src/panels/ProductionMediaClips.tsx +156 -0
  93. package/src/panels/ProductionPanel.tsx +272 -0
  94. package/src/panels/ProductionRecording.tsx +107 -0
  95. package/src/panels/ProductionRecordingRange.tsx +118 -0
  96. package/src/panels/ProductionTimeline.tsx +62 -0
  97. package/src/panels/ProductionTranscription.tsx +140 -0
  98. package/src/panels/SourceDocumentPanel.tsx +215 -0
  99. package/src/panels/SpeechSettingsPanel.tsx +64 -0
  100. package/src/panels/TranscriptionAdoption.tsx +147 -0
  101. package/src/panels/VoiceCloneDialog.tsx +152 -0
  102. package/src/panels/VoicePreviewDialog.tsx +74 -0
  103. package/src/panels/VoiceScopeField.tsx +30 -0
  104. package/src/panels/VoiceoverDialog.tsx +216 -0
  105. package/src/places.ts +102 -0
  106. package/src/preview-playback.ts +125 -0
  107. package/src/preview-pool.ts +320 -0
  108. package/src/preview.ts +110 -0
  109. package/src/production-messages.ts +151 -0
  110. package/src/production-playback.ts +109 -0
  111. package/src/production-voice-playback.ts +114 -0
  112. package/src/recording-adoption.ts +33 -0
  113. package/src/schema-form/PmSchemaForm.tsx +187 -0
  114. package/src/schema-form/context.ts +26 -0
  115. package/src/schema-form/fields.tsx +59 -0
  116. package/src/schema-form/templates.tsx +50 -0
  117. package/src/schema-form/validator.ts +36 -0
  118. package/src/schema-form/widgets.tsx +60 -0
  119. package/src/shortcuts.ts +8 -0
  120. package/src/sortable-list.tsx +201 -0
  121. package/src/speech-settings-messages.ts +40 -0
  122. package/src/step-production.css +5 -0
  123. package/src/step-production.ts +51 -0
  124. package/src/styles.css +1025 -0
  125. package/src/telemetry.ts +152 -0
  126. package/src/transcription-messages.ts +26 -0
  127. package/src/transcription-session.ts +159 -0
  128. package/src/use-recording-adoption.ts +84 -0
  129. package/src/voice-clone-messages.ts +92 -0
  130. package/src/voice-drafts.ts +89 -0
  131. package/src/voice-messages.ts +146 -0
  132. package/src/voice-snapshot.ts +55 -0
  133. package/src/voiceover-jobs.ts +87 -0
  134. package/src/workbench.ts +2328 -0
  135. package/src/workspace-client.ts +480 -0
@@ -0,0 +1,678 @@
1
+ import { hasRole, validateDocument } from '@lincyaw/kernel'
2
+ import type { DocumentAnswer, DocumentChangeAnswer, DocumentImageImportAnswer, DocumentIssue, Member, TeachingDocument } from '@lincyaw/kernel'
3
+ import { WorkspaceServerError } from './workspace-client.ts'
4
+ import type { WorkspaceClient } from './workspace-client.ts'
5
+ import { DocumentDrafts } from './document-drafts.ts'
6
+ import type { DocumentDraftChoice, DocumentDraftCommit, DocumentDraftReference, StoredDocumentDraft } from './document-drafts.ts'
7
+
8
+ export interface DocumentSessionState {
9
+ directory: string
10
+ member?: Member
11
+ identity?: string
12
+ content?: TeachingDocument
13
+ version?: string
14
+ fingerprint?: string
15
+ projectRevision?: number
16
+ loading: boolean
17
+ dirty: boolean
18
+ saving: boolean
19
+ restored: boolean
20
+ composing: boolean
21
+ storageFailure: boolean
22
+ backupPending?: boolean
23
+ recoveryReadFailure?: boolean
24
+ reset: number
25
+ error?: string
26
+ issues: DocumentIssue[]
27
+ recovering?: boolean
28
+ recoveryDrafts?: DocumentDraftChoice[]
29
+ recoveryChanged?: boolean
30
+ }
31
+ interface Slot {
32
+ state: DocumentSessionState
33
+ read: number
34
+ compositions: Set<string>
35
+ input: number
36
+ baseHash: string
37
+ storage: string
38
+ drafts: DocumentDrafts
39
+ backup?: DocumentDraftCommit
40
+ draft?: StoredDocumentDraft
41
+ draftOrigins?: DocumentDraftReference[]
42
+ draftRead?: number
43
+ draftFailure?: boolean
44
+ legacyFailure?: 'read' | 'write'
45
+ persistFailure?: boolean
46
+ recoveryOperation?: number
47
+ flight?: { id: string, input: number, content: TeachingDocument, baseVersion: string, hash?: string }
48
+ assetFlight?: Promise<DocumentChangeAnswer>
49
+ timer?: ReturnType<typeof setTimeout>
50
+ refreshed?: boolean
51
+ pendingHash?: string
52
+ projectObservation?: string
53
+ saveWaiters?: Set<() => void>
54
+ }
55
+
56
+ const serialize = (value: unknown) => JSON.stringify(value, (_, entry) => entry && typeof entry === 'object' && !Array.isArray(entry) ? Object.fromEntries(Object.entries(entry).sort(([a], [b]) => a.localeCompare(b))) : entry)
57
+ /** Non-body authoring changes keep native selection, comment drafts and body history. */
58
+ function keepsEditorContent(before: TeachingDocument, after: TeachingDocument): boolean {
59
+ const editing = ({ assets: _assets, production: _production, comments: _comments, definitions: _definitions, ...document }: TeachingDocument) => {
60
+ const resolved = new Map<string, Map<string, { blockId: string, visible: boolean, state?: string }>>()
61
+ const visiting = new Set<string>()
62
+ const visuals = (id?: string): Map<string, { blockId: string, visible: boolean, state?: string }> => {
63
+ if (!id || !document.steps[id] || visiting.has(id)) return new Map()
64
+ const known = resolved.get(id)
65
+ if (known) return known
66
+ visiting.add(id)
67
+ const step = document.steps[id]!, result = new Map(visuals(step.inherits))
68
+ for (const { parameters: _parameters, ...visual } of step.visuals) result.set(visual.blockId, { ...result.get(visual.blockId), ...visual })
69
+ visiting.delete(id); resolved.set(id, result)
70
+ return result
71
+ }
72
+ return { ...document,
73
+ blocks: Object.fromEntries(Object.entries(document.blocks).map(([id, block]) => {
74
+ if (block.type !== 'component') return [id, block]
75
+ const { parameters: _parameters, definitionId: _definitionId, ...body } = block
76
+ return [id, body]
77
+ })),
78
+ steps: Object.fromEntries(Object.entries(document.steps).map(([id, step]) => [id, { ...step, visuals: step.visuals.flatMap(({ parameters: _parameters, ...visual }) => {
79
+ if (document.blocks[visual.blockId]?.type !== 'component') return [visual]
80
+ const inherited = visuals(step.inherits).get(visual.blockId)
81
+ // A first parameter override materializes the already inherited visibility. It is not a body-tree edit.
82
+ return visual.visible === (inherited?.visible ?? false) && (visual.state ?? inherited?.state) === inherited?.state ? [] : [visual]
83
+ }) }])),
84
+ }
85
+ }
86
+ return serialize(editing(before)) === serialize(editing(after))
87
+ && Object.entries(before.assets).every(([id, asset]) => Object.hasOwn(after.assets, id) && serialize(asset) === serialize(after.assets[id]))
88
+ }
89
+
90
+ async function digest(value: TeachingDocument): Promise<string> {
91
+ const text = serialize(value)
92
+ if (!globalThis.crypto?.subtle) return `json:${text}`
93
+ const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
94
+ return [...new Uint8Array(bytes)].map(value => value.toString(16).padStart(2, '0')).join('')
95
+ }
96
+
97
+ /** One source actor per document. Runtime compilation and iframe lifecycle never own its input. */
98
+ export class DocumentSession {
99
+ readonly #client: WorkspaceClient
100
+ readonly #changed: (state: DocumentSessionState) => void
101
+ readonly #slots = new Map<string, Slot>()
102
+ readonly #imageImports = new WeakMap<File, { identity: string, version: string, id: string }>()
103
+ #active?: string
104
+ #opening = 0
105
+ #unsubscribe?: () => void
106
+ #disposed = false
107
+ #leaveTarget?: Window
108
+ readonly #storageChanged = (event: StorageEvent) => {
109
+ if (this.#disposed) return
110
+ for (const slot of this.#slots.values()) if (slot.state.identity && slot.drafts.matches(slot.state.identity, event.key)) void this.#readDrafts(slot)
111
+ }
112
+ readonly #draftFocus = () => { const slot = this.#active === undefined ? undefined : this.#slots.get(this.#active); if (slot && !this.#disposed) void this.#readDrafts(slot) }
113
+ readonly #beforeUnload = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
114
+ constructor(client: WorkspaceClient, changed: (state: DocumentSessionState) => void) {
115
+ this.#client = client; this.#changed = changed
116
+ if (typeof window !== 'undefined') { window.addEventListener('storage', this.#storageChanged); window.addEventListener('focus', this.#draftFocus) }
117
+ }
118
+
119
+ #syncLeaveProtection(): void {
120
+ const pending = !this.#disposed && [...this.#slots.values()].some(slot => slot.state.dirty || slot.state.composing || slot.flight || slot.assetFlight)
121
+ const target = pending && typeof window !== 'undefined' ? window : undefined
122
+ if (target === this.#leaveTarget) return
123
+ this.#leaveTarget?.removeEventListener('beforeunload', this.#beforeUnload)
124
+ target?.addEventListener('beforeunload', this.#beforeUnload)
125
+ this.#leaveTarget = target
126
+ }
127
+
128
+ async open(directory: string, expectedIdentity?: string): Promise<'identity-changed' | void> {
129
+ if (this.#active !== directory) this.interrupt()
130
+ const opening = ++this.#opening
131
+ this.#active = directory
132
+ this.#unsubscribe?.()
133
+ const cached = this.#slots.get(directory)
134
+ if (cached) { cached.read++; cached.state = { ...cached.state, loading: true } }
135
+ this.#changed(cached ? { ...cached.state } : { directory, loading: true, dirty: false, saving: false, restored: false, composing: false, storageFailure: false, reset: 0, issues: [] })
136
+ try {
137
+ const [info, answer] = await Promise.all([this.#client.readWorkbenchInfo(), this.#client.readDocument(directory)])
138
+ if (this.#active !== directory || opening !== this.#opening) return
139
+ if (expectedIdentity !== undefined && JSON.stringify([info.root, answer.content.id]) !== expectedIdentity) return 'identity-changed'
140
+ let slot = this.#slots.get(directory)
141
+ if (!slot) {
142
+ const baseHash = await digest(answer.content)
143
+ if (this.#disposed || this.#active !== directory || opening !== this.#opening) return
144
+ const drafts = new DocumentDrafts(() => { if (slot && !this.#disposed) void this.#readDrafts(slot) })
145
+ slot = { input: 0, read: 0, compositions: new Set(), drafts, baseHash, storage: `presmium.document.v1:${JSON.stringify([info.root, answer.content.id])}`, state: { directory, member: info.member, identity: JSON.stringify([info.root, answer.content.id]), content: answer.content, version: answer.version, fingerprint: answer.fingerprint, loading: true, dirty: false, saving: false, restored: false, composing: false, storageFailure: false, reset: 0, issues: answer.issues } }
146
+ this.#slots.set(directory, slot)
147
+ await this.#restore(slot, answer)
148
+ } else {
149
+ slot.state = { ...slot.state, member: info.member }
150
+ await this.#adopt(slot, answer, () => this.#active === directory && opening === this.#opening)
151
+ await this.#readDrafts(slot)
152
+ }
153
+ this.#syncLeaveProtection()
154
+ if (this.#active !== directory || opening !== this.#opening) return
155
+ // A fresh observation invalidates idle project references before the editor becomes ready.
156
+ slot.projectObservation = undefined
157
+ slot.state = { ...slot.state, loading: false, projectRevision: (slot.state.projectRevision ?? 0) + 1 }
158
+ this.#emit(slot)
159
+ this.#prune()
160
+ this.#unsubscribe = this.#client.subscribeDocument(directory, event => {
161
+ const current = this.#slots.get(directory)
162
+ if (!current) return
163
+ const projectChanged = current.projectObservation !== undefined && event.projectRevision !== undefined && event.projectRevision !== current.projectObservation
164
+ current.projectObservation = event.projectRevision
165
+ if (event.version === current.state.version) {
166
+ if (projectChanged) { current.state = { ...current.state, projectRevision: (current.state.projectRevision ?? 0) + 1 }; this.#emit(current) }
167
+ return
168
+ }
169
+ if (current.flight || current.assetFlight) { current.refreshed = true; return }
170
+ void this.refresh(directory)
171
+ }, () => {})
172
+ } catch (error) {
173
+ if (this.#active === directory && opening === this.#opening) {
174
+ const state = { ...(cached?.state ?? { directory, dirty: false, saving: false, restored: false, composing: false, storageFailure: false, reset: 0, issues: [] }), loading: false, error: this.#error(error), issues: this.#issues(error) }
175
+ if (cached) cached.state = state
176
+ this.#changed(state)
177
+ }
178
+ }
179
+ }
180
+
181
+ close(): void { this.interrupt(); this.#active = undefined; this.#opening++; this.#unsubscribe?.(); this.#unsubscribe = undefined }
182
+ #prune(): void {
183
+ for (const [directory, slot] of this.#slots) {
184
+ if (this.#slots.size <= 8) return
185
+ if (directory === this.#active || slot.flight || slot.assetFlight || slot.state.dirty || slot.state.composing) continue
186
+ clearTimeout(slot.timer)
187
+ slot.drafts.close()
188
+ this.#slots.delete(directory)
189
+ }
190
+ }
191
+ #emit(slot: Slot): void {
192
+ this.#syncLeaveProtection()
193
+ for (const changed of slot.saveWaiters ?? []) changed()
194
+ if (this.#active === slot.state.directory && this.#slots.get(slot.state.directory) === slot) this.#changed({ ...slot.state })
195
+ }
196
+ #error(error: unknown): string { return error instanceof WorkspaceServerError ? error.answer.error ?? 'document-request-failed' : 'document-disconnected' }
197
+ #readOnly(slot: Slot): boolean { return this.#client.capabilities?.readOnly === true || Boolean(slot.state.member && !hasRole(slot.state.member, 'editor')) }
198
+ #issues(error: unknown): DocumentIssue[] { const value = error instanceof WorkspaceServerError ? (error.answer as { issues?: DocumentIssue[] }).issues : undefined; return Array.isArray(value) ? value : [] }
199
+
200
+ async refresh(directory = this.#active): Promise<void> {
201
+ if (directory === undefined) return
202
+ const slot = this.#slots.get(directory)
203
+ if (!slot) { if (directory === this.#active) await this.open(directory); return }
204
+ if (slot.flight || slot.assetFlight) { slot.refreshed = true; return }
205
+ const read = ++slot.read, current = () => slot.read === read && this.#slots.get(directory) === slot
206
+ try {
207
+ const answer = await this.#client.readDocument(directory)
208
+ if (!current()) return
209
+ if (slot.flight || slot.assetFlight) { slot.refreshed = true; return }
210
+ await this.#adopt(slot, answer, current)
211
+ if (current()) { this.#persist(slot); this.#emit(slot); void this.#readDrafts(slot) }
212
+ } catch (error) {
213
+ if (current()) { slot.state = { ...slot.state, error: this.#error(error), issues: this.#issues(error) }; this.#emit(slot) }
214
+ }
215
+ }
216
+
217
+ async #adopt(slot: Slot, answer: DocumentAnswer, current = () => true): Promise<void> {
218
+ if (!current()) return
219
+ if (answer.content.id !== slot.state.content?.id) { slot.state = { ...slot.state, error: 'document-identity-changed' }; return }
220
+ if (answer.version === slot.state.version) {
221
+ if (answer.fingerprint !== slot.state.fingerprint) slot.state = { ...slot.state, fingerprint: answer.fingerprint, issues: answer.issues }
222
+ return
223
+ }
224
+ const remoteHash = await digest(answer.content)
225
+ if (!current()) return
226
+ if (slot.state.dirty || slot.state.composing) {
227
+ const input = slot.input, localHash = await digest(slot.state.content!)
228
+ if (!current()) return
229
+ if (remoteHash === localHash) {
230
+ const same = slot.input === input && !slot.state.composing
231
+ slot.baseHash = remoteHash
232
+ slot.pendingHash = undefined
233
+ slot.state = { ...slot.state, version: answer.version, fingerprint: answer.fingerprint, dirty: !same, error: undefined, issues: answer.issues }
234
+ } else if (remoteHash === slot.pendingHash) {
235
+ slot.baseHash = remoteHash; slot.pendingHash = undefined
236
+ slot.state = { ...slot.state, version: answer.version, fingerprint: answer.fingerprint, error: undefined, issues: answer.issues }
237
+ } else if (remoteHash === slot.baseHash) slot.state = { ...slot.state, version: answer.version, fingerprint: answer.fingerprint, issues: answer.issues }
238
+ else slot.state = { ...slot.state, error: 'document-version-changed', issues: answer.issues }
239
+ return
240
+ }
241
+ const reset = slot.state.reset + (keepsEditorContent(slot.state.content!, answer.content) ? 0 : 1)
242
+ slot.baseHash = remoteHash
243
+ slot.state = { ...slot.state, version: answer.version, fingerprint: answer.fingerprint, content: answer.content, reset, error: undefined, issues: answer.issues }
244
+ }
245
+
246
+ record(content: TeachingDocument): void {
247
+ const slot = this.#active === undefined ? undefined : this.#slots.get(this.#active)
248
+ if (!slot || this.#readOnly(slot) || content.id !== slot.state.content?.id) return
249
+ slot.input++
250
+ slot.state = { ...slot.state, content: structuredClone(content), dirty: true }
251
+ this.#persist(slot); this.#emit(slot)
252
+ this.#schedule(slot)
253
+ }
254
+
255
+ #schedule(slot: Slot): void {
256
+ clearTimeout(slot.timer)
257
+ slot.timer = undefined
258
+ if (slot.state.dirty && !slot.state.restored && !slot.state.error && !slot.state.composing) slot.timer = setTimeout(() => { slot.timer = undefined; void this.#save(slot) }, 600)
259
+ }
260
+
261
+ replace(content: TeachingDocument): void {
262
+ const current = this.#active === undefined ? undefined : this.#slots.get(this.#active)
263
+ if (current && this.#readOnly(current)) return
264
+ this.record(content)
265
+ const slot = this.#active === undefined ? undefined : this.#slots.get(this.#active)
266
+ if (slot) { slot.state.reset++; this.#emit(slot) }
267
+ }
268
+
269
+ composition(composing: boolean, owner = 'body', identity?: string): void {
270
+ const slot = this.#active === undefined ? undefined : this.#slots.get(this.#active)
271
+ if (!slot || this.#readOnly(slot) || identity !== undefined && identity !== slot.state.identity || slot.compositions.has(owner) === composing) return
272
+ if (composing) slot.compositions.add(owner)
273
+ else slot.compositions.delete(owner)
274
+ slot.state = { ...slot.state, composing: slot.compositions.size > 0 }
275
+ this.#persist(slot); this.#emit(slot)
276
+ this.#schedule(slot)
277
+ }
278
+ interrupt(): void {
279
+ const slot = this.#active === undefined ? undefined : this.#slots.get(this.#active)
280
+ if (!slot?.state.composing) return
281
+ slot.compositions.clear()
282
+ slot.state = { ...slot.state, composing: false, restored: true, error: 'document-composition-interrupted' }
283
+ this.#persist(slot); this.#emit(slot)
284
+ }
285
+
286
+ async #save(slot: Slot, immediate = false): Promise<void> {
287
+ if (immediate) { clearTimeout(slot.timer); slot.timer = undefined }
288
+ else if (slot.timer !== undefined) return
289
+ if (this.#readOnly(slot) || slot.flight || slot.assetFlight || !slot.state.dirty || slot.state.composing || slot.state.restored || slot.state.error || !slot.state.content || !slot.state.version) return
290
+ slot.read++
291
+ const flight = { id: crypto.randomUUID(), input: slot.input, content: structuredClone(slot.state.content), baseVersion: slot.state.version, hash: undefined as string | undefined }
292
+ slot.flight = flight
293
+ slot.state = { ...slot.state, saving: true }; this.#emit(slot)
294
+ try {
295
+ flight.hash = await digest(flight.content)
296
+ slot.pendingHash = flight.hash
297
+ this.#persist(slot)
298
+ const answer = await this.#client.changeDocument(slot.state.directory, { id: flight.id, baseDocumentVersion: flight.baseVersion, replacement: flight.content })
299
+ if (answer.changeId !== flight.id || answer.document !== slot.state.directory || answer.content.id !== flight.content.id) throw new Error('document-confirmation')
300
+ this.#ack(slot, flight, answer)
301
+ } catch (error) {
302
+ slot.state = { ...slot.state, saving: false, error: this.#error(error), issues: this.#issues(error) }
303
+ } finally {
304
+ if (slot.flight === flight) slot.flight = undefined
305
+ this.#persist(slot); this.#emit(slot)
306
+ void this.#readDrafts(slot)
307
+ if (slot.refreshed) { slot.refreshed = false; await this.refresh(slot.state.directory) }
308
+ if (!slot.state.error) void this.#save(slot)
309
+ }
310
+ }
311
+ #ack(slot: Slot, flight: NonNullable<Slot['flight']>, answer: DocumentChangeAnswer): void {
312
+ slot.baseHash = flight.hash!
313
+ slot.pendingHash = undefined
314
+ const same = slot.input === flight.input
315
+ slot.state = { ...slot.state, version: answer.version, fingerprint: answer.fingerprint, ...(same ? { content: answer.content } : {}), dirty: !same, saving: false, error: undefined, issues: answer.issues }
316
+ }
317
+
318
+ /** Adopt a verified asset-only transaction without replacing later native text. */
319
+ async #adoptImportedAssets(slot: Slot, before: TeachingDocument, input: number, answer: DocumentAnswer, owns: () => boolean): Promise<boolean> {
320
+ const withoutAssets = ({ assets: _assets, ...content }: TeachingDocument) => serialize(content)
321
+ if (!owns() || answer.document !== slot.state.directory || withoutAssets(before) !== withoutAssets(answer.content)
322
+ || !Object.entries(before.assets).every(([id, asset]) => serialize(asset) === serialize(answer.content.assets[id]))) return false
323
+ const baseHash = await digest(answer.content)
324
+ if (!owns()) return false
325
+ const current = slot.state.content!
326
+ const added = Object.entries(answer.content.assets).filter(([id]) => !Object.hasOwn(before.assets, id))
327
+ if (added.some(([id, asset]) => Object.hasOwn(current.assets, id) && serialize(current.assets[id]) !== serialize(asset))) return false
328
+ const same = slot.input === input, content = same ? answer.content : structuredClone(current)
329
+ if (!same) for (const [id, asset] of added) content.assets[id] = structuredClone(asset)
330
+ slot.baseHash = baseHash
331
+ slot.state = { ...slot.state, content, version: answer.version, fingerprint: answer.fingerprint, dirty: !same, error: undefined, issues: answer.issues }
332
+ return true
333
+ }
334
+
335
+ async #adoptExternal(slot: Slot, before: TeachingDocument, input: number, answer: DocumentAnswer, owns: () => boolean, reconcile: (before: TeachingDocument, answer: DocumentAnswer, current: TeachingDocument) => TeachingDocument | undefined): Promise<boolean> {
336
+ if (!owns() || answer.document !== slot.state.directory || answer.content.id !== before.id || !slot.state.content) return false
337
+ if (!reconcile(before, answer, slot.state.content)) return false
338
+ const baseHash = await digest(answer.content)
339
+ if (!owns()) return false
340
+ const content = reconcile(before, answer, slot.state.content!)
341
+ if (!content || content.id !== before.id) return false
342
+ const checked = validateDocument(content)
343
+ if (!checked.document || checked.issues.some(issue => issue.severity === 'error')) return false
344
+ slot.baseHash = baseHash
345
+ slot.pendingHash = undefined
346
+ slot.state = { ...slot.state, content: structuredClone(checked.document), version: answer.version, fingerprint: answer.fingerprint,
347
+ dirty: slot.input !== input || slot.state.composing || serialize(content) !== serialize(answer.content), error: undefined, issues: answer.issues }
348
+ return true
349
+ }
350
+
351
+ /** Reconcile an external source transaction without replacing later native input. */
352
+ async commitExternal<T extends DocumentChangeAnswer>(expected: { identity: string, version: string }, submit: (before: TeachingDocument, signal?: AbortSignal) => Promise<T>, reconcile: (before: TeachingDocument, answer: DocumentAnswer, current: TeachingDocument) => TeachingDocument | undefined, signal?: AbortSignal): Promise<T> {
353
+ signal?.throwIfAborted()
354
+ const slot = this.#active === undefined ? undefined : this.#slots.get(this.#active)
355
+ if (slot && this.#readOnly(slot)) throw new WorkspaceServerError(403, { error: 'permission-denied' })
356
+ if (!slot?.state.content || this.#disposed || slot.state.identity !== expected.identity || slot.state.version !== expected.version
357
+ || slot.state.loading || slot.state.dirty || slot.state.saving || slot.state.composing || slot.state.restored || slot.state.recovering || slot.state.error || slot.flight || slot.assetFlight) throw new Error('document-identity-changed')
358
+ const before = structuredClone(slot.state.content), input = slot.input, read = ++slot.read, directory = slot.state.directory, baseHash = slot.baseHash, fingerprint = slot.state.fingerprint, { identity, version } = expected
359
+ const owns = () => !this.#disposed && this.#slots.get(directory) === slot && slot.state.identity === identity && slot.read === read && slot.state.version === version
360
+ let refreshClean = false
361
+ const work = Promise.resolve().then(async () => {
362
+ signal?.throwIfAborted()
363
+ if (this.#active !== directory || !owns()) throw new Error('document-identity-changed')
364
+ const answer = await submit(structuredClone(before), signal)
365
+ if (!answer.changeId || !await this.#adoptExternal(slot, before, input, answer, owns, reconcile)) throw new Error('document-version-changed')
366
+ return answer
367
+ })
368
+ slot.assetFlight = work
369
+ slot.state = { ...slot.state, saving: true }; this.#emit(slot)
370
+ try { return await work }
371
+ catch (error) {
372
+ if (owns()) {
373
+ try {
374
+ // The write may have committed before cancellation or a lost response.
375
+ // Reconciliation reads remain independent of the submit signal.
376
+ slot.refreshed = false
377
+ const answer = await this.#client.readDocument(directory)
378
+ const unchanged = answer.document === directory && answer.content.id === before.id && answer.version === version && answer.fingerprint === fingerprint && await digest(answer.content) === baseHash
379
+ if (owns() && !unchanged && !await this.#adoptExternal(slot, before, input, answer, owns, reconcile) && owns()) {
380
+ refreshClean = error instanceof WorkspaceServerError && error.status === 409 && slot.input === input && !slot.state.composing
381
+ if (!refreshClean) slot.state = { ...slot.state, error: 'document-version-changed' }
382
+ }
383
+ } catch { if (owns()) slot.state = { ...slot.state, error: this.#error(error) } }
384
+ }
385
+ throw error
386
+ } finally {
387
+ if (slot.assetFlight === work) {
388
+ slot.assetFlight = undefined
389
+ slot.state = { ...slot.state, saving: false }
390
+ this.#persist(slot); this.#emit(slot)
391
+ if (!this.#disposed && this.#active === directory) {
392
+ if (refreshClean && owns() && slot.input === input && !slot.state.composing) await this.refresh(directory)
393
+ else if (slot.refreshed) { slot.refreshed = false; await this.refresh(directory) }
394
+ if (!slot.state.error) void this.#save(slot)
395
+ }
396
+ }
397
+ }
398
+ }
399
+
400
+ /** The file and its asset declaration share the actor's write boundary. */
401
+ async importImage(file: File, identity: string, signal?: AbortSignal): Promise<DocumentImageImportAnswer> {
402
+ await this.flush(signal)
403
+ const slot = this.#active === undefined ? undefined : this.#slots.get(this.#active)
404
+ if (slot && this.#readOnly(slot)) throw new WorkspaceServerError(403, { error: 'permission-denied' })
405
+ if (!slot?.state.content || !slot.state.version || slot.state.identity !== identity || slot.state.dirty || slot.state.saving || slot.state.composing || slot.state.error) throw new Error('document-identity-changed')
406
+ const before = structuredClone(slot.state.content), input = slot.input, version = slot.state.version
407
+ let request = this.#imageImports.get(file)
408
+ if (!request || request.identity !== identity || request.version !== version) {
409
+ request = { identity, version, id: crypto.randomUUID() }
410
+ this.#imageImports.set(file, request)
411
+ }
412
+ const read = ++slot.read
413
+ const owns = () => this.#slots.get(slot.state.directory) === slot && slot.read === read && slot.state.version === version
414
+ slot.state = { ...slot.state, saving: true }; this.#emit(slot)
415
+ const work = (async () => {
416
+ const answer = await this.#client.importDocumentImage(slot.state.directory, file, { id: request!.id, baseDocumentVersion: request!.version }, signal)
417
+ if (answer.changeId !== request!.id || answer.content.id !== before.id || answer.content.assets[answer.assetId]?.kind !== 'image') throw new Error('document-confirmation')
418
+ if (!await this.#adoptImportedAssets(slot, before, input, answer, owns)) throw new Error('document-version-changed')
419
+ return answer
420
+ })()
421
+ slot.assetFlight = work
422
+ this.#syncLeaveProtection()
423
+ try { return await work }
424
+ catch (error) {
425
+ // A lost reply may still have committed the file. Reconcile only a proven
426
+ // asset addition; an unrelated external edit remains a source conflict.
427
+ if (owns()) {
428
+ try {
429
+ slot.refreshed = false
430
+ const answer = await this.#client.readDocument(slot.state.directory)
431
+ if (!await this.#adoptImportedAssets(slot, before, input, answer, owns) && owns()) slot.state = { ...slot.state, error: 'document-version-changed' }
432
+ } catch { /* The native upload error and the pending source draft remain. */ }
433
+ }
434
+ if (error instanceof WorkspaceServerError && error.status >= 400 && error.status < 500 && this.#imageImports.get(file) === request) this.#imageImports.delete(file)
435
+ throw error
436
+ } finally {
437
+ if (slot.assetFlight === work) slot.assetFlight = undefined
438
+ slot.state = { ...slot.state, saving: false }
439
+ this.#persist(slot); this.#emit(slot)
440
+ void this.#readDrafts(slot)
441
+ if (slot.refreshed) { slot.refreshed = false; await this.refresh(slot.state.directory) }
442
+ if (!slot.state.error) void this.#save(slot)
443
+ }
444
+ }
445
+
446
+ async retry(): Promise<void> {
447
+ if (this.#disposed || this.#active === undefined) return
448
+ const slot = this.#slots.get(this.#active)
449
+ if (!slot) { await this.open(this.#active); return }
450
+ if (slot.flight || slot.assetFlight || slot.state.composing) return
451
+ const previousError = slot.state.error
452
+ slot.state = { ...slot.state, restored: false, error: undefined }
453
+ await this.refresh(slot.state.directory)
454
+ if (slot.state.error) { if (!previousError) this.#emit(slot); return }
455
+ await this.#save(slot, true)
456
+ }
457
+ async discard(): Promise<void> {
458
+ const slot = this.#active === undefined ? undefined : this.#slots.get(this.#active)
459
+ if (!slot || slot.flight || slot.assetFlight || slot.state.composing) return
460
+ const directory = slot.state.directory, identity = slot.state.identity, documentId = slot.state.content?.id, input = slot.input, read = ++slot.read
461
+ const current = () => !this.#disposed && this.#slots.get(directory) === slot && slot.read === read && slot.input === input
462
+ && slot.state.identity === identity && !slot.state.composing && !slot.flight && !slot.assetFlight
463
+ try {
464
+ const answer = await this.#client.readDocument(directory)
465
+ if (!current()) return
466
+ if (answer.document !== directory || answer.content.id !== documentId) {
467
+ slot.state = { ...slot.state, error: 'document-identity-changed' }; this.#emit(slot)
468
+ return
469
+ }
470
+ const baseHash = await digest(answer.content)
471
+ if (!current()) return
472
+ slot.input++; slot.baseHash = baseHash
473
+ slot.pendingHash = undefined
474
+ slot.compositions.clear()
475
+ slot.state = { ...slot.state, content: answer.content, version: answer.version, fingerprint: answer.fingerprint, dirty: false, restored: false, composing: false, reset: slot.state.reset + 1, error: undefined, issues: answer.issues }
476
+ this.#persist(slot); this.#emit(slot)
477
+ void this.#readDrafts(slot)
478
+ } catch (error) { if (current()) { slot.state = { ...slot.state, error: this.#error(error) }; this.#emit(slot) } }
479
+ }
480
+ async flush(signal?: AbortSignal): Promise<void> {
481
+ signal?.throwIfAborted()
482
+ const slot = this.#active === undefined ? undefined : this.#slots.get(this.#active)
483
+ if (!slot) return
484
+ if (slot.state.composing || slot.state.restored || slot.state.error) throw new Error(slot.state.error ?? 'document-draft-unconfirmed')
485
+ const started = Date.now()
486
+ while (slot.state.dirty || slot.flight || slot.assetFlight) {
487
+ signal?.throwIfAborted()
488
+ if (slot.state.error || slot.state.composing || slot.state.restored) throw new Error(slot.state.error ?? 'document-draft-unconfirmed')
489
+ const remaining = 30000 - (Date.now() - started)
490
+ if (remaining <= 0) throw new Error('document-save-timeout')
491
+ const waiters = slot.saveWaiters ??= new Set()
492
+ await new Promise<void>((resolve, reject) => {
493
+ const cleanup = () => {
494
+ clearTimeout(timer)
495
+ waiters.delete(changed)
496
+ signal?.removeEventListener('abort', cancelled)
497
+ }
498
+ const changed = () => { cleanup(); resolve() }
499
+ const failed = (error: unknown) => { cleanup(); reject(error) }
500
+ const cancelled = () => failed(signal?.reason)
501
+ const timer = setTimeout(() => failed(new Error('document-save-timeout')), remaining)
502
+ waiters.add(changed)
503
+ signal?.addEventListener('abort', cancelled, { once: true })
504
+ void this.#save(slot, true).catch(failed)
505
+ })
506
+ }
507
+ signal?.throwIfAborted()
508
+ }
509
+
510
+ #persist(slot: Slot): boolean {
511
+ const identity = slot.state.identity
512
+ if (!identity || !slot.state.content || !slot.state.version) return false
513
+ let commit: DocumentDraftCommit | undefined
514
+ if (!slot.state.dirty && !slot.state.composing && !slot.flight) {
515
+ if (!slot.draft) {
516
+ slot.persistFailure = false; slot.state.backupPending = false
517
+ slot.state.storageFailure = slot.legacyFailure === 'write'
518
+ return true
519
+ }
520
+ commit = slot.drafts.remove(identity, slot.draft, slot.draftOrigins ?? [])
521
+ } else {
522
+ commit = slot.drafts.write(identity, { content: slot.state.content, baseVersion: slot.state.version, baseHash: slot.baseHash, input: slot.input,
523
+ pendingHash: slot.flight?.hash ?? slot.pendingHash, composing: slot.state.composing, origins: slot.draftOrigins }, slot.draft)
524
+ }
525
+ slot.persistFailure = !commit
526
+ slot.state.backupPending = Boolean(commit && !commit.clearing)
527
+ slot.backup = commit
528
+ if (commit) {
529
+ const previous = slot.draft?.id, input = slot.input
530
+ if (!commit.clearing) slot.draft = commit.record
531
+ const ticket = commit
532
+ void ticket.done.then(result => {
533
+ if (this.#disposed || this.#slots.get(slot.state.directory) !== slot || slot.state.identity !== identity || slot.backup !== ticket) return
534
+ if (result === 'superseded') return
535
+ slot.persistFailure = result === 'failed'
536
+ slot.state = { ...slot.state, backupPending: false, storageFailure: Boolean(slot.persistFailure || slot.legacyFailure === 'write') }
537
+ if (result === 'committed' && ticket.clearing && slot.input === input && !slot.state.dirty && !slot.state.composing) {
538
+ slot.draft = undefined; slot.draftOrigins = undefined; slot.backup = undefined
539
+ }
540
+ this.#emit(slot)
541
+ if (ticket.clearing || previous !== ticket.record.id) void this.#readDrafts(slot)
542
+ })
543
+ }
544
+ slot.state.storageFailure = Boolean(slot.persistFailure || slot.legacyFailure === 'write')
545
+ slot.state.recoveryReadFailure = Boolean(slot.draftFailure || slot.legacyFailure === 'read')
546
+ return Boolean(commit)
547
+ }
548
+
549
+ async #restore(slot: Slot, answer: DocumentAnswer): Promise<void> {
550
+ const identity = slot.state.identity!, read = slot.read, input = slot.input
551
+ const migrated = await slot.drafts.migrate(identity, answer.content.id, slot.storage)
552
+ if (this.#disposed || this.#slots.get(slot.state.directory) !== slot || slot.state.identity !== identity || slot.read !== read || slot.input !== input) return
553
+ slot.legacyFailure = migrated
554
+ await this.#readDrafts(slot)
555
+ }
556
+
557
+ async #readDrafts(slot: Slot): Promise<void> {
558
+ const identity = slot.state.identity, documentId = slot.state.content?.id
559
+ if (!identity || !documentId || this.#disposed) return
560
+ const read = slot.draftRead = (slot.draftRead ?? 0) + 1, sourceRead = slot.read, input = slot.input, baseHash = slot.baseHash, own = slot.draft?.id
561
+ const current = () => !this.#disposed && this.#slots.get(slot.state.directory) === slot && slot.state.identity === identity && slot.draftRead === read && slot.read === sourceRead && slot.input === input && slot.baseHash === baseHash && slot.draft?.id === own
562
+ try {
563
+ if (slot.legacyFailure) {
564
+ const migrated = await slot.drafts.migrate(identity, documentId, slot.storage)
565
+ if (!current()) return
566
+ slot.legacyFailure = migrated
567
+ }
568
+ const { records, failed } = await slot.drafts.read(identity, documentId), choices: DocumentDraftChoice[] = []
569
+ if (!current()) return
570
+ for (const record of records) {
571
+ if (record.id === own && record.owned || slot.draftOrigins?.some(value => value.id === record.id && value.revision === record.revision && value.token === record.token)) continue
572
+ if (!record.composing && await digest(record.content) === baseHash) continue
573
+ if (!current()) return
574
+ choices.push({ id: record.id, revision: record.revision, token: record.token, updatedAt: record.updatedAt, baseVersion: record.baseVersion, source: record.source })
575
+ }
576
+ if (!current()) return
577
+ slot.draftFailure = failed
578
+ slot.state = { ...slot.state, recoveryDrafts: choices.sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id)),
579
+ storageFailure: Boolean(slot.persistFailure || slot.legacyFailure === 'write'), recoveryReadFailure: Boolean(failed || slot.legacyFailure === 'read') }
580
+ this.#emit(slot)
581
+ } catch {
582
+ if (current()) { slot.draftFailure = true; slot.state = { ...slot.state, recoveryReadFailure: true }; this.#emit(slot) }
583
+ }
584
+ }
585
+
586
+ /** Selecting a candidate never writes the source or replaces this tab's only snapshot. */
587
+ async recoverDraft(value: DocumentDraftReference): Promise<void> {
588
+ const slot = this.#active === undefined ? undefined : this.#slots.get(this.#active)
589
+ if (slot && this.#readOnly(slot)) return
590
+ if (!slot?.state.identity || !slot.state.content || slot.state.loading || slot.flight || slot.assetFlight || slot.state.composing || this.#disposed) return
591
+ const identity = slot.state.identity, documentId = slot.state.content.id, input = slot.input, read = ++slot.read
592
+ const operation = slot.recoveryOperation = (slot.recoveryOperation ?? 0) + 1
593
+ const pendingSave = slot.timer !== undefined
594
+ clearTimeout(slot.timer); slot.timer = undefined
595
+ let adopted = false
596
+ let protectedRecord: { id?: string, revision?: number } | undefined
597
+ const current = () => !this.#disposed && this.#active === slot.state.directory && this.#slots.get(slot.state.directory) === slot && slot.state.identity === identity && slot.read === read && slot.input === input && slot.recoveryOperation === operation
598
+ && !slot.state.composing && !slot.flight && !slot.assetFlight && (!protectedRecord || slot.draft?.id === protectedRecord.id && slot.draft?.revision === protectedRecord.revision)
599
+ slot.state = { ...slot.state, recovering: true, recoveryChanged: false }; this.#emit(slot)
600
+ try {
601
+ if (!this.#persist(slot)) { this.#emit(slot); return }
602
+ const backup = slot.backup
603
+ if (backup && await backup.done !== 'committed') return
604
+ if (!current()) return
605
+ protectedRecord = { id: slot.draft?.id, revision: slot.draft?.revision }
606
+ const candidate = (await slot.drafts.read(identity, documentId)).records.find(record => record.id === value.id && record.revision === value.revision && record.token === value.token)
607
+ if (!current()) return
608
+ if (!candidate) { slot.state = { ...slot.state, recoveryChanged: true }; return }
609
+ const answer = await this.#client.readDocument(slot.state.directory)
610
+ if (!current()) return
611
+ if (answer.document !== slot.state.directory || answer.content.id !== documentId) { slot.state = { ...slot.state, error: 'document-identity-changed' }; return }
612
+ const [localHash, remoteHash] = await Promise.all([digest(candidate.content), digest(answer.content)])
613
+ if (!current()) return
614
+ const origins = [...new Map([...(candidate.origins ?? []), { id: candidate.id, revision: candidate.revision, token: candidate.token }].map(value => [JSON.stringify([value.id, value.revision, value.token]), value])).values()]
615
+ if (localHash === remoteHash && !candidate.composing) {
616
+ const settled = await slot.drafts.settle(identity, origins)
617
+ if (!current()) return
618
+ slot.persistFailure = !settled
619
+ return
620
+ }
621
+ const conflict = answer.version !== candidate.baseVersion && remoteHash !== candidate.baseHash && remoteHash !== candidate.pendingHash
622
+ const version = remoteHash === candidate.pendingHash || remoteHash === candidate.baseHash ? answer.version : candidate.baseVersion
623
+ const baseHash = remoteHash === candidate.pendingHash ? remoteHash : candidate.baseHash
624
+ const record = await slot.drafts.fork(identity, { content: candidate.content, baseVersion: version, baseHash, input: input + 1,
625
+ pendingHash: candidate.pendingHash, composing: Boolean(candidate.composing), origins })
626
+ if (!current()) { if (record) slot.drafts.archive(record); return }
627
+ if (!record) { slot.persistFailure = true; slot.state = { ...slot.state, storageFailure: true }; return }
628
+ clearTimeout(slot.timer); slot.timer = undefined
629
+ if (slot.draft) slot.drafts.archive(slot.draft)
630
+ slot.input = input + 1; slot.baseHash = baseHash; slot.pendingHash = candidate.pendingHash; slot.draft = record; slot.draftOrigins = origins
631
+ slot.backup = undefined; slot.persistFailure = false
632
+ adopted = true
633
+ slot.compositions.clear()
634
+ slot.state = { ...slot.state, content: candidate.content, version, fingerprint: answer.fingerprint, dirty: true, restored: true, composing: false,
635
+ backupPending: false, storageFailure: slot.legacyFailure === 'write', reset: slot.state.reset + 1,
636
+ error: conflict ? 'document-version-changed' : candidate.composing ? 'document-composition-interrupted' : undefined, issues: answer.issues }
637
+ } catch (error) {
638
+ if (current()) slot.state = { ...slot.state, error: this.#error(error), issues: this.#issues(error) }
639
+ } finally {
640
+ if (!this.#disposed && this.#slots.get(slot.state.directory) === slot && slot.state.identity === identity && slot.recoveryOperation === operation) {
641
+ if (!adopted && slot.input === input && pendingSave && slot.timer === undefined) this.#schedule(slot)
642
+ slot.state = { ...slot.state, recovering: false }; this.#emit(slot); void this.#readDrafts(slot)
643
+ }
644
+ }
645
+ }
646
+
647
+ async forgetDraft(value: DocumentDraftReference): Promise<void> {
648
+ const slot = this.#active === undefined ? undefined : this.#slots.get(this.#active)
649
+ if (!slot?.state.identity || !slot.state.content || slot.state.recovering || slot.flight || slot.assetFlight || slot.state.composing || this.#disposed) return
650
+ const identity = slot.state.identity, documentId = slot.state.content.id, input = slot.input, read = slot.read
651
+ const operation = slot.recoveryOperation = (slot.recoveryOperation ?? 0) + 1
652
+ const current = () => !this.#disposed && this.#active === slot.state.directory && this.#slots.get(slot.state.directory) === slot && slot.state.identity === identity && slot.input === input && slot.read === read && slot.recoveryOperation === operation && !slot.state.composing && !slot.flight && !slot.assetFlight
653
+ slot.state = { ...slot.state, recovering: true }; this.#emit(slot)
654
+ try {
655
+ const candidate = (await slot.drafts.read(identity, documentId)).records.find(record => record.id === value.id && record.revision === value.revision && record.token === value.token)
656
+ if (!current()) return
657
+ if (candidate?.owned && candidate.id === slot.draft?.id) return
658
+ if (!candidate) slot.state = { ...slot.state, recoveryChanged: true }
659
+ else {
660
+ const settled = await slot.drafts.settle(identity, [value])
661
+ if (!current()) return
662
+ slot.persistFailure = !settled
663
+ slot.state = { ...slot.state, recoveryChanged: false, storageFailure: Boolean(slot.persistFailure || slot.legacyFailure === 'write') }
664
+ }
665
+ } catch { if (current()) { slot.persistFailure = true; slot.state = { ...slot.state, storageFailure: true } } }
666
+ finally {
667
+ if (!this.#disposed && this.#slots.get(slot.state.directory) === slot && slot.state.identity === identity && slot.recoveryOperation === operation) {
668
+ slot.state = { ...slot.state, recovering: false }; this.#emit(slot); void this.#readDrafts(slot)
669
+ }
670
+ }
671
+ }
672
+
673
+ dispose(): void {
674
+ this.#disposed = true; this.#syncLeaveProtection(); this.close()
675
+ if (typeof window !== 'undefined') { window.removeEventListener('storage', this.#storageChanged); window.removeEventListener('focus', this.#draftFocus) }
676
+ for (const slot of this.#slots.values()) { clearTimeout(slot.timer); this.#persist(slot); slot.drafts.close() }
677
+ }
678
+ }