@cueloop/client 0.1.0-alpha.77 → 0.1.0-alpha.78

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 (63) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +3 -3
  3. package/src/App.diff.test.tsx +14 -2
  4. package/src/App.export.test.tsx +1 -2
  5. package/src/App.plan-review.test.tsx +16 -0
  6. package/src/App.submit-confirm.test.tsx +34 -5
  7. package/src/App.test.tsx +2 -2
  8. package/src/App.tsx +4 -0
  9. package/src/App.walk.test.tsx +2 -2
  10. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  11. package/src/ansi-screen.test.ts +28 -0
  12. package/src/ansi-screen.ts +72 -0
  13. package/src/app-screens.tsx +7 -3
  14. package/src/app-view-model.ts +3 -0
  15. package/src/components/AnnotatableFileView.tsx +1 -1
  16. package/src/components/AnnotationCards.tsx +18 -2
  17. package/src/components/AnsiScreen.stories.tsx +12 -0
  18. package/src/components/AnsiScreen.tsx +28 -0
  19. package/src/components/CompletionOverlay.stories.tsx +15 -1
  20. package/src/components/CompletionOverlay.tsx +22 -5
  21. package/src/components/ConfirmCard.stories.tsx +2 -0
  22. package/src/components/ConfirmCard.tsx +45 -18
  23. package/src/components/ConfirmDialog.tsx +11 -12
  24. package/src/components/DiffContentView.tsx +4 -4
  25. package/src/components/FileContentsView.tsx +1 -1
  26. package/src/components/ManageAccessDialog.tsx +13 -11
  27. package/src/components/PromptDialog.test.tsx +2 -2
  28. package/src/components/PromptDialog.tsx +9 -10
  29. package/src/components/ShareChoiceDialog.tsx +0 -2
  30. package/src/components/SlashComposer.stories.tsx +37 -0
  31. package/src/components/SlashComposer.tsx +87 -0
  32. package/src/components/ThreadTree.tsx +11 -6
  33. package/src/components/ThreadView.selection.test.tsx +88 -1
  34. package/src/components/ThreadView.slash.test.tsx +0 -1
  35. package/src/components/ThreadView.tsx +66 -7
  36. package/src/components/Toast.tsx +0 -5
  37. package/src/components/WalkWizard.stories.tsx +2 -2
  38. package/src/components/WalkWizard.tsx +10 -12
  39. package/src/components/__snapshots__/stories.test.tsx.snap +219 -89
  40. package/src/components/primitives/Card.tsx +3 -0
  41. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  42. package/src/components/primitives/DialogActions.tsx +39 -0
  43. package/src/components/session-tree.ts +7 -6
  44. package/src/components/stories-app.tsx +13 -4
  45. package/src/components/stories.test.tsx +8 -1
  46. package/src/components/story-tree.test.ts +32 -24
  47. package/src/components/story.ts +32 -15
  48. package/src/diff-intraline.test.ts +75 -2
  49. package/src/diff-intraline.ts +95 -26
  50. package/src/keymap.test.ts +2 -2
  51. package/src/keymap.ts +0 -1
  52. package/src/pasted-image.test.ts +67 -0
  53. package/src/pasted-image.ts +60 -0
  54. package/src/run.ts +31 -19
  55. package/src/serve.ts +1 -1
  56. package/src/share.ts +1 -1
  57. package/src/slash-palette.test.ts +17 -0
  58. package/src/slash-palette.ts +11 -0
  59. package/src/thread-controller.derivation.test.ts +62 -0
  60. package/src/thread-controller.optimistic.test.ts +69 -0
  61. package/src/thread-controller.switch-cache.test.ts +146 -0
  62. package/src/thread-controller.ts +157 -24
  63. package/src/use-annotation-surface.tsx +43 -27
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The review-session controller: every daemon round-trip and mutation
2
+ * The thread controller: every daemon round-trip and mutation
3
3
  * primitive behind one React-free object. It owns connect/autostart/subscribe,
4
4
  * the session/inbox/status/error snapshot, optimistic apply, the mutation
5
5
  * primitives (cut/edit/annotate/submit/...), and the post-submit completion
@@ -132,6 +132,41 @@ function planCutId(base: { lineStart: number; lineEnd: number }): string {
132
132
  return `plan:${base.lineStart}-${base.lineEnd}`;
133
133
  }
134
134
 
135
+ /**
136
+ * Whether two records of the same thread yield the same document projection (display, rows, files,
137
+ * models). Those derive only from the artifact content and the working copy, so an update that
138
+ * touched only annotations, status, or the verdict leaves them identical - the projection can be
139
+ * reused rather than re-parsed. Content and working copy are strings, compared here by value.
140
+ */
141
+ export function sameDerivationInputs(previous: Thread, next: Thread): boolean {
142
+ return (
143
+ previous.id === next.id &&
144
+ previous.artifact.content === next.artifact.content &&
145
+ previous.workingCopy === next.workingCopy
146
+ );
147
+ }
148
+
149
+ /**
150
+ * The session with `annotation` upserted, for an optimistic paint the instant the composer closes
151
+ * rather than after the daemon round-trip - otherwise a saved comment blinks out until the write
152
+ * lands. An edit keeps the note in place and its timestamp; a new note appends. The daemon's record
153
+ * replaces this guess when the write returns.
154
+ */
155
+ export function withAnnotationUpserted(
156
+ session: Thread,
157
+ annotation: Omit<Annotation, "createdAt">,
158
+ ): Thread {
159
+ const index = session.annotations.findIndex((entry) => entry.id === annotation.id);
160
+ const createdAt = index >= 0 ? session.annotations[index]!.createdAt : new Date().toISOString();
161
+ const optimistic: Annotation = { ...annotation, createdAt };
162
+ const annotations =
163
+ index >= 0
164
+ ? session.annotations.map((entry) => (entry.id === annotation.id ? optimistic : entry))
165
+ : [...session.annotations, optimistic];
166
+
167
+ return { ...session, annotations };
168
+ }
169
+
135
170
  export interface ControllerSnapshot {
136
171
  session: Thread | null;
137
172
  inbox: Thread[] | null;
@@ -345,6 +380,9 @@ interface DerivedSessionProjection {
345
380
  display: DisplayBlock[];
346
381
  rows: DiffRow[];
347
382
  files: WalkFile[];
383
+ /** Raw per-file contents; parsed into a model lazily on first curation touch, not all upfront. */
384
+ fileContents: Map<string, DiffFileContents>;
385
+ /** Parsed file models, filled on demand from fileContents and memoized here. */
348
386
  models: Map<string, FileDiffMetadata>;
349
387
  tree: TreeRow[];
350
388
  }
@@ -355,6 +393,15 @@ interface LiveWorkingDiff {
355
393
  files: readonly DiffFileContents[];
356
394
  }
357
395
 
396
+ interface DerivedCacheEntry {
397
+ derivedFor: Thread;
398
+ derivedForLiveDiff: LiveWorkingDiff | null;
399
+ derived: DerivedSessionProjection;
400
+ }
401
+
402
+ /** Recently-viewed threads whose parsed projection is kept for an instant return; oldest evicted first. */
403
+ const DERIVED_CACHE_LIMIT = 8;
404
+
358
405
  class Controller implements ReviewController {
359
406
  readonly readOnly: boolean;
360
407
  private client: SessionClient | null = null;
@@ -389,9 +436,12 @@ class Controller implements ReviewController {
389
436
  display: [],
390
437
  rows: [],
391
438
  files: [],
439
+ fileContents: new Map(),
392
440
  models: new Map(),
393
441
  tree: [],
394
442
  };
443
+ /** Recently-viewed threads' parsed projections, so returning to a thread reuses its work instead of re-parsing. */
444
+ private derivedCache = new Map<string, DerivedCacheEntry>();
395
445
  /** File paths whose diff body is folded to just the file band. */
396
446
  private collapsedFiles = new Set<string>();
397
447
  /** File paths woven to their full contents (unchanged lines shown as context). */
@@ -498,6 +548,57 @@ class Controller implements ReviewController {
498
548
  }
499
549
 
500
550
  // ── derived projections ─────────────────────
551
+ /**
552
+ * The projection reuses when the same thread's derivation inputs are unchanged. display, rows,
553
+ * files, and models derive only from the artifact and working copy - never from annotations,
554
+ * status, or the verdict - so an update that touched only those produces an all-new session
555
+ * record but an identical projection.
556
+ */
557
+ private reusesDerived(session: Thread, liveDiff: LiveWorkingDiff | null): boolean {
558
+ return (
559
+ this.derivedFor !== null &&
560
+ this.derivedForLiveDiff === liveDiff &&
561
+ sameDerivationInputs(this.derivedFor, session)
562
+ );
563
+ }
564
+
565
+ private buildDerived(
566
+ session: Thread | null,
567
+ frozenDiff: boolean,
568
+ liveDiff: LiveWorkingDiff | null,
569
+ ): DerivedSessionProjection {
570
+ const content = frozenDiff ? session!.artifact.content : (liveDiff?.patch ?? "");
571
+ const files = frozenDiff ? (session?.artifact.files ?? []) : (liveDiff?.files ?? []);
572
+ const rows = content ? diffRows(content) : [];
573
+ const fileContents = new Map<string, DiffFileContents>();
574
+
575
+ for (const file of files) fileContents.set(file.path, file);
576
+
577
+ return {
578
+ display: session ? buildDisplay(session.artifact.content, session.workingCopy) : [],
579
+ rows,
580
+ files: walkFiles(rows),
581
+ fileContents,
582
+ models: new Map<string, FileDiffMetadata>(),
583
+ tree: session?.history ? treeRows(session.history) : [],
584
+ };
585
+ }
586
+
587
+ /** The parsed model for a changed file, computed on first touch and memoized in the projection. */
588
+ private fileModel(path: string): FileDiffMetadata | undefined {
589
+ const cached = this.derived.models.get(path);
590
+
591
+ if (cached) return cached;
592
+ const contents = this.derived.fileContents.get(path);
593
+
594
+ if (!contents) return undefined;
595
+ const model = parseFileDiff(contents);
596
+
597
+ this.derived.models.set(path, model);
598
+
599
+ return model;
600
+ }
601
+
501
602
  private ensureDerived(): void {
502
603
  const session = this.snapshot.session;
503
604
  const frozenDiff = readsFrozenDiff(session);
@@ -505,27 +606,60 @@ class Controller implements ReviewController {
505
606
  const liveDiff = frozenDiff ? null : this.liveDiff;
506
607
 
507
608
  if (this.derivedFor === session && this.derivedForLiveDiff === liveDiff) return;
508
- // fold state belongs to the session, so a live-tree refresh keeps it; only a new session resets it
509
- if (this.derivedFor !== session) {
609
+ // reuse the parsed projection across an update that left the inputs unchanged; only the cheap tree can differ
610
+ if (session !== null && this.reusesDerived(session, liveDiff)) {
611
+ this.derivedFor = session;
612
+ this.derived = { ...this.derived, tree: session.history ? treeRows(session.history) : [] };
613
+
614
+ return;
615
+ }
616
+ // a different thread resets fold state; a same-thread re-derivation (content edit) keeps it
617
+ if (this.derivedFor?.id !== session?.id) {
510
618
  this.collapsedFiles.clear();
511
619
  this.expandedFiles.clear();
512
620
  }
621
+ // returning to a recently-viewed thread whose inputs are unchanged reuses its parsed projection
622
+ const cached = session !== null ? this.derivedCache.get(session.id) : undefined;
623
+
624
+ if (
625
+ cached &&
626
+ cached.derivedForLiveDiff === liveDiff &&
627
+ sameDerivationInputs(cached.derivedFor, session!)
628
+ ) {
629
+ this.derivedFor = session;
630
+ this.derivedForLiveDiff = liveDiff;
631
+ this.derived = {
632
+ ...cached.derived,
633
+ tree: session!.history ? treeRows(session!.history) : [],
634
+ };
635
+ this.rememberDerived(session!, liveDiff, this.derived);
636
+
637
+ return;
638
+ }
513
639
  this.derivedFor = session;
514
640
  this.derivedForLiveDiff = liveDiff;
641
+ this.derived = this.buildDerived(session, frozenDiff, liveDiff);
642
+ if (session !== null) this.rememberDerived(session, liveDiff, this.derived);
643
+ }
515
644
 
516
- const content = frozenDiff ? session!.artifact.content : (liveDiff?.patch ?? "");
517
- const files = frozenDiff ? (session?.artifact.files ?? []) : (liveDiff?.files ?? []);
518
- const rows = content ? diffRows(content) : [];
519
- const models = new Map<string, FileDiffMetadata>();
645
+ /** Keep the freshest projections per thread, evicting the least-recently-used past the cap. */
646
+ private rememberDerived(
647
+ session: Thread,
648
+ liveDiff: LiveWorkingDiff | null,
649
+ derived: DerivedSessionProjection,
650
+ ): void {
651
+ this.derivedCache.delete(session.id);
652
+ this.derivedCache.set(session.id, {
653
+ derivedFor: session,
654
+ derivedForLiveDiff: liveDiff,
655
+ derived,
656
+ });
657
+ while (this.derivedCache.size > DERIVED_CACHE_LIMIT) {
658
+ const oldest = this.derivedCache.keys().next().value;
520
659
 
521
- for (const file of files) models.set(file.path, parseFileDiff(file));
522
- this.derived = {
523
- display: session ? buildDisplay(session.artifact.content, session.workingCopy) : [],
524
- rows,
525
- files: walkFiles(rows),
526
- models,
527
- tree: session?.history ? treeRows(session.history) : [],
528
- };
660
+ if (oldest === undefined) break;
661
+ this.derivedCache.delete(oldest);
662
+ }
529
663
  }
530
664
 
531
665
  /** The per-file contents that back the current rows: a diff thread's capture, else the live working tree. */
@@ -821,7 +955,7 @@ class Controller implements ReviewController {
821
955
 
822
956
  return null;
823
957
  }
824
- const model = this.derived.models.get(row.file);
958
+ const model = this.fileModel(row.file);
825
959
 
826
960
  if (!model) return null;
827
961
 
@@ -902,7 +1036,7 @@ class Controller implements ReviewController {
902
1036
  const rejected = new Set<number>();
903
1037
 
904
1038
  this.derived.rows.forEach((row, index) => {
905
- const model = this.derived.models.get(row.file);
1039
+ const model = this.fileModel(row.file);
906
1040
 
907
1041
  if (model && isRowRejected(row.file, model, row, this.rejections)) rejected.add(index);
908
1042
  });
@@ -979,7 +1113,7 @@ class Controller implements ReviewController {
979
1113
 
980
1114
  /** The change/deletion rows a rejection covers, for its preview and reveal row. */
981
1115
  private rowsForRejection(rejection: HunkRejection): DiffRow[] {
982
- const model = this.derived.models.get(rejection.path);
1116
+ const model = this.fileModel(rejection.path);
983
1117
 
984
1118
  if (!model) return [];
985
1119
  const rows: DiffRow[] = [];
@@ -1094,7 +1228,7 @@ class Controller implements ReviewController {
1094
1228
  : { id: newAnnotationId(), kind, anchor, body, target: resolvedTarget };
1095
1229
  const persisted = this.client!.sessionComment(session.id, wire);
1096
1230
 
1097
- this.apply(persisted);
1231
+ this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
1098
1232
  this.mirrorAnnotation(persisted, wire);
1099
1233
 
1100
1234
  return wire.id;
@@ -1110,7 +1244,7 @@ class Controller implements ReviewController {
1110
1244
  : { id: newAnnotationId(), kind: "comment", anchor, body, target };
1111
1245
  const persisted = this.client!.sessionComment(session.id, wire);
1112
1246
 
1113
- this.apply(persisted);
1247
+ this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
1114
1248
  this.mirrorAnnotation(persisted, wire);
1115
1249
 
1116
1250
  return wire.id;
@@ -1176,7 +1310,7 @@ class Controller implements ReviewController {
1176
1310
  const wire = root.target ? { ...base, target: root.target } : base;
1177
1311
  const persisted = this.client!.sessionComment(session.id, wire);
1178
1312
 
1179
- this.apply(persisted);
1313
+ this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
1180
1314
  this.mirrorAnnotation(persisted, wire);
1181
1315
 
1182
1316
  return wire.id;
@@ -1190,7 +1324,7 @@ class Controller implements ReviewController {
1190
1324
  const wire = { id: newAnnotationId(), kind: "comment", anchor, body };
1191
1325
  const persisted = this.client!.sessionComment(session.id, wire);
1192
1326
 
1193
- this.apply(persisted);
1327
+ this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
1194
1328
  this.mirrorAnnotation(persisted, wire);
1195
1329
 
1196
1330
  return wire.id;
@@ -1215,9 +1349,8 @@ class Controller implements ReviewController {
1215
1349
  if (existing.replyTo !== undefined) wire.replyTo = existing.replyTo;
1216
1350
  const persisted = this.client!.sessionComment(session.id, wire);
1217
1351
 
1218
- this.apply(persisted);
1352
+ this.applyOptimistic(withAnnotationUpserted(session, wire), persisted);
1219
1353
  this.mirrorAnnotation(persisted, wire);
1220
- this.setStatus("annotation updated");
1221
1354
  }
1222
1355
 
1223
1356
  // push only after the local write lands, so a rejected write never leaks to the share
@@ -40,6 +40,7 @@ import { printableSequence, type MarkRange, type VisualLine } from "./mark-runs"
40
40
  import { matchesLeader } from "./thread-chords";
41
41
  import {
42
42
  activeSlashToken,
43
+ insertSlashItem,
43
44
  isStandaloneSlashQuery,
44
45
  mergeSlashItems,
45
46
  slashFilter,
@@ -243,6 +244,11 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
243
244
  };
244
245
  const [folded, setFolded] = useState<Set<string>>(new Set());
245
246
  const [composeText, setComposeText] = useState("");
247
+ const composeTextRef = useRef("");
248
+ const setDraft = (text: string): void => {
249
+ composeTextRef.current = text;
250
+ setComposeText(text);
251
+ };
246
252
  const [caretOffset, setCaretOffset] = useState(0);
247
253
  const [slashIndex, setSlashIndex] = useState(0);
248
254
  const composerReady = useRef(false);
@@ -314,14 +320,14 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
314
320
  composerReady.current = false;
315
321
  composeRef.current = state;
316
322
  setCompose(state);
317
- setComposeText(state.seed);
323
+ setDraft(state.seed);
318
324
  setCaretOffset(state.seed.length);
319
325
  setSlashIndex(0);
320
326
  };
321
327
  const closeCompose = (): void => {
322
328
  composeRef.current = null;
323
329
  setCompose(null);
324
- setComposeText("");
330
+ setDraft("");
325
331
  setCaretOffset(0);
326
332
  };
327
333
  const skills = useContext(SlashSkillsContext);
@@ -451,14 +457,10 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
451
457
  return true;
452
458
  }
453
459
  if (key.name === "return" || key.name === "tab") {
454
- const token = activeSlashToken(composeText, caretOffset) ?? "";
455
- const cut = caretOffset - token.length;
456
- const insertion = `/${slashItems[selected]!.name} `;
457
- const seed = composeText.slice(0, cut) + insertion + composeText.slice(caretOffset);
460
+ const inserted = insertSlashItem(composeText, caretOffset, slashItems[selected]!.name);
458
461
 
459
- openCompose({ ...activeCompose, seed });
460
- // land the caret just after the inserted reference, not at the end of a chained draft
461
- setCaretOffset(cut + insertion.length);
462
+ openCompose({ ...activeCompose, seed: inserted.text });
463
+ setCaretOffset(inserted.caret);
462
464
 
463
465
  return true;
464
466
  }
@@ -473,7 +475,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
473
475
  const grown = { ...activeCompose, seed: `${activeCompose.seed}\n` };
474
476
 
475
477
  composeRef.current = grown;
476
- setComposeText(grown.seed);
478
+ setDraft(grown.seed);
477
479
  setCaretOffset(grown.seed.length);
478
480
 
479
481
  return setCompose(grown);
@@ -484,7 +486,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
484
486
  const grown = { ...activeCompose, seed: activeCompose.seed + sequence };
485
487
 
486
488
  composeRef.current = grown;
487
- setComposeText(grown.seed);
489
+ setDraft(grown.seed);
488
490
  setCaretOffset(grown.seed.length);
489
491
  setCompose(grown);
490
492
  }
@@ -503,7 +505,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
503
505
  // (an edit of an existing comment is never deleted this way)
504
506
  if (
505
507
  key.name === "backspace" &&
506
- composeText.length === 0 &&
508
+ composeTextRef.current.length === 0 &&
507
509
  activeCompose.editAnnotationId === null
508
510
  ) {
509
511
  return closeCompose();
@@ -704,7 +706,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
704
706
  onSave={saveComment}
705
707
  onReady={() => (composerReady.current = true)}
706
708
  onInput={(text, caret) => {
707
- setComposeText(text);
709
+ setDraft(text);
708
710
  setCaretOffset(caret);
709
711
  }}
710
712
  />
@@ -799,9 +801,36 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
799
801
  const endsInLine = (end: number): boolean => end - 1 >= line.start && end - 1 < line.end;
800
802
  const nodes: React.ReactNode[] = [];
801
803
  const composeHere = compose && compose.blockIndex === blockIndex;
804
+ const newComposeHere = Boolean(composeHere && compose.discussionKey === null && composerNode);
805
+ const composeAnchoredHere =
806
+ newComposeHere && (compose!.span ? endsInLine(compose!.span.end.char) : isLastLine);
807
+ const composeStart = compose?.span?.start;
808
+
809
+ const pushComposeCard = (): void => {
810
+ nodes.push(
811
+ <DiscussionCard
812
+ key="compose-new"
813
+ tokens={tokens}
814
+ segments={[{ color: palette.cardEdge, node: composerNode }]}
815
+ />,
816
+ );
817
+ nodes.push(paletteNode);
818
+ };
819
+
820
+ // the draft sorts in by its span start, the same order discussionsFrom lands it on save, so a
821
+ // new comment sits in its final position while typing instead of appending to the stack
822
+ let composePending = composeAnchoredHere;
802
823
 
803
824
  for (const discussion of discussions) {
804
825
  if (discussion.blockIndex !== blockIndex || !endsInLine(discussion.span.end.char)) continue;
826
+ if (
827
+ composePending &&
828
+ composeStart &&
829
+ comparePositions(composeStart, discussion.span.start) < 0
830
+ ) {
831
+ pushComposeCard();
832
+ composePending = false;
833
+ }
805
834
  const composingHere = Boolean(composeHere && compose.discussionKey === discussion.key);
806
835
 
807
836
  if (folded.has(discussion.key) && !composingHere) {
@@ -811,20 +840,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
811
840
  nodes.push(discussionCardFor(discussion, composingHere));
812
841
  if (composingHere) nodes.push(paletteNode);
813
842
  }
814
- if (composeHere && compose.discussionKey === null && composerNode) {
815
- const anchoredHere = compose.span ? endsInLine(compose.span.end.char) : isLastLine;
816
-
817
- if (anchoredHere) {
818
- nodes.push(
819
- <DiscussionCard
820
- key="compose-new"
821
- tokens={tokens}
822
- segments={[{ color: palette.cardEdge, node: composerNode }]}
823
- />,
824
- );
825
- nodes.push(paletteNode);
826
- }
827
- }
843
+ if (composePending) pushComposeCard();
828
844
 
829
845
  return nodes;
830
846
  };