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

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 (130) hide show
  1. package/package.json +14 -13
  2. package/src/App.autoclose.test.tsx +3 -3
  3. package/src/App.diff.test.tsx +22 -5
  4. package/src/App.export.test.tsx +1 -2
  5. package/src/App.focus.test.ts +1 -59
  6. package/src/App.identity.test.tsx +3 -3
  7. package/src/App.plan-render.test.tsx +209 -0
  8. package/src/App.plan-review.test.tsx +91 -26
  9. package/src/App.readonly.test.tsx +6 -5
  10. package/src/App.share-dialog.test.tsx +172 -0
  11. package/src/App.submit-confirm.test.tsx +39 -8
  12. package/src/App.test.tsx +33 -30
  13. package/src/App.toast.test.tsx +11 -4
  14. package/src/App.tsx +263 -274
  15. package/src/App.walk.test.tsx +11 -10
  16. package/src/App.workbench.test.tsx +86 -0
  17. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  18. package/src/annotation-palette.ts +1 -0
  19. package/src/ansi-screen.test.ts +28 -0
  20. package/src/ansi-screen.ts +72 -0
  21. package/src/app-screens.tsx +19 -9
  22. package/src/app-view-model.ts +3 -1
  23. package/src/components/AnnotatableFileView.tsx +1 -1
  24. package/src/components/AnnotationCards.tsx +18 -2
  25. package/src/components/AnsiScreen.stories.tsx +12 -0
  26. package/src/components/AnsiScreen.tsx +28 -0
  27. package/src/components/CompletionOverlay.stories.tsx +15 -1
  28. package/src/components/CompletionOverlay.tsx +22 -5
  29. package/src/components/ConfirmCard.stories.tsx +2 -0
  30. package/src/components/ConfirmCard.tsx +45 -18
  31. package/src/components/ConfirmDialog.stories.tsx +3 -3
  32. package/src/components/ConfirmDialog.tsx +11 -12
  33. package/src/components/DiffContentView.tsx +18 -11
  34. package/src/components/DiscussionMarkerRail.tsx +9 -4
  35. package/src/components/FileContentsView.tsx +1 -1
  36. package/src/components/GridTabContent.tsx +8 -2
  37. package/src/components/HandleChipsInput.stories.tsx +24 -0
  38. package/src/components/HandleChipsInput.test.tsx +35 -0
  39. package/src/components/HandleChipsInput.tsx +145 -0
  40. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  41. package/src/components/MarkdownGridBlock.tsx +72 -0
  42. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  43. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  44. package/src/components/MarkdownThreadEditor.tsx +162 -0
  45. package/src/components/NavModeHint.stories.tsx +40 -0
  46. package/src/components/NavModeHint.tsx +31 -0
  47. package/src/components/PromptDialog.test.tsx +2 -2
  48. package/src/components/PromptDialog.tsx +9 -10
  49. package/src/components/SettingsDialog.tsx +1 -0
  50. package/src/components/ShareDialog.stories.tsx +121 -0
  51. package/src/components/ShareDialog.tsx +552 -0
  52. package/src/components/SlashComposer.stories.tsx +37 -0
  53. package/src/components/SlashComposer.tsx +87 -0
  54. package/src/components/ThreadFooter.tsx +4 -1
  55. package/src/components/ThreadTree.tsx +12 -7
  56. package/src/components/ThreadView.selection.test.tsx +88 -1
  57. package/src/components/ThreadView.slash.test.tsx +0 -36
  58. package/src/components/ThreadView.tsx +200 -67
  59. package/src/components/Toast.stories.tsx +7 -0
  60. package/src/components/Toast.tsx +15 -8
  61. package/src/components/WalkWizard.stories.tsx +2 -2
  62. package/src/components/WalkWizard.tsx +10 -12
  63. package/src/components/__snapshots__/stories.test.tsx.snap +436 -182
  64. package/src/components/primitives/Button.tsx +6 -2
  65. package/src/components/primitives/Card.tsx +5 -2
  66. package/src/components/primitives/Dialog.tsx +2 -2
  67. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  68. package/src/components/primitives/DialogActions.tsx +39 -0
  69. package/src/components/primitives/Switch.stories.tsx +17 -0
  70. package/src/components/primitives/Switch.tsx +32 -0
  71. package/src/components/primitives/Tree.tsx +5 -1
  72. package/src/components/primitives/frame.ts +5 -0
  73. package/src/components/session-tree.ts +7 -6
  74. package/src/components/share-dialog-store.ts +58 -0
  75. package/src/components/stories-app.tsx +13 -4
  76. package/src/components/stories.test.tsx +12 -4
  77. package/src/components/story-tree.test.ts +32 -24
  78. package/src/components/story.ts +32 -15
  79. package/src/config.test.ts +18 -0
  80. package/src/config.ts +8 -1
  81. package/src/diff-fold.ts +1 -1
  82. package/src/diff-intraline.test.ts +75 -2
  83. package/src/diff-intraline.ts +95 -26
  84. package/src/intent-dispatch.test.ts +12 -3
  85. package/src/intent-dispatch.ts +10 -25
  86. package/src/key-bindings.ts +0 -6
  87. package/src/keymap.test.ts +2 -2
  88. package/src/keymap.ts +0 -2
  89. package/src/markdown-editor-syntax-style.ts +50 -0
  90. package/src/markdown-grid.test.ts +101 -0
  91. package/src/markdown-grid.ts +203 -0
  92. package/src/markdown-highlight.test.ts +61 -0
  93. package/src/markdown-highlight.ts +122 -0
  94. package/src/pasted-image.test.ts +67 -0
  95. package/src/pasted-image.ts +60 -0
  96. package/src/prototype-browser.test.ts +9 -1
  97. package/src/prototype-browser.ts +22 -0
  98. package/src/run.ts +39 -25
  99. package/src/serve.ts +1 -1
  100. package/src/share.ts +16 -2
  101. package/src/slash-palette.test.ts +17 -0
  102. package/src/slash-palette.ts +11 -0
  103. package/src/test-support.ts +14 -0
  104. package/src/thread-chords.test.ts +55 -178
  105. package/src/thread-chords.ts +78 -169
  106. package/src/thread-controller.curate.test.ts +1 -0
  107. package/src/thread-controller.derivation.test.ts +62 -0
  108. package/src/thread-controller.live-diff.test.ts +3 -0
  109. package/src/thread-controller.optimistic.test.ts +69 -0
  110. package/src/thread-controller.share.test.ts +60 -5
  111. package/src/thread-controller.switch-cache.test.ts +176 -0
  112. package/src/thread-controller.tree.test.ts +6 -1
  113. package/src/thread-controller.ts +386 -78
  114. package/src/use-annotation-surface.tsx +88 -63
  115. package/src/use-settings-dialog.tsx +14 -3
  116. package/src/use-thread-body-editing.tsx +58 -0
  117. package/src/view-plan.test.ts +56 -6
  118. package/src/view-plan.ts +154 -5
  119. package/src/App.share-choice.test.tsx +0 -105
  120. package/src/components/ManageAccessDialog.stories.tsx +0 -36
  121. package/src/components/ManageAccessDialog.test.tsx +0 -94
  122. package/src/components/ManageAccessDialog.tsx +0 -120
  123. package/src/components/ShareChoiceDialog.stories.tsx +0 -34
  124. package/src/components/ShareChoiceDialog.test.tsx +0 -103
  125. package/src/components/ShareChoiceDialog.tsx +0 -82
  126. package/src/components/ThreadsSidebar.stories.tsx +0 -36
  127. package/src/components/ThreadsSidebar.tsx +0 -71
  128. package/src/components/agent-launcher.stories.tsx +0 -26
  129. package/src/components/agent-launcher.test.tsx +0 -50
  130. package/src/components/agent-launcher.tsx +0 -149
@@ -37,9 +37,9 @@ import {
37
37
  } from "./thread-selection";
38
38
  import { annotationPaletteFor, type AnnotationPalette } from "./annotation-palette";
39
39
  import { printableSequence, type MarkRange, type VisualLine } from "./mark-runs";
40
- import { matchesLeader } from "./thread-chords";
41
40
  import {
42
41
  activeSlashToken,
42
+ insertSlashItem,
43
43
  isStandaloneSlashQuery,
44
44
  mergeSlashItems,
45
45
  slashFilter,
@@ -106,8 +106,8 @@ export interface AnnotationSurfaceOptions {
106
106
  onUpdateAnnotation: (id: string, body: string) => void;
107
107
  /** The author's display name for a comment's hover tooltip; the rail resolves it against the participant registry. */
108
108
  resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
109
- leaderCombos?: readonly string[];
110
- onLeaderCommand?: (key: KeyEvent) => void;
109
+ /** Resolve a nav-mode key to a session/curation/tree/diff command; true when it acted. */
110
+ onNavCommand?: (key: KeyEvent) => boolean;
111
111
  onExit: () => void;
112
112
  }
113
113
 
@@ -118,6 +118,8 @@ export interface AnnotationSurface {
118
118
  cursor: number;
119
119
  head: TextPosition;
120
120
  compose: ComposeState | null;
121
+ /** True while the surface is in nav mode: bare keys act as commands, typing does not compose. */
122
+ navMode: boolean;
121
123
  focusedDiscussion: string | null;
122
124
  /** The block to keep in view: an opening card, a focused discussion, else the caret. */
123
125
  revealBlockIndex: number;
@@ -182,8 +184,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
182
184
  onReply,
183
185
  onUpdateAnnotation,
184
186
  resolveAuthorLabel,
185
- leaderCombos,
186
- onLeaderCommand,
187
+ onNavCommand,
187
188
  onExit,
188
189
  } = options;
189
190
  const palette = annotationPaletteFor(tokens);
@@ -198,7 +199,12 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
198
199
  return { head: start, anchor: start };
199
200
  });
200
201
  const [compose, setCompose] = useState<ComposeState | null>(null);
201
- const leaderPending = useRef(false);
202
+ const [navMode, setNavModeState] = useState(false);
203
+ const navModeRef = useRef(false);
204
+ const setNavMode = (value: boolean): void => {
205
+ navModeRef.current = value;
206
+ setNavModeState(value);
207
+ };
202
208
 
203
209
  useEffect(() => {
204
210
  onComposingChange?.(compose !== null);
@@ -243,6 +249,11 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
243
249
  };
244
250
  const [folded, setFolded] = useState<Set<string>>(new Set());
245
251
  const [composeText, setComposeText] = useState("");
252
+ const composeTextRef = useRef("");
253
+ const setDraft = (text: string): void => {
254
+ composeTextRef.current = text;
255
+ setComposeText(text);
256
+ };
246
257
  const [caretOffset, setCaretOffset] = useState(0);
247
258
  const [slashIndex, setSlashIndex] = useState(0);
248
259
  const composerReady = useRef(false);
@@ -314,14 +325,14 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
314
325
  composerReady.current = false;
315
326
  composeRef.current = state;
316
327
  setCompose(state);
317
- setComposeText(state.seed);
328
+ setDraft(state.seed);
318
329
  setCaretOffset(state.seed.length);
319
330
  setSlashIndex(0);
320
331
  };
321
332
  const closeCompose = (): void => {
322
333
  composeRef.current = null;
323
334
  setCompose(null);
324
- setComposeText("");
335
+ setDraft("");
325
336
  setCaretOffset(0);
326
337
  };
327
338
  const skills = useContext(SlashSkillsContext);
@@ -451,14 +462,10 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
451
462
  return true;
452
463
  }
453
464
  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);
465
+ const inserted = insertSlashItem(composeText, caretOffset, slashItems[selected]!.name);
458
466
 
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);
467
+ openCompose({ ...activeCompose, seed: inserted.text });
468
+ setCaretOffset(inserted.caret);
462
469
 
463
470
  return true;
464
471
  }
@@ -473,7 +480,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
473
480
  const grown = { ...activeCompose, seed: `${activeCompose.seed}\n` };
474
481
 
475
482
  composeRef.current = grown;
476
- setComposeText(grown.seed);
483
+ setDraft(grown.seed);
477
484
  setCaretOffset(grown.seed.length);
478
485
 
479
486
  return setCompose(grown);
@@ -484,7 +491,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
484
491
  const grown = { ...activeCompose, seed: activeCompose.seed + sequence };
485
492
 
486
493
  composeRef.current = grown;
487
- setComposeText(grown.seed);
494
+ setDraft(grown.seed);
488
495
  setCaretOffset(grown.seed.length);
489
496
  setCompose(grown);
490
497
  }
@@ -503,7 +510,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
503
510
  // (an edit of an existing comment is never deleted this way)
504
511
  if (
505
512
  key.name === "backspace" &&
506
- composeText.length === 0 &&
513
+ composeTextRef.current.length === 0 &&
507
514
  activeCompose.editAnnotationId === null
508
515
  ) {
509
516
  return closeCompose();
@@ -512,7 +519,22 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
512
519
  if (!composerReady.current) handlePremountKey(key, activeCompose);
513
520
  };
514
521
 
515
- /** m / cmd+[ / cmd+] / tab / return - true when the key was a discussion primitive. */
522
+ /** Fold or unfold every discussion sitting on the caret's block (nav-mode z). */
523
+ const toggleFoldAtCursor = (): void => {
524
+ setFolded((current) => {
525
+ const next = new Set(current);
526
+
527
+ for (const discussion of discussions) {
528
+ if (discussion.blockIndex !== cursor) continue;
529
+ if (next.has(discussion.key)) next.delete(discussion.key);
530
+ else next.add(discussion.key);
531
+ }
532
+
533
+ return next;
534
+ });
535
+ };
536
+
537
+ /** m / cmd+[ / cmd+] / return - true when the key was a discussion primitive. */
516
538
  const handleDiscussionVerb = (key: KeyEvent): boolean => {
517
539
  // comment on selection: cmd+option+m (alt+m where cmd arrives ESC-prefixed)
518
540
  if (key.name === "m" && (key.super || key.meta || key.option)) {
@@ -536,21 +558,6 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
536
558
 
537
559
  return true;
538
560
  }
539
- if (key.name === "tab") {
540
- setFolded((current) => {
541
- const next = new Set(current);
542
-
543
- for (const discussion of discussions) {
544
- if (discussion.blockIndex !== cursor) continue;
545
- if (next.has(discussion.key)) next.delete(discussion.key);
546
- else next.add(discussion.key);
547
- }
548
-
549
- return next;
550
- });
551
-
552
- return true;
553
- }
554
561
  // enter replies into the focused discussion, else the cursor block's discussion
555
562
  if (key.name === "return") {
556
563
  const replyTarget =
@@ -666,23 +673,25 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
666
673
 
667
674
  if (activeCompose) return handleComposeKey(key, activeCompose);
668
675
  if (key.name === "escape") {
669
- if (leaderPending.current) {
670
- leaderPending.current = false;
671
-
672
- return;
673
- }
674
- if (focusedDiscussion !== null) return setFocusedDiscussion(null);
676
+ // from type mode esc only enters nav, so a held mark survives for `c`
677
+ if (!navModeRef.current) return setNavMode(true);
678
+ if (focusedDiscussion !== null) setFocusedDiscussion(null);
675
679
 
676
680
  return collapseCaret();
677
681
  }
678
- if (leaderPending.current) {
679
- leaderPending.current = false;
680
- onLeaderCommand?.(key);
681
-
682
- return;
683
- }
684
- if (leaderCombos && matchesLeader(key, leaderCombos)) {
685
- leaderPending.current = true;
682
+ if (navModeRef.current) {
683
+ if (handleCaretKey(key)) return;
684
+ if (key.name === "z") return toggleFoldAtCursor();
685
+ if (onNavCommand?.(key)) return;
686
+ if (key.name === "c") {
687
+ setNavMode(false);
688
+
689
+ return openNewCompose("");
690
+ }
691
+ if (printableSequence(key)) {
692
+ setNavMode(false);
693
+ startTyping(key);
694
+ }
686
695
 
687
696
  return;
688
697
  }
@@ -704,7 +713,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
704
713
  onSave={saveComment}
705
714
  onReady={() => (composerReady.current = true)}
706
715
  onInput={(text, caret) => {
707
- setComposeText(text);
716
+ setDraft(text);
708
717
  setCaretOffset(caret);
709
718
  }}
710
719
  />
@@ -799,9 +808,36 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
799
808
  const endsInLine = (end: number): boolean => end - 1 >= line.start && end - 1 < line.end;
800
809
  const nodes: React.ReactNode[] = [];
801
810
  const composeHere = compose && compose.blockIndex === blockIndex;
811
+ const newComposeHere = Boolean(composeHere && compose.discussionKey === null && composerNode);
812
+ const composeAnchoredHere =
813
+ newComposeHere && (compose!.span ? endsInLine(compose!.span.end.char) : isLastLine);
814
+ const composeStart = compose?.span?.start;
815
+
816
+ const pushComposeCard = (): void => {
817
+ nodes.push(
818
+ <DiscussionCard
819
+ key="compose-new"
820
+ tokens={tokens}
821
+ segments={[{ color: palette.cardEdge, node: composerNode }]}
822
+ />,
823
+ );
824
+ nodes.push(paletteNode);
825
+ };
826
+
827
+ // the draft sorts in by its span start, the same order discussionsFrom lands it on save, so a
828
+ // new comment sits in its final position while typing instead of appending to the stack
829
+ let composePending = composeAnchoredHere;
802
830
 
803
831
  for (const discussion of discussions) {
804
832
  if (discussion.blockIndex !== blockIndex || !endsInLine(discussion.span.end.char)) continue;
833
+ if (
834
+ composePending &&
835
+ composeStart &&
836
+ comparePositions(composeStart, discussion.span.start) < 0
837
+ ) {
838
+ pushComposeCard();
839
+ composePending = false;
840
+ }
805
841
  const composingHere = Boolean(composeHere && compose.discussionKey === discussion.key);
806
842
 
807
843
  if (folded.has(discussion.key) && !composingHere) {
@@ -811,20 +847,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
811
847
  nodes.push(discussionCardFor(discussion, composingHere));
812
848
  if (composingHere) nodes.push(paletteNode);
813
849
  }
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
- }
850
+ if (composePending) pushComposeCard();
828
851
 
829
852
  return nodes;
830
853
  };
@@ -849,6 +872,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
849
872
  const pressed = positionAt(allGeometry(), event.x, event.y);
850
873
 
851
874
  if (!pressed || !source.annotatable(pressed.blockIndex)) return;
875
+ setNavMode(false);
852
876
  const stamp = { time: Date.now(), x: event.x, y: event.y };
853
877
  const wordMode = isDoubleClick(lastClick.current, stamp);
854
878
 
@@ -876,6 +900,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
876
900
  cursor,
877
901
  head,
878
902
  compose,
903
+ navMode,
879
904
  focusedDiscussion,
880
905
  revealBlockIndex,
881
906
  spanQuote,
@@ -35,11 +35,14 @@ export interface SettingsDialogModel {
35
35
  cycleSetting: (rowKey: string) => void;
36
36
  handleSettingsKey: (name: string) => void;
37
37
  onCategorySelect: (categoryId: string) => void;
38
+ /** Open the settings dialog focused on the left nav, from a fresh state. */
39
+ openSettings: () => void;
38
40
  }
39
41
 
40
42
  const DOWN_KEYS = new Set(["j", "down"]);
41
43
  const UP_KEYS = new Set(["k", "up"]);
42
- const ENTER_BODY_KEYS = new Set(["l", "tab", "return"]);
44
+ const ENTER_BODY_KEYS = new Set(["l", "right", "tab", "return"]);
45
+ const BACK_TO_NAV_KEYS = new Set(["h", "left", "tab"]);
43
46
  const ACTIVATE_KEYS = new Set(["return", "space", "l"]);
44
47
 
45
48
  function moveNavZone(
@@ -64,7 +67,7 @@ function moveNavZone(
64
67
  function moveBodyRow(name: string, nav: SettingsNav, rowCount: number): SettingsNav | null {
65
68
  if (DOWN_KEYS.has(name)) return { ...nav, rowIndex: Math.min(rowCount - 1, nav.rowIndex + 1) };
66
69
  if (UP_KEYS.has(name)) return { ...nav, rowIndex: Math.max(0, nav.rowIndex - 1) };
67
- if (name === "h" || name === "tab") return { ...nav, zone: "nav" };
70
+ if (BACK_TO_NAV_KEYS.has(name)) return { ...nav, zone: "nav" };
68
71
 
69
72
  return null;
70
73
  }
@@ -108,10 +111,11 @@ export function useSettingsDialog(params: {
108
111
  onRenameDisplayName,
109
112
  } = params;
110
113
 
114
+ // open focused on the left nav, so up/down browses categories until l/tab/enter enters the body
111
115
  const [settingsNav, setSettingsNav] = useState<SettingsNav>({
112
116
  categoryId: "general",
113
117
  rowIndex: 0,
114
- zone: "body",
118
+ zone: "nav",
115
119
  });
116
120
  const [actionsExpandedIndex, setActionsExpandedIndex] = useState<number | null>(null);
117
121
  const [actionsExpandedField, setActionsExpandedField] = useState<"prompt" | "metadata">("prompt");
@@ -247,6 +251,12 @@ export function useSettingsDialog(params: {
247
251
  }
248
252
  };
249
253
 
254
+ const openSettings = (): void => {
255
+ setSettingsNav({ categoryId: "general", rowIndex: 0, zone: "nav" });
256
+ setActionsExpandedIndex(null);
257
+ setMenuDialog("settings");
258
+ };
259
+
250
260
  const onCategorySelect = (categoryId: string): void => {
251
261
  // ignore a select that names no real category, so the key handler never dereferences nothing
252
262
  if (!settingsCategories.some((category) => category.id === categoryId)) return;
@@ -305,5 +315,6 @@ export function useSettingsDialog(params: {
305
315
  cycleSetting,
306
316
  handleSettingsKey,
307
317
  onCategorySelect,
318
+ openSettings,
308
319
  };
309
320
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The thread pane's inline edit mode: owns whether the native markdown editor is
3
+ * open over the body, the editor element itself, and the open and exit handles.
4
+ * Opening is gated to an editable thread (the caller passes canEdit - owner,
5
+ * unresolved, not a diff); exiting saves the edited body as the working copy
6
+ * through the controller, so leaving the editor never loses work. The header
7
+ * edit/normal toggle exits through requestExit; switching threads closes the
8
+ * editor. Rendering the editor lives here too, so the pane pulls in one concept
9
+ * rather than wiring the component and its handle ref by hand.
10
+ */
11
+
12
+ import React, { useRef, useState } from "react";
13
+ import type { Theme } from "./theme";
14
+ import type { ReviewController } from "./thread-controller";
15
+ import { MarkdownThreadEditor, type MarkdownEditorHandle } from "./components/MarkdownThreadEditor";
16
+
17
+ export interface ThreadBodyEditing {
18
+ /** True while the inline markdown editor owns the thread pane and its keys. */
19
+ editing: boolean;
20
+ /** Open the editor when the active thread is editable; a no-op otherwise. */
21
+ openEditor: () => void;
22
+ /** Save the current text and close - the header edit/normal toggle's exit. */
23
+ requestExit: () => void;
24
+ /** The editor element for the thread pane, bound to the working copy and theme. */
25
+ renderEditor: (theme: Theme) => React.ReactNode;
26
+ }
27
+
28
+ export function useThreadBodyEditing(options: {
29
+ controller: ReviewController;
30
+ sessionId: string | undefined;
31
+ canEdit: boolean;
32
+ }): ThreadBodyEditing {
33
+ const { controller, sessionId, canEdit } = options;
34
+ // key editing by thread id so a thread switch closes the editor with no effect or ref
35
+ const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
36
+ const handleRef = useRef<MarkdownEditorHandle | null>(null);
37
+ const editing = editingSessionId !== null && editingSessionId === sessionId;
38
+ const exitEditor = (text: string): void => {
39
+ controller.saveEditedBody(text);
40
+ setEditingSessionId(null);
41
+ };
42
+
43
+ return {
44
+ editing,
45
+ openEditor: () => {
46
+ if (canEdit && sessionId !== undefined) setEditingSessionId(sessionId);
47
+ },
48
+ requestExit: () => handleRef.current?.requestExit(),
49
+ renderEditor: (theme) => (
50
+ <MarkdownThreadEditor
51
+ ref={handleRef}
52
+ initialText={controller.working()}
53
+ theme={theme}
54
+ onExitEditor={exitEditor}
55
+ />
56
+ ),
57
+ };
58
+ }
@@ -9,10 +9,14 @@ import {
9
9
  renderedOffsetAtOrAfter,
10
10
  renderedOffsetAtOrBefore,
11
11
  renderedOffsetFor,
12
+ renderedSpanToWork,
13
+ renderedStyleRuns,
14
+ renderedText,
12
15
  safeLinkHref,
13
16
  spanFromRange,
14
17
  spanKey,
15
18
  startSpan,
19
+ styledRunsFor,
16
20
  workRangeForRendered,
17
21
  } from "./view-plan";
18
22
  import { cutBlock, parseBlocks, restoreBlock, restoreLine, type Annotation } from "@cueloop/schema";
@@ -273,9 +277,9 @@ describe("rendered/work offset mapping", () => {
273
277
  const rendered = renderedOffsetFor(runs, wordsAt)!;
274
278
 
275
279
  // Assert
276
- const renderedText = runs.map((run) => run.text).join("");
280
+ const renderedString = runs.map((run) => run.text).join("");
277
281
 
278
- expect(renderedText.slice(rendered, rendered + "words".length)).toBe("words");
282
+ expect(renderedString.slice(rendered, rendered + "words".length)).toBe("words");
279
283
  // reading a rendered selection over "words" recovers the work range
280
284
  expect(workRangeForRendered(runs, rendered, rendered + "words".length)).toEqual({
281
285
  start: wordsAt,
@@ -287,11 +291,11 @@ describe("rendered/work offset mapping", () => {
287
291
  // Arrange
288
292
  const block = buildDisplay("old words\n", "new words\n")[0]!;
289
293
  const runs = blockRuns(block, true);
290
- const renderedText = runs.map((run) => run.text).join("");
294
+ const renderedString = runs.map((run) => run.text).join("");
291
295
 
292
296
  // Act
293
297
  // select everything rendered: the work range is the whole work text
294
- const range = workRangeForRendered(runs, 0, renderedText.length)!;
298
+ const range = workRangeForRendered(runs, 0, renderedString.length)!;
295
299
 
296
300
  // Assert
297
301
  expect(displayText(block).slice(range.start, range.end)).toBe(displayText(block));
@@ -319,9 +323,9 @@ describe("rendered/work offset mapping", () => {
319
323
  const renderedEnd = renderedOffsetAtOrBefore(runs, spanEnd - 1)!;
320
324
 
321
325
  // Assert
322
- const renderedText = runs.map((run) => run.text).join("");
326
+ const renderedString = runs.map((run) => run.text).join("");
323
327
 
324
- expect(renderedText.slice(renderedStart, renderedEnd + 1)).toBe("very");
328
+ expect(renderedString.slice(renderedStart, renderedEnd + 1)).toBe("very");
325
329
  // exact hits behave like renderedOffsetFor
326
330
  expect(renderedOffsetAtOrAfter(runs, 0)).toBe(0);
327
331
  expect(renderedOffsetAtOrBefore(runs, 0)).toBe(0);
@@ -404,3 +408,49 @@ describe("cut / restore round-trip", () => {
404
408
  expect(buildDisplay(BASE, restored).every((block) => block.type === "same")).toBe(true);
405
409
  });
406
410
  });
411
+
412
+ describe("rendered-text projection (markers concealed)", () => {
413
+ const linkedBlock = () => buildDisplay("Use the `flag` and see [docs](https://x.dev).")[0]!;
414
+
415
+ test("renderedText drops inline markers, keeping visible characters", () => {
416
+ expect(renderedText(linkedBlock())).toBe("Use the flag and see docs.");
417
+ });
418
+
419
+ test("renderedText leaves a table block's source verbatim", () => {
420
+ const table = buildDisplay("| a | b |\n| --- | --- |\n| 1 | 2 |")[0]!;
421
+
422
+ expect(table.kind).toBe("table");
423
+ expect(renderedText(table)).toBe("| a | b |\n| --- | --- |\n| 1 | 2 |");
424
+ });
425
+
426
+ test("renderedSpanToWork maps a rendered link label back to its work offsets", () => {
427
+ const display = buildDisplay("Use the `flag` and see [docs](https://x.dev).");
428
+ // "docs" sits at rendered offset 21 ("Use the flag and see ".length)
429
+ const work = renderedSpanToWork(display, 0, 0, 21, 25);
430
+ const workText = display[0]!.work!.text;
431
+
432
+ expect(workText.slice(work.start, work.end)).toBe("docs");
433
+ });
434
+
435
+ test("renderedSpanToWork maps rendered inline code back to the backtick content", () => {
436
+ const display = buildDisplay("Use the `flag` and see docs.");
437
+ // "flag" sits at rendered offset 8 ("Use the ".length)
438
+ const work = renderedSpanToWork(display, 0, 0, 8, 12);
439
+
440
+ expect(display[0]!.work!.text.slice(work.start, work.end)).toBe("flag");
441
+ });
442
+
443
+ test("styledRunsFor colors a link run and marks the covered stretch", () => {
444
+ const linkRow = buildDisplay("see [docs](https://x.dev) now")[0]!;
445
+ const roleRuns = renderedStyleRuns(linkRow);
446
+ // "see docs now"; mark the whole "docs" label (rendered offsets 4..8)
447
+ const runs = styledRunsFor(roleRuns, { start: 0, end: renderedText(linkRow).length }, [
448
+ { start: 4, end: 8 },
449
+ ]);
450
+ const link = runs.find((run) => run.role === "link")!;
451
+
452
+ expect(link.text).toBe("docs");
453
+ expect(link.marked).toBe(true);
454
+ expect(runs.find((run) => run.text === "see ")!.marked).toBe(false);
455
+ });
456
+ });