@plannotator/ui 0.33.0 → 0.35.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.
@@ -0,0 +1,167 @@
1
+ /** Direction in which a recorded history action is replayed. */
2
+ export type HistoryDirection = 'undo' | 'redo';
3
+
4
+ /** Immutable state for one bounded undo/redo context. */
5
+ export interface UndoHistoryState<TAction> {
6
+ readonly past: readonly TAction[];
7
+ readonly future: readonly TAction[];
8
+ }
9
+
10
+ /** Result of taking one action from a history stack. */
11
+ export interface UndoHistoryStep<TAction> {
12
+ readonly state: UndoHistoryState<TAction>;
13
+ readonly action: TAction | null;
14
+ }
15
+
16
+ /** Create an empty undo/redo state. */
17
+ export function createUndoHistoryState<TAction>(): UndoHistoryState<TAction> {
18
+ return { past: [], future: [] };
19
+ }
20
+
21
+ /** Record an already-applied action and invalidate the redo branch. */
22
+ export function recordUndoAction<TAction>(
23
+ state: UndoHistoryState<TAction>,
24
+ action: TAction,
25
+ capacity: number,
26
+ ): UndoHistoryState<TAction> {
27
+ const boundedCapacity = Math.max(1, Math.floor(capacity));
28
+ return {
29
+ past: [...state.past, action].slice(-boundedCapacity),
30
+ future: [],
31
+ };
32
+ }
33
+
34
+ /** Take the newest undo action, if one exists. */
35
+ export function takeUndoAction<TAction>(state: UndoHistoryState<TAction>): UndoHistoryStep<TAction> {
36
+ const action = state.past.at(-1) ?? null;
37
+ if (action === null) return { state, action: null };
38
+ return {
39
+ action,
40
+ state: {
41
+ past: state.past.slice(0, -1),
42
+ future: [...state.future, action],
43
+ },
44
+ };
45
+ }
46
+
47
+ /** Take the newest redo action, if one exists. */
48
+ export function takeRedoAction<TAction>(
49
+ state: UndoHistoryState<TAction>,
50
+ capacity: number,
51
+ ): UndoHistoryStep<TAction> {
52
+ const action = state.future.at(-1) ?? null;
53
+ if (action === null) return { state, action: null };
54
+ const boundedCapacity = Math.max(1, Math.floor(capacity));
55
+ return {
56
+ action,
57
+ state: {
58
+ past: [...state.past, action].slice(-boundedCapacity),
59
+ future: state.future.slice(0, -1),
60
+ },
61
+ };
62
+ }
63
+
64
+ /** One reversible mutation to an ID-addressed collection. */
65
+ export type CollectionMutation<TItem> =
66
+ | { readonly kind: 'add'; readonly item: TItem; readonly index: number }
67
+ | { readonly kind: 'edit'; readonly before: TItem; readonly after: TItem }
68
+ | { readonly kind: 'delete'; readonly item: TItem; readonly index: number };
69
+
70
+ function insertAt<TItem>(items: readonly TItem[], item: TItem, index: number): TItem[] {
71
+ const insertionIndex = Math.max(0, Math.min(index, items.length));
72
+ return [...items.slice(0, insertionIndex), item, ...items.slice(insertionIndex)];
73
+ }
74
+
75
+ /** Apply or invert one collection mutation while preserving the recorded item index. */
76
+ export function applyCollectionMutation<TItem>(
77
+ items: readonly TItem[],
78
+ mutation: CollectionMutation<TItem>,
79
+ direction: HistoryDirection,
80
+ getId: (item: TItem) => string,
81
+ ): TItem[] {
82
+ switch (mutation.kind) {
83
+ case 'add':
84
+ return direction === 'undo'
85
+ ? items.filter((item) => getId(item) !== getId(mutation.item))
86
+ : insertAt(items, mutation.item, mutation.index);
87
+ case 'delete':
88
+ return direction === 'undo'
89
+ ? insertAt(items, mutation.item, mutation.index)
90
+ : items.filter((item) => getId(item) !== getId(mutation.item));
91
+ case 'edit': {
92
+ const replacement = direction === 'undo' ? mutation.before : mutation.after;
93
+ return items.map((item) => getId(item) === getId(replacement) ? replacement : item);
94
+ }
95
+ }
96
+ }
97
+
98
+ /** Apply a compound collection action in the order required for exact inversion. */
99
+ export function applyCollectionMutations<TItem>(
100
+ items: readonly TItem[],
101
+ mutations: readonly CollectionMutation<TItem>[],
102
+ direction: HistoryDirection,
103
+ getId: (item: TItem) => string,
104
+ ): TItem[] {
105
+ const ordered = direction === 'undo' ? [...mutations].reverse() : mutations;
106
+ return ordered.reduce<TItem[]>(
107
+ (current, mutation) => applyCollectionMutation(current, mutation, direction, getId),
108
+ [...items],
109
+ );
110
+ }
111
+
112
+ const NATIVE_HISTORY_SELECTOR = [
113
+ 'input',
114
+ 'textarea',
115
+ '[contenteditable]:not([contenteditable="false"])',
116
+ '.cm-editor',
117
+ '[role="textbox"]',
118
+ ].join(',');
119
+
120
+ const ACTIVE_HISTORY_OVERLAY_SELECTOR = [
121
+ '[role="dialog"]',
122
+ '[data-popover-layer]',
123
+ '[data-comment-popover="true"]',
124
+ '[data-history-owner]',
125
+ '.cm-editor.cm-focused',
126
+ ].join(',');
127
+
128
+ /**
129
+ * Return whether the event belongs to native text history or another active
130
+ * tool. `composedPath()` is required for editors mounted inside shadow DOM.
131
+ */
132
+ export function isNativeHistoryOwner(event: KeyboardEvent): boolean {
133
+ const first = event.composedPath()[0];
134
+ if (!(first instanceof Element)) return false;
135
+ return first.matches(NATIVE_HISTORY_SELECTOR)
136
+ || first.closest(NATIVE_HISTORY_SELECTOR) !== null;
137
+ }
138
+
139
+ /** Return whether a dialog, composer, or source editor currently owns history. */
140
+ export function hasActiveHistoryOverlay(root: ParentNode): boolean {
141
+ return root.querySelector(ACTIVE_HISTORY_OVERLAY_SELECTOR) !== null;
142
+ }
143
+
144
+ /** External or agent-authored annotations never enter human undo history. */
145
+ export function isHumanHistoryMutation(item: { readonly source?: string }): boolean {
146
+ return !item.source;
147
+ }
148
+
149
+ /** Minimal imperative highlight surface used by annotation-history replay. */
150
+ export interface HistoryHighlightTarget<TItem extends { readonly id: string }> {
151
+ removeHighlight: (id: string) => void;
152
+ applySharedAnnotations: (items: TItem[]) => void;
153
+ }
154
+
155
+ /**
156
+ * Synchronize one replayed annotation without tombstoning retained highlights.
157
+ * Removal is reserved for actions whose result no longer contains the item.
158
+ */
159
+ export function syncHistoryHighlight<TItem extends { readonly id: string }>(
160
+ target: HistoryHighlightTarget<TItem> | null,
161
+ item: TItem,
162
+ visible: boolean,
163
+ ): void {
164
+ if (!target) return;
165
+ if (visible) target.applySharedAnnotations([item]);
166
+ else target.removeHighlight(item.id);
167
+ }
package/webmcp/changes.ts CHANGED
@@ -57,6 +57,15 @@ export function hashAnnotation(annotation: TrackedAnnotation): string {
57
57
 
58
58
  const CURSOR_PREFIX = 'w:';
59
59
 
60
+ /**
61
+ * Tombstones kept per tracker. A removal past this many is forgotten oldest
62
+ * first (FIFO by seq), together with the agent-ownership records of the
63
+ * forgotten id, so a create/delete loop cannot grow the tracker for the
64
+ * life of the tab. A tombstone older than 2,000 later removals is one no
65
+ * `since` in practice still asks about.
66
+ */
67
+ export const MAX_TOMBSTONES = 2000;
68
+
60
69
  export class AnnotationChangeTracker {
61
70
  private readonly entries = new Map<string, ChangeEntry>();
62
71
  private readonly tombstones = new Map<string, Tombstone>();
@@ -151,12 +160,27 @@ export class AnnotationChangeTracker {
151
160
  this.tombstones.set(id, tombstone);
152
161
  delta.removed.push(tombstone);
153
162
  }
163
+ this.pruneTombstones();
154
164
  // A re-added id is a fresh record: forget any agent-removal claim on it.
155
165
  for (const id of seen) this.agentRemoved.delete(id);
156
166
  if (touched) this.lastActivity = this.now();
157
167
  return delta;
158
168
  }
159
169
 
170
+ /** Bounded eviction (MAX_TOMBSTONES): the oldest tombstones go, and with
171
+ * them the ownership and agent-removal records that only mattered while
172
+ * the id could still be asked about. Live entries keep theirs. */
173
+ private pruneTombstones(): void {
174
+ while (this.tombstones.size > MAX_TOMBSTONES) {
175
+ const oldest = this.tombstones.keys().next().value;
176
+ if (oldest === undefined) break;
177
+ this.tombstones.delete(oldest);
178
+ this.ownHashes.delete(oldest);
179
+ this.ownSeqs.delete(oldest);
180
+ this.agentRemoved.delete(oldest);
181
+ }
182
+ }
183
+
160
184
  seqOf(id: string): number | undefined {
161
185
  return this.entries.get(id)?.seq;
162
186
  }
package/webmcp/nudges.ts CHANGED
@@ -50,11 +50,23 @@ export interface NudgeSnapshot {
50
50
  }
51
51
 
52
52
  export const MAX_OTHER_DOCUMENT_NUDGES = 10;
53
+ /** Ids listed on one nudge; a burst past this names the first ones and says how many more. */
54
+ export const MAX_NUDGE_IDS = 100;
53
55
 
54
56
  function plural(count: number, noun: string): string {
55
57
  return `${count} ${noun}${count === 1 ? '' : 's'}`;
56
58
  }
57
59
 
60
+ /** The first MAX_NUDGE_IDS ids plus a suffix for the message when some are left out. */
61
+ function capIds(ids: string[]): { ids: string[]; suffix: string } {
62
+ if (ids.length <= MAX_NUDGE_IDS) return { ids, suffix: '' };
63
+ const more = ids.length - MAX_NUDGE_IDS;
64
+ return {
65
+ ids: ids.slice(0, MAX_NUDGE_IDS),
66
+ suffix: ` The first ${MAX_NUDGE_IDS} ids are listed; ${more} more ${more === 1 ? 'is' : 'are'} not (read the document for the rest).`,
67
+ };
68
+ }
69
+
58
70
  export function buildNudges(
59
71
  snapshot: NudgeSnapshot,
60
72
  tracker: AnnotationChangeTracker,
@@ -71,29 +83,32 @@ export function buildNudges(
71
83
  });
72
84
  const plain = fresh.filter((id) => !replies.includes(id));
73
85
  if (plain.length > 0) {
86
+ const capped = capIds(plain);
74
87
  nudges.push({
75
88
  code: 'annotations_new',
76
- message: `The human added or edited ${plural(plain.length, 'comment')} since your last read.`,
77
- ids: plain,
89
+ message: `The human added or edited ${plural(plain.length, 'comment')} since your last read.${capped.suffix}`,
90
+ ids: capped.ids,
78
91
  });
79
92
  }
80
93
  if (replies.length > 0) {
94
+ const capped = capIds(replies);
81
95
  nudges.push({
82
96
  code: 'replies_new',
83
- message: `The human replied to your comments (${replies.length} new ${replies.length === 1 ? 'reply' : 'replies'}).`,
84
- ids: replies,
97
+ message: `The human replied to your comments (${replies.length} new ${replies.length === 1 ? 'reply' : 'replies'}).${capped.suffix}`,
98
+ ids: capped.ids,
85
99
  });
86
100
  }
87
101
 
88
102
  const removed = tracker.removedSince(since);
89
103
  if (removed.length > 0) {
90
104
  const own = removed.filter((t) => t.agent);
105
+ const capped = capIds(removed.map((t) => t.id));
91
106
  nudges.push({
92
107
  code: 'annotations_removed',
93
- message: own.length > 0
108
+ message: (own.length > 0
94
109
  ? `The human removed ${own.length} of your comments; treat that as resolved and do not re-add them.`
95
- : `${plural(removed.length, 'comment')} you had seen ${removed.length === 1 ? 'was' : 'were'} removed.`,
96
- ids: removed.map((t) => t.id),
110
+ : `${plural(removed.length, 'comment')} you had seen ${removed.length === 1 ? 'was' : 'were'} removed.`) + capped.suffix,
111
+ ids: capped.ids,
97
112
  });
98
113
  }
99
114