@artkit-ai/react 0.1.0 → 0.2.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 (89) hide show
  1. package/dist/cache.d.ts +7 -11
  2. package/dist/cache.d.ts.map +1 -1
  3. package/dist/cache.js +32 -53
  4. package/dist/cache.js.map +1 -1
  5. package/dist/client.d.ts +4 -15
  6. package/dist/client.d.ts.map +1 -1
  7. package/dist/client.js +2 -29
  8. package/dist/client.js.map +1 -1
  9. package/dist/components/card.d.ts +2 -2
  10. package/dist/components/card.d.ts.map +1 -1
  11. package/dist/components/card.js +3 -3
  12. package/dist/components/card.js.map +1 -1
  13. package/dist/components/editor.d.ts +31 -18
  14. package/dist/components/editor.d.ts.map +1 -1
  15. package/dist/components/editor.js +81 -131
  16. package/dist/components/editor.js.map +1 -1
  17. package/dist/components/icons.d.ts +2 -2
  18. package/dist/components/icons.d.ts.map +1 -1
  19. package/dist/components/icons.js +2 -2
  20. package/dist/components/icons.js.map +1 -1
  21. package/dist/components/panel.d.ts +13 -16
  22. package/dist/components/panel.d.ts.map +1 -1
  23. package/dist/components/panel.js +13 -97
  24. package/dist/components/panel.js.map +1 -1
  25. package/dist/components/shared.d.ts +2 -6
  26. package/dist/components/shared.d.ts.map +1 -1
  27. package/dist/components/shared.js +2 -6
  28. package/dist/components/shared.js.map +1 -1
  29. package/dist/components/view.d.ts +7 -8
  30. package/dist/components/view.d.ts.map +1 -1
  31. package/dist/components/view.js +16 -15
  32. package/dist/components/view.js.map +1 -1
  33. package/dist/context.d.ts +14 -9
  34. package/dist/context.d.ts.map +1 -1
  35. package/dist/context.js.map +1 -1
  36. package/dist/editor-session.d.ts +77 -165
  37. package/dist/editor-session.d.ts.map +1 -1
  38. package/dist/editor-session.js +164 -586
  39. package/dist/editor-session.js.map +1 -1
  40. package/dist/hooks.d.ts +2 -23
  41. package/dist/hooks.d.ts.map +1 -1
  42. package/dist/hooks.js +24 -116
  43. package/dist/hooks.js.map +1 -1
  44. package/dist/i18n.d.ts +4 -66
  45. package/dist/i18n.d.ts.map +1 -1
  46. package/dist/i18n.js +6 -68
  47. package/dist/i18n.js.map +1 -1
  48. package/dist/index.d.ts +6 -9
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +4 -8
  51. package/dist/index.js.map +1 -1
  52. package/dist/provider.d.ts +1 -1
  53. package/dist/provider.d.ts.map +1 -1
  54. package/dist/provider.js +12 -12
  55. package/dist/provider.js.map +1 -1
  56. package/dist/styles.d.ts +1 -1
  57. package/dist/styles.d.ts.map +1 -1
  58. package/dist/styles.js +4 -4
  59. package/package.json +2 -2
  60. package/src/cache.ts +32 -55
  61. package/src/client.ts +7 -55
  62. package/src/components/card.tsx +6 -7
  63. package/src/components/editor.tsx +208 -308
  64. package/src/components/icons.tsx +2 -2
  65. package/src/components/panel.tsx +43 -300
  66. package/src/components/shared.tsx +3 -12
  67. package/src/components/view.tsx +26 -29
  68. package/src/context.ts +16 -11
  69. package/src/editor-session.ts +197 -675
  70. package/src/hooks.ts +25 -140
  71. package/src/i18n.ts +6 -68
  72. package/src/index.ts +7 -39
  73. package/src/provider.tsx +13 -13
  74. package/src/styles.ts +4 -4
  75. package/dist/components/approval.d.ts +0 -34
  76. package/dist/components/approval.d.ts.map +0 -1
  77. package/dist/components/approval.js +0 -80
  78. package/dist/components/approval.js.map +0 -1
  79. package/dist/components/version-diff.d.ts +0 -35
  80. package/dist/components/version-diff.d.ts.map +0 -1
  81. package/dist/components/version-diff.js +0 -85
  82. package/dist/components/version-diff.js.map +0 -1
  83. package/dist/line-diff.d.ts +0 -25
  84. package/dist/line-diff.d.ts.map +0 -1
  85. package/dist/line-diff.js +0 -96
  86. package/dist/line-diff.js.map +0 -1
  87. package/src/components/approval.tsx +0 -219
  88. package/src/components/version-diff.tsx +0 -266
  89. package/src/line-diff.ts +0 -111
@@ -4,735 +4,257 @@ import {
4
4
  type ArtifactRegistry,
5
5
  applyOperations,
6
6
  type ChangeSummary,
7
- type CommitResult,
8
- deepEqual,
9
- type EmitOptions,
10
7
  isArtifactError,
8
+ type LintIssue,
11
9
  } from '@artkit-ai/core';
12
10
 
13
- /** The editable part of an artifact: content plus the envelope fields operations can change. */
14
- export interface EnvelopeState {
15
- content: unknown;
16
- title: string;
17
- themeId: string;
18
- locale: string;
19
- }
11
+ /**
12
+ * Local draft of one artifact (spec §11.6). The editor never talks to the server while the user edits: every batch is
13
+ * applied here, with the same pure engine as the server (`applyOperations`), and kept until the application saves.
14
+ * Saving is the application's decision: the editor emits the draft and the application persists it (or not).
15
+ */
20
16
 
21
- export interface SendRequest {
17
+ export interface DraftSnapshot {
18
+ /** Version of the artifact the draft started from (the last one the application loaded or saved). */
22
19
  baseVersion: number;
23
- operations: unknown[];
24
- idempotencyKey: string;
25
- }
26
-
27
- export interface EditorSessionOptions {
28
- registry: ArtifactRegistry;
29
- /** Last confirmed version. */
20
+ /** The draft as the user sees it. */
30
21
  artifact: Artifact;
31
- /** Commits a batch (usually `client.apply`). */
32
- send(request: SendRequest): Promise<CommitResult>;
33
- /** Loads the latest version, or a specific one, after a conflict or a rebased commit. */
34
- load(version?: number): Promise<Artifact>;
35
- /** Typing operations emitted within this window become one batch (default 600 ms). */
36
- groupingWindowMs?: number;
37
- /** Upper bound for one typing batch while the user keeps typing (default 5 s). */
38
- maxGroupingMs?: number;
39
- /** Undo entries kept (default 100). */
40
- maxUndo?: number;
41
- /** Locale of error messages produced locally. */
42
- locale?: string;
43
- createKey?(): string;
44
- onCommitted?(result: CommitResult): void;
45
- }
46
-
47
- export type SessionStatus = 'idle' | 'editing' | 'saving' | 'conflict';
48
-
49
- export interface SessionConflict {
50
- /**
51
- * `server`: the server answered VERSION_CONFLICT; `rebase`: a newer version arrived and the pending
52
- * operations no longer apply on it; `undo`/`redo`: the artifact changed after the edit being undone.
53
- */
54
- reason: 'server' | 'rebase' | 'undo' | 'redo';
55
- /** What changed in the newer version, for the conflict diff. */
56
- changes: ChangeSummary[];
57
- /** The local operations that would be reapplied. */
22
+ /** Operations applied since `baseVersion`, in order. */
58
23
  operations: unknown[];
59
- latest: Artifact;
60
- }
61
-
62
- export type SessionEvent =
63
- | { type: 'saved'; version: number }
64
- | { type: 'undone'; version: number }
65
- | { type: 'redone'; version: number }
66
- | { type: 'rebased'; version: number }
67
- | { type: 'rejected'; error: ArtifactError }
68
- | { type: 'rolled_back'; error: ArtifactError }
69
- | { type: 'conflict'; reason: SessionConflict['reason'] }
70
- | { type: 'conflict_resolved'; choice: ConflictChoice };
71
-
72
- export type ConflictChoice = 'keep-theirs' | 'reapply';
73
-
74
- export interface EditorSessionState {
75
- confirmed: Artifact;
76
- /** Optimistic state: confirmed version plus every unconfirmed local operation. */
77
- view: EnvelopeState;
78
- status: SessionStatus;
79
- /** Local operations not confirmed yet (buffered, queued or in flight). */
24
+ /** True when the draft differs from the base. */
80
25
  dirty: boolean;
81
26
  canUndo: boolean;
82
27
  canRedo: boolean;
83
- error?: ArtifactError;
84
- conflict?: SessionConflict;
85
- /** Set after a LOCKED rejection; editing stays disabled until then. */
86
- lockedUntil?: string;
87
- /** Last notable event, with a sequence number so hosts can announce each one once. */
88
- event?: SessionEvent & { seq: number };
28
+ /** An edit is being applied. */
29
+ pending: boolean;
30
+ /** Last rejected batch, cleared by the next accepted one. */
31
+ error?: { code: string; message: string; issues: { path: string; message: string; hint?: string }[] };
32
+ /** Lint issues of the draft. */
33
+ lint: LintIssue[];
34
+ /** Changes of the last accepted batch. */
35
+ changes: ChangeSummary[];
89
36
  }
90
37
 
91
- interface UndoEntry {
92
- /** Missing when the commit was rebased: loaded from `beforeVersion` on demand. */
93
- before?: EnvelopeState;
94
- beforeVersion: number;
95
- after: EnvelopeState;
38
+ export interface DraftSessionOptions {
39
+ registry: ArtifactRegistry;
40
+ artifact: Artifact;
41
+ /** Locale of the model-facing messages produced by the engine (pt-BR or en). */
42
+ locale?: string;
43
+ /** Times within which consecutive typing batches share one undo step. */
44
+ coalesceMs?: number;
45
+ /** Undo steps kept in memory. */
46
+ maxUndo?: number;
47
+ now?: () => number;
96
48
  }
97
49
 
98
- type Origin = 'edit' | 'undo' | 'redo' | 'reapply';
99
-
100
- type QueueItem =
101
- | { type: 'ops'; ops: unknown[]; key: string; origin: Origin; entry?: UndoEntry }
102
- | { type: 'undo' }
103
- | { type: 'redo' };
104
-
105
- export function envelopeOf(
106
- artifact: Pick<Artifact, 'content' | 'title' | 'themeId' | 'locale'>,
107
- ): EnvelopeState {
108
- return {
109
- content: artifact.content,
110
- title: artifact.title,
111
- themeId: artifact.themeId,
112
- locale: artifact.locale,
113
- };
50
+ interface Step {
51
+ artifact: Artifact;
52
+ operations: unknown[];
53
+ at: number;
54
+ typing: boolean;
114
55
  }
115
56
 
116
- function sameEnvelope(a: EnvelopeState, b: EnvelopeState): boolean {
117
- return (
118
- a.title === b.title && a.themeId === b.themeId && a.locale === b.locale && deepEqual(a.content, b.content)
119
- );
57
+ export interface EmitOptions {
58
+ /** Continuous gestures (typing, dragging) share one undo step; discrete changes always get their own. */
59
+ kind?: 'typing' | 'continuous' | 'discrete';
120
60
  }
121
61
 
122
- /**
123
- * Operations that turn `from` into `to`. Kinds do not provide inverse operations, so undo and redo send
124
- * `replace_content` with the snapshot (plus envelope operations); the commit still becomes a new version
125
- * (spec §8.5).
126
- */
127
- export function operationsBetween(from: EnvelopeState, to: EnvelopeState): unknown[] {
128
- const ops: unknown[] = [];
129
- if (!deepEqual(from.content, to.content))
130
- ops.push({ op: 'replace_content', content: structuredClone(to.content) });
131
- if (from.title !== to.title) ops.push({ op: 'set_title', title: to.title });
132
- if (from.themeId !== to.themeId) ops.push({ op: 'set_theme', themeId: to.themeId });
133
- if (from.locale !== to.locale) ops.push({ op: 'set_locale', locale: to.locale });
134
- return ops;
135
- }
62
+ type Listener = () => void;
136
63
 
137
- function toArtifactError(error: unknown): ArtifactError {
138
- if (isArtifactError(error)) return error;
139
- return new ArtifactError('INTERNAL_ERROR', 'Internal error while processing the request.', {
140
- cause: error,
141
- });
142
- }
64
+ const DEFAULT_COALESCE_MS = 1000;
65
+ const DEFAULT_MAX_UNDO = 200;
143
66
 
144
- function defaultKey(): string {
145
- const cryptoApi = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
146
- if (cryptoApi?.randomUUID) return cryptoApi.randomUUID();
147
- return `ak-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
148
- }
149
-
150
- /**
151
- * Framework-free editing session behind `ArtifactEditor` (spec §13.6): batching of typing operations,
152
- * optimistic apply with the kind's own logic, rollback on rejection, local undo/redo over the user's own
153
- * batches, rebase of pending operations on newer versions, conflict resolution and lock handling.
154
- *
155
- * Batches are sent one at a time, each against the version the previous one produced.
156
- */
157
- export class EditorSession {
158
- private readonly options: EditorSessionOptions;
159
- private readonly windowMs: number;
160
- private readonly maxGroupingMs: number;
67
+ /** Draft of one artifact with local undo and redo. Batches are applied in order, one at a time. */
68
+ export class DraftSession {
69
+ private readonly registry: ArtifactRegistry;
70
+ private readonly locale: string;
71
+ private readonly coalesceMs: number;
161
72
  private readonly maxUndo: number;
162
- private confirmed: Artifact;
163
- private view: EnvelopeState;
164
- private buffer: unknown[] = [];
165
- private bufferStartedAt = 0;
166
- private timer: ReturnType<typeof setTimeout> | undefined;
167
- private lockTimer: ReturnType<typeof setTimeout> | undefined;
168
- private queue: QueueItem[] = [];
169
- private inflight = false;
170
- private undoStack: UndoEntry[] = [];
171
- private redoStack: UndoEntry[] = [];
172
- private error: ArtifactError | undefined;
173
- private conflict: (SessionConflict & { origin: Origin; entry?: UndoEntry }) | undefined;
174
- private lockedUntil: string | undefined;
175
- private deferredRemote: Artifact | undefined;
176
- private event: (SessionEvent & { seq: number }) | undefined;
177
- private seq = 0;
73
+ private readonly now: () => number;
74
+ private base: Artifact;
75
+ private current: Step;
76
+ private undoStack: Step[] = [];
77
+ private redoStack: Step[] = [];
78
+ private queue: Promise<unknown> = Promise.resolve();
79
+ private pending = 0;
80
+ private error: DraftSnapshot['error'];
81
+ private lint: LintIssue[];
82
+ private changes: ChangeSummary[] = [];
83
+ private snapshot: DraftSnapshot;
84
+ private readonly listeners = new Set<Listener>();
178
85
  private disposed = false;
179
- private readonly listeners = new Set<() => void>();
180
- private idleWaiters: (() => void)[] = [];
181
- private snapshot: EditorSessionState;
182
86
 
183
- constructor(options: EditorSessionOptions) {
184
- this.options = options;
185
- this.windowMs = options.groupingWindowMs ?? 600;
186
- this.maxGroupingMs = options.maxGroupingMs ?? 5000;
187
- this.maxUndo = options.maxUndo ?? 100;
188
- this.confirmed = options.artifact;
189
- this.view = envelopeOf(options.artifact);
87
+ constructor(options: DraftSessionOptions) {
88
+ this.registry = options.registry;
89
+ this.locale = options.locale ?? 'en';
90
+ this.coalesceMs = options.coalesceMs ?? DEFAULT_COALESCE_MS;
91
+ this.maxUndo = options.maxUndo ?? DEFAULT_MAX_UNDO;
92
+ this.now = options.now ?? (() => Date.now());
93
+ this.base = options.artifact;
94
+ this.current = { artifact: options.artifact, operations: [], at: 0, typing: false };
95
+ this.lint = this.lintOf(options.artifact);
190
96
  this.snapshot = this.buildSnapshot();
191
97
  }
192
98
 
193
- // -- subscription ---------------------------------------------------------------------------------
99
+ /** The current snapshot. Its identity changes only when the state changes. */
100
+ getSnapshot = (): DraftSnapshot => this.snapshot;
194
101
 
195
- subscribe = (listener: () => void): (() => void) => {
102
+ subscribe = (listener: Listener): (() => void) => {
196
103
  this.listeners.add(listener);
197
104
  return () => this.listeners.delete(listener);
198
105
  };
199
106
 
200
- getSnapshot = (): EditorSessionState => this.snapshot;
201
-
202
- /** Resolves once nothing is buffered, queued or in flight (or a conflict is waiting for the user). */
203
- whenIdle(): Promise<void> {
204
- if (this.isIdle()) return Promise.resolve();
205
- return new Promise((resolve) => this.idleWaiters.push(resolve));
206
- }
207
-
208
- private isIdle(): boolean {
209
- return (
210
- this.buffer.length === 0 && !this.inflight && (this.queue.length === 0 || this.conflict !== undefined)
211
- );
212
- }
213
-
214
- private buildSnapshot(): EditorSessionState {
215
- const pendingEdits = this.queue.filter(
216
- (item) => item.type === 'ops' && item.origin !== 'undo' && item.origin !== 'redo',
217
- ).length;
218
- const queuedUndo = this.queue.filter((item) => item.type === 'undo').length;
219
- const queuedRedo = this.queue.filter((item) => item.type === 'redo').length;
220
- const dirty = this.buffer.length > 0 || this.queue.length > 0 || this.inflight;
221
- let status: SessionStatus = 'idle';
222
- if (this.conflict) status = 'conflict';
223
- else if (this.inflight || this.queue.length > 0) status = 'saving';
224
- else if (this.buffer.length > 0) status = 'editing';
225
- const blocked = this.conflict !== undefined || this.isLocked();
226
- return {
227
- confirmed: this.confirmed,
228
- view: this.view,
229
- status,
230
- dirty,
231
- canUndo:
232
- !blocked && this.undoStack.length + pendingEdits + (this.buffer.length > 0 ? 1 : 0) > queuedUndo,
233
- canRedo:
234
- !blocked && this.buffer.length === 0 && pendingEdits === 0 && this.redoStack.length > queuedRedo,
235
- ...(this.error ? { error: this.error } : {}),
236
- ...(this.conflict
237
- ? {
238
- conflict: {
239
- reason: this.conflict.reason,
240
- changes: this.conflict.changes,
241
- operations: this.conflict.operations,
242
- latest: this.conflict.latest,
243
- },
244
- }
245
- : {}),
246
- ...(this.lockedUntil ? { lockedUntil: this.lockedUntil } : {}),
247
- ...(this.event ? { event: this.event } : {}),
248
- };
249
- }
250
-
251
- private notify(): void {
252
- this.snapshot = this.buildSnapshot();
253
- for (const listener of [...this.listeners]) listener();
254
- if (this.isIdle() && this.idleWaiters.length > 0) {
255
- const waiters = this.idleWaiters;
256
- this.idleWaiters = [];
257
- for (const resolve of waiters) resolve();
258
- }
259
- }
260
-
261
- private announce(event: SessionEvent): void {
262
- this.seq += 1;
263
- this.event = { ...event, seq: this.seq };
264
- }
265
-
266
- private isLocked(): boolean {
267
- return this.lockedUntil !== undefined && Date.parse(this.lockedUntil) > Date.now();
268
- }
269
-
270
- // -- local application -----------------------------------------------------------------------------
271
-
272
- private applyLocal(state: EnvelopeState, ops: unknown[]): EnvelopeState {
273
- const result = applyOperations(
274
- this.options.registry,
275
- { kind: this.confirmed.kind, ...state },
276
- ops,
277
- this.options.locale ? { locale: this.options.locale } : {},
278
- );
279
- return { content: result.content, title: result.title, themeId: result.themeId, locale: result.locale };
280
- }
281
-
282
- private pendingOperations(): unknown[] {
283
- const ops: unknown[] = [];
284
- for (const item of this.queue) if (item.type === 'ops') ops.push(...item.ops);
285
- ops.push(...this.buffer);
286
- return ops;
287
- }
288
-
289
107
  /**
290
- * Recomputes the optimistic view from the confirmed version. When the unconfirmed operations no
291
- * longer apply, they move into a `rebase` conflict and the view falls back to the confirmed version.
108
+ * Applies a batch to the draft. Resolves to `true` when the batch was accepted. A rejected batch leaves the draft
109
+ * untouched and sets `snapshot.error`.
292
110
  */
293
- private rebaseView(previous: Artifact): void {
294
- let state = envelopeOf(this.confirmed);
295
- try {
296
- for (const item of this.queue) if (item.type === 'ops') state = this.applyLocal(state, item.ops);
297
- if (this.buffer.length > 0) state = this.applyLocal(state, this.buffer);
298
- this.view = state;
299
- } catch {
300
- const operations = this.pendingOperations();
301
- this.clearTimer();
302
- this.buffer = [];
303
- // The in-flight batch (if any) is already at the server and settles on its own.
304
- this.queue = this.inflight ? this.queue.slice(0, 1) : [];
305
- this.view = envelopeOf(this.confirmed);
306
- this.conflict = {
307
- reason: 'rebase',
308
- changes: this.changesBetween(previous, this.confirmed),
309
- operations,
310
- latest: this.confirmed,
311
- origin: 'edit',
312
- };
313
- this.announce({ type: 'conflict', reason: 'rebase' });
314
- }
111
+ apply(operations: unknown[], options: EmitOptions = {}): Promise<boolean> {
112
+ if (operations.length === 0) return Promise.resolve(true);
113
+ this.pending += 1;
114
+ this.publish();
115
+ const run = this.queue.then(async () => {
116
+ if (this.disposed) return false;
117
+ const typing = options.kind === 'typing' || options.kind === 'continuous';
118
+ try {
119
+ const result = await applyOperations(this.registry, this.current.artifact, operations, {
120
+ locale: this.locale,
121
+ });
122
+ const next: Artifact = {
123
+ ...this.current.artifact,
124
+ formatVersion: result.formatVersion,
125
+ title: result.title,
126
+ locale: result.locale,
127
+ themeId: result.themeId,
128
+ content: result.content,
129
+ };
130
+ const now = this.now();
131
+ const coalesce = typing && this.current.typing && now - this.current.at <= this.coalesceMs;
132
+ if (!coalesce) {
133
+ this.undoStack.push(this.current);
134
+ if (this.undoStack.length > this.maxUndo) this.undoStack.shift();
135
+ }
136
+ this.redoStack = [];
137
+ this.current = {
138
+ artifact: next,
139
+ operations: [...this.current.operations, ...operations],
140
+ at: now,
141
+ typing,
142
+ };
143
+ this.lint = result.lint;
144
+ this.changes = result.diff;
145
+ this.error = undefined;
146
+ return true;
147
+ } catch (error) {
148
+ this.error = errorOf(error);
149
+ return false;
150
+ }
151
+ });
152
+ this.queue = run.finally(() => {
153
+ this.pending -= 1;
154
+ this.publish();
155
+ });
156
+ return run.then((accepted) => {
157
+ this.publish();
158
+ return accepted;
159
+ });
315
160
  }
316
161
 
317
- private changesBetween(before: EnvelopeState, after: EnvelopeState): ChangeSummary[] {
318
- const changes: ChangeSummary[] = [];
319
- if (before.title !== after.title)
320
- changes.push({
321
- type: 'changed',
322
- target: 'envelope:title',
323
- summary: `'${before.title}' → '${after.title}'`,
324
- });
325
- if (before.themeId !== after.themeId)
326
- changes.push({
327
- type: 'changed',
328
- target: 'envelope:themeId',
329
- summary: `${before.themeId} → ${after.themeId}`,
330
- });
331
- if (before.locale !== after.locale)
332
- changes.push({
333
- type: 'changed',
334
- target: 'envelope:locale',
335
- summary: `${before.locale} → ${after.locale}`,
336
- });
337
- try {
338
- changes.push(...this.options.registry.get(this.confirmed.kind).diff(before.content, after.content));
339
- } catch {
340
- // A diff failure must not hide the conflict itself.
341
- }
342
- return changes;
162
+ /** Steps back one edit (local). Returns false when there is nothing to undo. */
163
+ undo(): boolean {
164
+ const previous = this.undoStack.pop();
165
+ if (!previous) return false;
166
+ this.redoStack.push(this.current);
167
+ this.restore(previous);
168
+ return true;
343
169
  }
344
170
 
345
- // -- editing ---------------------------------------------------------------------------------------
171
+ /** Steps forward after an undo. Returns false when there is nothing to redo. */
172
+ redo(): boolean {
173
+ const next = this.redoStack.pop();
174
+ if (!next) return false;
175
+ this.undoStack.push(this.current);
176
+ this.restore(next);
177
+ return true;
178
+ }
346
179
 
347
180
  /**
348
- * Called by the kind editor. Operations are applied optimistically right away; invalid ones are
349
- * rejected locally (returns false) and never reach the server.
181
+ * Adopts an artifact loaded or saved by the application. A clean draft follows the new artifact. A draft that has
182
+ * changes keeps its content, and the base moves to the new version so the next save is a complete one.
350
183
  */
351
- emit = (operations: unknown[], options: EmitOptions = {}): boolean => {
352
- if (
353
- this.disposed ||
354
- this.conflict ||
355
- this.isLocked() ||
356
- !Array.isArray(operations) ||
357
- operations.length === 0
358
- )
359
- return false;
360
- try {
361
- this.view = this.applyLocal(this.view, operations);
362
- } catch (error) {
363
- this.error = toArtifactError(error);
364
- this.announce({ type: 'rejected', error: this.error });
365
- this.notify();
366
- return false;
367
- }
368
- this.error = undefined;
369
- this.redoStack = [];
370
- const maxOps = this.options.registry.batch.maxOperations;
371
- if (options.kind === 'typing') {
372
- if (this.buffer.length + operations.length > maxOps) this.flushBuffer();
373
- if (this.buffer.length === 0) this.bufferStartedAt = Date.now();
374
- this.buffer.push(...operations);
375
- if (Date.now() - this.bufferStartedAt >= this.maxGroupingMs) this.flushBuffer();
376
- else this.schedule();
377
- } else {
378
- // Discrete operations and the end of continuous gestures flush right away, after pending typing.
379
- this.flushBuffer();
380
- this.queue.push({ type: 'ops', ops: operations, key: this.key(), origin: 'edit' });
184
+ load(artifact: Artifact): void {
185
+ if (artifact.id !== this.base.id) throw new ArtifactError('INVALID_OPERATION', 'Load the same artifact.');
186
+ const clean = !this.buildSnapshot().dirty;
187
+ const sameContent = JSON.stringify(artifact.content) === JSON.stringify(this.current.artifact.content);
188
+ this.base = artifact;
189
+ if (clean || sameContent) {
190
+ this.current = { artifact, operations: [], at: 0, typing: false };
191
+ this.undoStack = [];
192
+ this.redoStack = [];
193
+ this.lint = this.lintOf(artifact);
194
+ this.changes = [];
381
195
  }
382
- this.pump();
383
- this.notify();
384
- return true;
385
- };
386
-
387
- /** Sends buffered typing operations now (e.g. on blur or before navigation). */
388
- flush(): void {
389
- if (this.buffer.length === 0) return;
390
- this.flushBuffer();
391
- this.pump();
392
- this.notify();
393
- }
394
-
395
- private key(): string {
396
- return this.options.createKey?.() ?? defaultKey();
397
- }
398
-
399
- private schedule(): void {
400
- this.clearTimer();
401
- this.timer = setTimeout(() => {
402
- this.timer = undefined;
403
- this.flush();
404
- }, this.windowMs);
405
- }
406
-
407
- private clearTimer(): void {
408
- if (this.timer !== undefined) clearTimeout(this.timer);
409
- this.timer = undefined;
196
+ this.publish();
410
197
  }
411
198
 
412
- private flushBuffer(): void {
413
- this.clearTimer();
414
- if (this.buffer.length === 0) return;
415
- this.queue.push({ type: 'ops', ops: this.buffer, key: this.key(), origin: 'edit' });
416
- this.buffer = [];
199
+ /** Stops the session: later batches are ignored and listeners are dropped. */
200
+ dispose(): void {
201
+ this.disposed = true;
202
+ this.listeners.clear();
417
203
  }
418
204
 
419
- undo = (): void => {
420
- if (!this.snapshot.canUndo) return;
421
- this.flushBuffer();
422
- this.queue.push({ type: 'undo' });
423
- this.pump();
424
- this.notify();
425
- };
426
-
427
- redo = (): void => {
428
- if (!this.snapshot.canRedo) return;
429
- this.queue.push({ type: 'redo' });
430
- this.pump();
431
- this.notify();
432
- };
433
-
434
- dismissError = (): void => {
435
- if (!this.error) return;
205
+ private restore(step: Step): void {
206
+ this.current = step;
207
+ this.lint = this.lintOf(step.artifact);
208
+ this.changes = [];
436
209
  this.error = undefined;
437
- this.notify();
438
- };
439
-
440
- // -- sending ---------------------------------------------------------------------------------------
441
-
442
- private pump(): void {
443
- if (this.inflight || this.conflict) return;
444
- const item = this.queue[0];
445
- if (!item) return;
446
- if (item.type === 'undo' || item.type === 'redo') {
447
- this.resolveHistoryCommand(item.type);
448
- return;
449
- }
450
- this.inflight = true;
451
- const base = this.confirmed;
452
- this.options
453
- .send({ baseVersion: base.version, operations: item.ops, idempotencyKey: item.key })
454
- .then(
455
- (result) => this.onCommitted(item, base, result),
456
- (error: unknown) => this.onRejected(item, base, error),
457
- )
458
- .finally(() => this.notify());
459
- }
460
-
461
- private resolveHistoryCommand(direction: 'undo' | 'redo'): void {
462
- const stack = direction === 'undo' ? this.undoStack : this.redoStack;
463
- const entry = stack.pop();
464
- if (!entry) {
465
- this.queue.shift();
466
- this.pump();
467
- return;
468
- }
469
- if (!entry.before) {
470
- // Rebased commit: the version before it is only known by the server.
471
- this.inflight = true;
472
- this.options
473
- .load(entry.beforeVersion)
474
- .then(
475
- (artifact) => {
476
- entry.before = envelopeOf(artifact);
477
- stack.push(entry);
478
- },
479
- (error: unknown) => {
480
- this.queue.shift();
481
- this.error = toArtifactError(error);
482
- this.announce({ type: 'rolled_back', error: this.error });
483
- },
484
- )
485
- .finally(() => {
486
- this.inflight = false;
487
- this.applyDeferred();
488
- this.pump();
489
- this.notify();
490
- });
491
- return;
492
- }
493
- const current = envelopeOf(this.confirmed);
494
- const expected = direction === 'undo' ? entry.after : entry.before;
495
- const target = direction === 'undo' ? entry.before : entry.after;
496
- this.queue.shift();
497
- if (!sameEnvelope(current, expected)) {
498
- // Someone else changed the artifact after this edit: undoing blindly would discard their work.
499
- this.conflict = {
500
- reason: direction,
501
- changes: this.changesBetween(expected, current),
502
- operations: operationsBetween(current, target),
503
- latest: this.confirmed,
504
- origin: direction,
505
- entry,
506
- };
507
- this.view = current;
508
- this.announce({ type: 'conflict', reason: direction });
509
- return;
510
- }
511
- const ops = operationsBetween(current, target);
512
- if (ops.length === 0) {
513
- (direction === 'undo' ? this.redoStack : this.undoStack).push(entry);
514
- this.pump();
515
- return;
516
- }
517
- this.queue.unshift({ type: 'ops', ops, key: this.key(), origin: direction, entry });
518
- this.rebaseView(this.confirmed);
519
- this.pump();
520
- }
521
-
522
- private onCommitted(item: Extract<QueueItem, { type: 'ops' }>, base: Artifact, result: CommitResult): void {
523
- this.inflight = false;
524
- this.queue.shift();
525
- const previous = this.confirmed;
526
- if (result.artifact.version > this.confirmed.version) this.confirmed = result.artifact;
527
- const after = envelopeOf(result.artifact);
528
- if (item.origin === 'undo' && item.entry) {
529
- this.redoStack.push(item.entry);
530
- this.announce({ type: 'undone', version: result.version });
531
- } else if (item.origin === 'redo' && item.entry) {
532
- this.pushUndo(item.entry);
533
- this.announce({ type: 'redone', version: result.version });
534
- } else {
535
- this.pushUndo({
536
- ...(result.rebased ? {} : { before: envelopeOf(base) }),
537
- beforeVersion: result.version - 1,
538
- after,
539
- });
540
- this.announce({ type: 'saved', version: result.version });
541
- }
542
- try {
543
- this.options.onCommitted?.(result);
544
- } catch {
545
- // A failing host callback must not break the session.
546
- }
547
- if (!this.disposed) this.rebaseView(previous);
548
- this.applyDeferred();
549
- this.pump();
550
- }
551
-
552
- /** Incorporates a version that arrived while a request was in flight. */
553
- private applyDeferred(): void {
554
- const remote = this.deferredRemote;
555
- this.deferredRemote = undefined;
556
- if (remote && remote.version > this.confirmed.version) this.incorporate(remote);
210
+ this.publish();
557
211
  }
558
212
 
559
- private pushUndo(entry: UndoEntry): void {
560
- this.undoStack.push(entry);
561
- if (this.undoStack.length > this.maxUndo) this.undoStack.splice(0, this.undoStack.length - this.maxUndo);
213
+ private lintOf(artifact: Artifact): LintIssue[] {
214
+ const adapter = this.registry.get(artifact.format, this.locale);
215
+ return adapter.lint(artifact.content as never, {
216
+ locale: artifact.locale,
217
+ theme: this.registry.theme(artifact.themeId, this.locale),
218
+ });
562
219
  }
563
220
 
564
- private onRejected(
565
- item: Extract<QueueItem, { type: 'ops' }>,
566
- base: Artifact,
567
- raw: unknown,
568
- ): Promise<void> | void {
569
- const error = toArtifactError(raw);
570
- this.queue.shift();
571
- if (error.code === 'VERSION_CONFLICT') {
572
- const operations = [...item.ops, ...this.pendingOperations()];
573
- this.clearTimer();
574
- this.queue = [];
575
- this.buffer = [];
576
- const changes = Array.isArray(error.details.changesSinceBase)
577
- ? (error.details.changesSinceBase as ChangeSummary[])
578
- : undefined;
579
- // Keep `inflight` set while loading so nothing else is sent against a stale base.
580
- return this.options
581
- .load()
582
- .then(
583
- (latest) => {
584
- if (latest.version > this.confirmed.version) this.confirmed = latest;
585
- },
586
- () => undefined,
587
- )
588
- .then(() => {
589
- this.inflight = false;
590
- const remote = this.deferredRemote;
591
- this.deferredRemote = undefined;
592
- if (remote && remote.version > this.confirmed.version) this.confirmed = remote;
593
- const reason = item.origin === 'undo' || item.origin === 'redo' ? item.origin : 'server';
594
- this.conflict = {
595
- reason,
596
- changes: changes ?? this.changesBetween(base, this.confirmed),
597
- operations: item.origin === 'undo' || item.origin === 'redo' ? item.ops : operations,
598
- latest: this.confirmed,
599
- origin: item.origin,
600
- ...(item.entry ? { entry: item.entry } : {}),
601
- };
602
- this.view = envelopeOf(this.confirmed);
603
- this.announce({ type: 'conflict', reason });
604
- });
605
- }
606
- this.inflight = false;
607
- // Any other rejection rolls back to the last confirmed version (spec §13.6).
608
- this.clearTimer();
609
- this.queue = [];
610
- this.buffer = [];
611
- if (item.entry) (item.origin === 'undo' ? this.undoStack : this.redoStack).push(item.entry);
612
- if (error.code === 'LOCKED') this.setLocked(error);
613
- this.error = error;
614
- this.view = envelopeOf(this.confirmed);
615
- this.announce({ type: 'rolled_back', error });
616
- const remote = this.deferredRemote;
617
- this.deferredRemote = undefined;
618
- if (remote && remote.version > this.confirmed.version) {
619
- this.confirmed = remote;
620
- this.view = envelopeOf(remote);
621
- }
221
+ private publish(): void {
222
+ this.snapshot = this.buildSnapshot();
223
+ for (const listener of this.listeners) listener();
622
224
  }
623
225
 
624
- private setLocked(error: ArtifactError): void {
625
- const expiresAt =
626
- typeof error.details.expiresAt === 'string'
627
- ? error.details.expiresAt
628
- : new Date(Date.now() + 60_000).toISOString();
629
- this.lockedUntil = expiresAt;
630
- if (this.lockTimer !== undefined) clearTimeout(this.lockTimer);
631
- const delay = Math.max(0, Date.parse(expiresAt) - Date.now());
632
- this.lockTimer = setTimeout(() => {
633
- this.lockTimer = undefined;
634
- this.lockedUntil = undefined;
635
- this.notify();
636
- }, delay);
226
+ private buildSnapshot(): DraftSnapshot {
227
+ const dirty =
228
+ this.current.operations.length > 0 ||
229
+ JSON.stringify(this.current.artifact.content) !== JSON.stringify(this.base.content) ||
230
+ this.current.artifact.title !== this.base.title;
231
+ return {
232
+ baseVersion: this.base.version,
233
+ artifact: this.current.artifact,
234
+ operations: this.current.operations,
235
+ dirty,
236
+ canUndo: this.undoStack.length > 0,
237
+ canRedo: this.redoStack.length > 0,
238
+ pending: this.pending > 0,
239
+ ...(this.error ? { error: this.error } : {}),
240
+ lint: this.lint,
241
+ changes: this.changes,
242
+ };
637
243
  }
244
+ }
638
245
 
639
- // -- remote versions ---------------------------------------------------------------------------------
640
-
641
- /**
642
- * A version arrived from outside the session (refetch, agent commit, prop). Pending local operations
643
- * are rebased on it; when they no longer apply, a `rebase` conflict asks the user what to keep.
644
- */
645
- receive = (artifact: Artifact): void => {
646
- if (this.disposed || artifact.id !== this.confirmed.id) return;
647
- if (artifact.version < this.confirmed.version) return;
648
- if (artifact.version === this.confirmed.version) {
649
- if (
650
- artifact.status !== this.confirmed.status ||
651
- artifact.meta.publishedVersion !== this.confirmed.meta.publishedVersion
652
- ) {
653
- this.confirmed = { ...this.confirmed, status: artifact.status, meta: artifact.meta };
654
- this.notify();
655
- }
656
- return;
657
- }
658
- if (this.inflight) {
659
- // Wait for the in-flight batch: applying it on top of a version that may already contain it
660
- // would show its operations twice.
661
- if (!this.deferredRemote || artifact.version > this.deferredRemote.version)
662
- this.deferredRemote = artifact;
663
- return;
664
- }
665
- this.incorporate(artifact);
666
- this.notify();
667
- };
668
-
669
- private incorporate(artifact: Artifact): void {
670
- const previous = this.confirmed;
671
- this.confirmed = artifact;
672
- if (this.conflict) {
673
- this.conflict.latest = artifact;
674
- this.view = envelopeOf(artifact);
675
- } else {
676
- this.rebaseView(previous);
677
- if (!this.conflict) this.announce({ type: 'rebased', version: artifact.version });
678
- }
246
+ function errorOf(error: unknown): NonNullable<DraftSnapshot['error']> {
247
+ if (isArtifactError(error)) {
248
+ const payload = error.toPayload();
249
+ return {
250
+ code: payload.error.code,
251
+ message: payload.error.message,
252
+ issues: payload.error.issues ?? [],
253
+ };
679
254
  }
680
-
681
- resolveConflict = (choice: ConflictChoice): void => {
682
- const conflict = this.conflict;
683
- if (!conflict) return;
684
- this.conflict = undefined;
685
- this.error = undefined;
686
- this.view = envelopeOf(this.confirmed);
687
- if (choice === 'reapply') {
688
- if (conflict.origin === 'undo' || conflict.origin === 'redo') {
689
- const entry = conflict.entry;
690
- const target = entry ? (conflict.origin === 'undo' ? entry.before : entry.after) : undefined;
691
- const ops = target ? operationsBetween(envelopeOf(this.confirmed), target) : [];
692
- if (ops.length > 0) {
693
- this.queue.unshift({
694
- type: 'ops',
695
- ops,
696
- key: this.key(),
697
- origin: conflict.origin,
698
- ...(entry ? { entry } : {}),
699
- });
700
- }
701
- } else {
702
- try {
703
- this.applyLocal(envelopeOf(this.confirmed), conflict.operations);
704
- this.queue.unshift({ type: 'ops', ops: conflict.operations, key: this.key(), origin: 'reapply' });
705
- } catch (error) {
706
- this.error = toArtifactError(error);
707
- this.announce({ type: 'rolled_back', error: this.error });
708
- this.pump();
709
- this.notify();
710
- return;
711
- }
712
- }
713
- this.rebaseView(this.confirmed);
714
- }
715
- this.announce({ type: 'conflict_resolved', choice });
716
- this.pump();
717
- this.notify();
255
+ return {
256
+ code: 'INTERNAL_ERROR',
257
+ message: error instanceof Error ? error.message : String(error),
258
+ issues: [],
718
259
  };
719
-
720
- /**
721
- * Stops timers and listeners. Buffered typing is still sent unless `discard` is set, so closing the
722
- * editor right after typing does not lose the last keystrokes.
723
- */
724
- dispose(options: { discard?: boolean } = {}): void {
725
- if (this.disposed) return;
726
- if (options.discard) {
727
- this.buffer = [];
728
- this.queue = this.inflight ? this.queue.slice(0, 1) : [];
729
- } else {
730
- this.flushBuffer();
731
- this.pump();
732
- }
733
- this.clearTimer();
734
- if (this.lockTimer !== undefined) clearTimeout(this.lockTimer);
735
- this.disposed = true;
736
- this.listeners.clear();
737
- }
738
260
  }