@startsimpli/ui 0.4.141 → 0.4.143

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 (39) hide show
  1. package/package.json +38 -12
  2. package/src/components/activity/ActivityFeedPanel.tsx +96 -29
  3. package/src/components/activity/__tests__/activity-feed-cursor.test.tsx +107 -0
  4. package/src/components/activity/types.ts +10 -5
  5. package/src/components/collection/DraftReviewWorkspace.tsx +2 -2
  6. package/src/components/collection/InlineReviewActions.tsx +3 -2
  7. package/src/components/collection/ReviewDrawer.tsx +35 -7
  8. package/src/components/collection/TopicReviewWorkspace.tsx +2 -2
  9. package/src/components/collection/__tests__/drawer-precondition.test.tsx +175 -0
  10. package/src/components/collection/data.ts +16 -1
  11. package/src/components/collection/index.ts +2 -1
  12. package/src/components/collection/scoped-client.ts +4 -1
  13. package/src/components/collection/types.ts +19 -1
  14. package/src/components/history/FieldAttribution.tsx +308 -0
  15. package/src/components/history/RecordActivityPanel.tsx +344 -0
  16. package/src/components/history/RecordHistoryPanel.tsx +331 -15
  17. package/src/components/history/RestoreConfirmDialog.tsx +244 -0
  18. package/src/components/history/RevisionFields.tsx +69 -3
  19. package/src/components/history/StaleSaveDialog.tsx +28 -7
  20. package/src/components/history/__tests__/field-attribution.test.tsx +213 -0
  21. package/src/components/history/__tests__/record-activity-panel.test.tsx +179 -0
  22. package/src/components/history/__tests__/record-history-restore.test.tsx +373 -0
  23. package/src/components/history/__tests__/restore.test.ts +209 -0
  24. package/src/components/history/index.ts +72 -1
  25. package/src/components/history/restore.ts +295 -0
  26. package/src/components/history/revision-feed.ts +105 -0
  27. package/src/components/history/revision-history.ts +22 -0
  28. package/src/components/history/types.ts +153 -1
  29. package/src/components/track-changes/TrackedTextEditor.tsx +595 -0
  30. package/src/components/track-changes/__tests__/extension.test.ts +350 -0
  31. package/src/components/track-changes/__tests__/model.test.ts +461 -0
  32. package/src/components/track-changes/__tests__/tracked-text-editor.test.tsx +476 -0
  33. package/src/components/track-changes/describe.ts +76 -0
  34. package/src/components/track-changes/extension.ts +724 -0
  35. package/src/components/track-changes/index.ts +74 -0
  36. package/src/components/track-changes/normalize.ts +364 -0
  37. package/src/components/track-changes/types.ts +373 -0
  38. package/src/components/track-changes/useTrackChanges.ts +214 -0
  39. package/src/components/track-changes/word-diff.ts +97 -0
@@ -0,0 +1,350 @@
1
+ /**
2
+ * The CodeMirror state layer, tested headless on EditorState (bd startsim-sjuai).
3
+ *
4
+ * What must hold: the editor never rewrites the stored string (CRLF, tabs,
5
+ * emoji, a trailing newline survive byte for byte); decorations sit on the
6
+ * right characters after local edits and after the stored value moves under
7
+ * the editor; text typed locally is never credited to the span it was typed
8
+ * into; and in suggesting mode the document is the PROPOSED copy, with the
9
+ * base untouched and every local change visible as an insert or a delete.
10
+ */
11
+ import { EditorSelection, EditorState } from '@codemirror/state'
12
+
13
+ import {
14
+ activeMarkOf,
15
+ enterSuggesting,
16
+ leaveSuggesting,
17
+ marksOf,
18
+ moveToMark,
19
+ proposedText,
20
+ rebaseOnto,
21
+ setAnnotations,
22
+ trackChanges,
23
+ trackingOf,
24
+ } from '../extension'
25
+ import type { BlameSpan, CommentThread, Suggestion, TrackActor } from '../types'
26
+
27
+ const ada: TrackActor = { sub: 'ada', label: 'ada@x.io', kind: 'person', kindSource: 'claim' }
28
+ const bo: TrackActor = { sub: 'bo', label: 'bo@x.io', kind: 'person', kindSource: 'claim' }
29
+ const bot: TrackActor = { sub: 'svc', label: 'svc:n8n', kind: 'machine', kindSource: 'claim' }
30
+
31
+ function span(from: number, to: number, author: TrackActor | null, reason?: string): BlameSpan {
32
+ return {
33
+ id: `blame:${from}`,
34
+ from,
35
+ to,
36
+ author,
37
+ unknownReason: author ? null : (reason ?? 'before history began'),
38
+ reason: author ? null : 'before_history',
39
+ version: 1,
40
+ at: null,
41
+ via: author ? 'edit' : 'unknown',
42
+ acceptedBy: null,
43
+ restoredFrom: null,
44
+ }
45
+ }
46
+
47
+ function suggestion(id: string, text: string, quote: string, replacement: string, author: TrackActor, at?: number): Suggestion {
48
+ const from = at ?? text.indexOf(quote)
49
+ return {
50
+ id,
51
+ setId: 'set',
52
+ from,
53
+ to: from + quote.length,
54
+ quote,
55
+ replacement,
56
+ status: 'open',
57
+ anchorState: 'mapped',
58
+ author,
59
+ createdAt: null,
60
+ }
61
+ }
62
+
63
+ function make(doc: string) {
64
+ return EditorState.create({ doc, extensions: [trackChanges()] })
65
+ }
66
+
67
+ const type = (s: EditorState, from: number, to: number, insert: string) =>
68
+ s.update({ changes: { from, to, insert }, userEvent: 'input.type' }).state
69
+
70
+ const text = (s: EditorState, m: { from: number; to: number }) => s.sliceDoc(m.from, m.to)
71
+
72
+ describe('the stored string is never rewritten', () => {
73
+ it.each([
74
+ ['CRLF', 'line one\r\nline two\r\n'],
75
+ ['lone CR', 'a\rb'],
76
+ ['tabs and trailing spaces', '\tindented \n two\t\n'],
77
+ ['emoji and CJK', '🚀 launch 中文博客 ✅\n'],
78
+ ['nbsp and zero-width', 'a b​c'],
79
+ ['no trailing newline', '# Title\n\nbody'],
80
+ ])('%s survives load and an edit that is undone by hand', (_, value) => {
81
+ let s = make(value)
82
+ expect(s.doc.toString()).toBe(value)
83
+ s = type(s, s.doc.length, s.doc.length, 'X')
84
+ s = type(s, s.doc.length - 1, s.doc.length, '')
85
+ expect(s.doc.toString()).toBe(value)
86
+ })
87
+ })
88
+
89
+ describe('decorations follow edits (editing mode)', () => {
90
+ const value = 'Ada wrote this. Bo wrote that.'
91
+ const annotations = {
92
+ blame: [span(0, 15, ada), span(15, 30, bo)],
93
+ suggestions: [suggestion('s1', value, 'that', 'this', bo)],
94
+ threads: [] as CommentThread[],
95
+ }
96
+
97
+ function loaded() {
98
+ return make(value).update({ effects: setAnnotations.of({ ...annotations, currentActorSub: 'ada' }) }).state
99
+ }
100
+
101
+ it('places blame and suggestion marks on their characters', () => {
102
+ const s = loaded()
103
+ const marks = marksOf(s)
104
+ const blame = marks.filter((m) => m.kind === 'blame')
105
+ expect(blame.map((m) => text(s, m))).toEqual(['Ada wrote this.', ' Bo wrote that.'])
106
+ const sug = marks.find((m) => m.kind === 'suggestion')!
107
+ expect(text(s, sug)).toBe('that')
108
+ })
109
+
110
+ it('shifts every mark when text is inserted before them', () => {
111
+ const s = type(loaded(), 0, 0, 'Note: ')
112
+ const marks = marksOf(s)
113
+ expect(marks.filter((m) => m.kind === 'blame').map((m) => text(s, m))).toEqual(['Ada wrote this.', ' Bo wrote that.'])
114
+ expect(text(s, marks.find((m) => m.kind === 'suggestion')!)).toBe('that')
115
+ })
116
+
117
+ it('does not credit text typed inside a span to that span’s author', () => {
118
+ const at = value.indexOf('this')
119
+ const s = type(loaded(), at, at, 'all of ')
120
+ const blame = marksOf(s).filter((m) => m.kind === 'blame')
121
+ expect(blame.map((m) => text(s, m))).toEqual(['Ada wrote ', 'this.', ' Bo wrote that.'])
122
+ expect(blame.every((m) => !text(s, m).includes('all of'))).toBe(true)
123
+ })
124
+
125
+ it('shrinks a span when part of it is deleted and drops a suggestion whose text is gone', () => {
126
+ const at = value.indexOf(' that')
127
+ const s = type(loaded(), at, at + 5, '')
128
+ const marks = marksOf(s)
129
+ expect(marks.filter((m) => m.kind === 'blame').map((m) => text(s, m))).toEqual(['Ada wrote this.', ' Bo wrote.'])
130
+ expect(marks.find((m) => m.kind === 'suggestion')).toBeUndefined()
131
+ })
132
+
133
+ it('moves marks when the stored value changes under the editor, keeping a local unsaved edit', () => {
134
+ let s = loaded()
135
+ s = type(s, s.doc.length, s.doc.length, ' Mine.') // unsaved local edit
136
+ const next = 'Intro. Ada wrote this. Bo wrote that.'
137
+ s = s.update(rebaseOnto(s, next)!).state
138
+ expect(s.doc.toString()).toBe('Intro. Ada wrote this. Bo wrote that. Mine.')
139
+ expect(trackingOf(s).base.toString()).toBe(next)
140
+ // New annotations arrive in the NEW value's coordinates.
141
+ s = s.update({
142
+ effects: setAnnotations.of({
143
+ blame: [span(0, 7, bot), span(7, 22, ada), span(22, 37, bo)],
144
+ suggestions: [],
145
+ threads: [],
146
+ currentActorSub: 'ada',
147
+ }),
148
+ }).state
149
+ expect(marksOf(s).filter((m) => m.kind === 'blame').map((m) => text(s, m))).toEqual([
150
+ 'Intro. ',
151
+ 'Ada wrote this.',
152
+ ' Bo wrote that.',
153
+ ])
154
+ })
155
+
156
+ it('treats the host echoing the editor’s own text as a save, not an external change', () => {
157
+ let s = loaded()
158
+ s = type(s, s.doc.length, s.doc.length, ' Mine.')
159
+ const saved = s.doc.toString()
160
+ s = s.update(rebaseOnto(s, saved) ?? {}).state
161
+ expect(s.doc.toString()).toBe(saved)
162
+ expect(trackingOf(s).changes.empty).toBe(true)
163
+ })
164
+
165
+ it('marks an unknown span with its reason, and a machine span as a machine', () => {
166
+ const s = make('old text new text').update({
167
+ effects: setAnnotations.of({
168
+ blame: [span(0, 9, null, 'before history began 2026-10-06'), span(9, 17, bot)],
169
+ suggestions: [],
170
+ threads: [],
171
+ currentActorSub: null,
172
+ }),
173
+ }).state
174
+ const [unknown, machine] = marksOf(s)
175
+ expect(unknown.description).toBe('Author unknown: before history began 2026-10-06')
176
+ expect(unknown.actorKind).toBe('unknown')
177
+ expect(machine.description).toMatch(/^Written by svc:n8n \(machine\)/)
178
+ expect(machine.actorKind).toBe('machine')
179
+ })
180
+
181
+ it('hides a kind of mark when asked, so whwxd.3 can build show/hide over it', () => {
182
+ const s = loaded().update({
183
+ effects: setAnnotations.of({ ...annotations, currentActorSub: 'ada', show: { blame: false } }),
184
+ }).state
185
+ expect(marksOf(s).some((m) => m.kind === 'blame')).toBe(false)
186
+ expect(marksOf(s).some((m) => m.kind === 'suggestion')).toBe(true)
187
+ })
188
+ })
189
+
190
+ describe('suggesting mode', () => {
191
+ const value = 'The quick brown fox jumps.'
192
+
193
+ function suggesting(own: Suggestion[] = [], others: Suggestion[] = []) {
194
+ let s = make(value).update({
195
+ effects: setAnnotations.of({ blame: [], suggestions: [...own, ...others], threads: [], currentActorSub: 'ada' }),
196
+ }).state
197
+ for (const spec of enterSuggesting(s)) s = s.update(spec).state
198
+ return s
199
+ }
200
+
201
+ it('keeps the base untouched and shows a typed insert as an insert', () => {
202
+ let s = suggesting()
203
+ const at = value.indexOf('fox')
204
+ s = type(s, at, at, 'red ')
205
+ expect(trackingOf(s).base.toString()).toBe(value)
206
+ expect(proposedText(s)).toBe('The quick brown red fox jumps.')
207
+ const local = marksOf(s).filter((m) => m.kind === 'local')
208
+ expect(local).toHaveLength(1)
209
+ expect(text(s, local[0])).toBe('red ')
210
+ expect(local[0].description).toBe('Your suggestion: insert “red”')
211
+ })
212
+
213
+ it('shows a deletion as struck base text that is no longer in the document', () => {
214
+ let s = suggesting()
215
+ const at = value.indexOf('quick ')
216
+ s = type(s, at, at + 6, '')
217
+ const [del] = marksOf(s).filter((m) => m.kind === 'local')
218
+ expect(del.deleted).toBe('quick ')
219
+ expect(del.from).toBe(del.to)
220
+ expect(del.description).toBe('Your suggestion: delete “quick”')
221
+ expect(proposedText(s)).toBe('The brown fox jumps.')
222
+ })
223
+
224
+ it('starts from the author’s own open suggestions, so a re-post does not drop them', () => {
225
+ const mine = suggestion('m1', value, 'quick', 'slow', ada)
226
+ const s = suggesting([mine])
227
+ expect(proposedText(s)).toBe('The slow brown fox jumps.')
228
+ // Shown as the author's local change, not twice.
229
+ expect(marksOf(s).filter((m) => m.kind === 'suggestion')).toHaveLength(0)
230
+ expect(marksOf(s).filter((m) => m.kind === 'local')).toHaveLength(1)
231
+ })
232
+
233
+ it('maps another reviewer’s suggestion through the local proposal', () => {
234
+ const theirs = suggestion('t1', value, 'jumps', 'leaps', bo)
235
+ let s = suggesting([], [theirs])
236
+ s = type(s, 0, 0, 'Look: ')
237
+ const m = marksOf(s).find((x) => x.kind === 'suggestion')!
238
+ expect(text(s, m)).toBe('jumps')
239
+ expect(m.description).toBe('Suggestion by bo@x.io: replace “jumps” with “leaps”')
240
+ })
241
+
242
+ it('keeps the proposal when the base moves underneath it', () => {
243
+ let s = suggesting()
244
+ const at = value.indexOf('fox')
245
+ s = type(s, at, at + 3, 'cat')
246
+ s = s.update(rebaseOnto(s, 'Today: The quick brown fox jumps.')!).state
247
+ expect(trackingOf(s).base.toString()).toBe('Today: The quick brown fox jumps.')
248
+ expect(proposedText(s)).toBe('Today: The quick brown cat jumps.')
249
+ })
250
+
251
+ it('returns to the base text, untouched, when leaving', () => {
252
+ let s = suggesting()
253
+ s = type(s, 0, 3, 'A')
254
+ s = s.update(leaveSuggesting(s)).state
255
+ expect(s.doc.toString()).toBe(value)
256
+ expect(trackingOf(s).changes.empty).toBe(true)
257
+ })
258
+ })
259
+
260
+ describe('keyboard navigation between marks', () => {
261
+ const value = 'One two three four.'
262
+
263
+ it('moves to the next and previous change and reports it as active', () => {
264
+ let s = make(value).update({
265
+ effects: setAnnotations.of({
266
+ blame: [],
267
+ suggestions: [suggestion('s1', value, 'two', '2', bo), suggestion('s2', value, 'four', '4', bo)],
268
+ threads: [
269
+ {
270
+ id: 't1',
271
+ from: value.indexOf('three'),
272
+ to: value.indexOf('three') + 5,
273
+ quote: 'three',
274
+ anchorState: 'mapped',
275
+ resolved: false,
276
+ redacted: false,
277
+ comments: [{ id: 'c', body: 'really?', deleted: false, author: ada, createdAt: null }],
278
+ },
279
+ ],
280
+ currentActorSub: null,
281
+ }),
282
+ }).state
283
+ const order: string[] = []
284
+ for (let i = 0; i < 3; i++) {
285
+ s = s.update(moveToMark(s, 1)!).state
286
+ order.push(activeMarkOf(s)!.id)
287
+ }
288
+ expect(order).toEqual(['s1', 't1', 's2'])
289
+ expect(text(s, s.selection.main)).toBe('four')
290
+ s = s.update(moveToMark(s, -1)!).state
291
+ expect(activeMarkOf(s)!.description).toBe('Comment by ada@x.io: “really?”')
292
+ // Wraps.
293
+ s = s.update(moveToMark(s, 1)!).state
294
+ s = s.update(moveToMark(s, 1)!).state
295
+ expect(activeMarkOf(s)!.id).toBe('s1')
296
+ })
297
+
298
+ it('reports the mark under the cursor as active when the cursor is moved by hand', () => {
299
+ let s = make(value).update({
300
+ effects: setAnnotations.of({
301
+ blame: [span(0, 19, ada)],
302
+ suggestions: [suggestion('s1', value, 'two', '2', bo)],
303
+ threads: [],
304
+ currentActorSub: null,
305
+ }),
306
+ }).state
307
+ s = s.update({ selection: EditorSelection.cursor(value.indexOf('two') + 1) }).state
308
+ // The suggestion wins over the blame span it sits inside.
309
+ expect(activeMarkOf(s)!.id).toBe('s1')
310
+ s = s.update({ selection: EditorSelection.cursor(1) }).state
311
+ expect(activeMarkOf(s)!.kind).toBe('blame')
312
+ })
313
+
314
+ it('returns null when there is nothing to move to', () => {
315
+ expect(moveToMark(make('plain'), 1)).toBeNull()
316
+ })
317
+ })
318
+
319
+ describe('the invariant: CHANGES applied to BASE is always the document', () => {
320
+ it('holds across random local edits and external rebases, in both modes', () => {
321
+ let seed = 11
322
+ const rnd = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff)
323
+ const pieces = ['word ', 'other ', '🚀', '中文', '\r\n', '\n', 'x', ', ']
324
+ const gen = () => Array.from({ length: 1 + Math.floor(rnd() * 4) }, () => pieces[Math.floor(rnd() * pieces.length)]).join('')
325
+ const randomEdit = (doc: string) => {
326
+ const a = Math.floor(rnd() * (doc.length + 1))
327
+ const b = Math.min(doc.length, a + Math.floor(rnd() * 6))
328
+ return { from: a, to: b, insert: rnd() < 0.3 ? '' : gen() }
329
+ }
330
+ for (let round = 0; round < 60; round++) {
331
+ let s = make('start ' + gen() + ' end')
332
+ if (round % 2) for (const spec of enterSuggesting(s)) s = s.update(spec).state
333
+ for (let step = 0; step < 15; step++) {
334
+ if (rnd() < 0.7) {
335
+ s = s.update({ changes: randomEdit(s.doc.toString()), userEvent: 'input.type' }).state
336
+ } else {
337
+ const base = trackingOf(s).base.toString()
338
+ const e = randomEdit(base)
339
+ const next = base.slice(0, e.from) + e.insert + base.slice(e.to)
340
+ const spec = rebaseOnto(s, next)
341
+ if (spec) s = s.update(spec).state
342
+ expect(trackingOf(s).base.toString()).toBe(next)
343
+ }
344
+ const { base, changes } = trackingOf(s)
345
+ expect(changes.apply(base).toString()).toBe(s.doc.toString())
346
+ marksOf(s) // never throws
347
+ }
348
+ }
349
+ })
350
+ })