@cueloop/client 0.1.0-alpha.64 → 0.1.0-alpha.66

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 (109) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.diff.test.tsx +8 -73
  4. package/src/App.export.test.tsx +2 -2
  5. package/src/App.plan-review.test.tsx +60 -412
  6. package/src/App.prototype.test.tsx +1 -8
  7. package/src/App.readonly.test.tsx +85 -62
  8. package/src/App.rendering.test.tsx +5 -6
  9. package/src/App.submit-confirm.test.tsx +32 -70
  10. package/src/App.test.tsx +136 -66
  11. package/src/App.toast.test.tsx +17 -8
  12. package/src/App.tsx +243 -284
  13. package/src/App.walk.test.tsx +1 -1
  14. package/src/app-screens.tsx +67 -52
  15. package/src/app-view-model.ts +6 -100
  16. package/src/components/AnnotationCard.stories.tsx +1 -1
  17. package/src/components/AnnotationCard.tsx +2 -2
  18. package/src/components/AppShell.stories.tsx +84 -0
  19. package/src/components/AppShell.tsx +127 -0
  20. package/src/components/Breadcrumb.stories.tsx +1 -1
  21. package/src/components/ChangesColumn.stories.tsx +39 -0
  22. package/src/components/ChangesColumn.tsx +147 -0
  23. package/src/components/CodeBlock.stories.tsx +1 -1
  24. package/src/components/CompletionOverlay.stories.tsx +1 -1
  25. package/src/components/ConfirmCard.stories.tsx +1 -1
  26. package/src/components/ConfirmCard.tsx +3 -4
  27. package/src/components/ConfirmDialog.stories.tsx +1 -1
  28. package/src/components/DiffSheet.stories.tsx +1 -1
  29. package/src/components/InboxList.stories.tsx +23 -8
  30. package/src/components/InboxList.tsx +45 -19
  31. package/src/components/KeybindsDialog.stories.tsx +1 -1
  32. package/src/components/KeybindsDialog.tsx +61 -17
  33. package/src/components/MarkerPopover.stories.tsx +1 -1
  34. package/src/components/MenuBar.stories.tsx +3 -12
  35. package/src/components/MenuBar.tsx +25 -56
  36. package/src/components/PromptDialog.stories.tsx +1 -1
  37. package/src/components/PrototypeSheet.stories.tsx +1 -1
  38. package/src/components/ReviewPanel.stories.tsx +15 -1
  39. package/src/components/ReviewRail.stories.tsx +15 -1
  40. package/src/components/ReviewRail.tsx +13 -6
  41. package/src/components/SettingsDialog.stories.tsx +7 -1
  42. package/src/components/SettingsDialog.tsx +112 -66
  43. package/src/components/SettingsRows.stories.tsx +1 -1
  44. package/src/components/ShellHeader.stories.tsx +50 -0
  45. package/src/components/ShellHeader.tsx +137 -0
  46. package/src/components/ThreadFooter.stories.tsx +30 -0
  47. package/src/components/ThreadFooter.tsx +49 -0
  48. package/src/components/ThreadView.selection.test.tsx +812 -0
  49. package/src/components/ThreadView.slash.test.tsx +113 -0
  50. package/src/components/ThreadView.stories.tsx +26 -0
  51. package/src/components/ThreadView.tsx +1708 -0
  52. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  53. package/src/components/ThreadsSidebar.tsx +57 -0
  54. package/src/components/Toast.stories.tsx +1 -1
  55. package/src/components/TreePane.stories.tsx +106 -0
  56. package/src/components/TreePane.tsx +156 -0
  57. package/src/components/WalkWizard.stories.tsx +1 -1
  58. package/src/components/__snapshots__/stories.test.tsx.snap +463 -305
  59. package/src/components/agent-launcher.stories.tsx +1 -1
  60. package/src/components/file-tree.test.ts +45 -0
  61. package/src/components/file-tree.ts +57 -0
  62. package/src/components/pages/Workspace.stories.tsx +181 -0
  63. package/src/components/primitives/Button.stories.tsx +1 -1
  64. package/src/components/primitives/Card.stories.tsx +1 -1
  65. package/src/components/primitives/Dialog.stories.tsx +1 -1
  66. package/src/components/primitives/IconButton.stories.tsx +36 -0
  67. package/src/components/primitives/IconButton.tsx +43 -0
  68. package/src/components/primitives/StatusBar.stories.tsx +1 -1
  69. package/src/components/primitives/Tabs.stories.tsx +1 -1
  70. package/src/components/primitives/Toolbar.stories.tsx +1 -1
  71. package/src/components/primitives/Tree.stories.tsx +124 -0
  72. package/src/components/primitives/Tree.tsx +87 -0
  73. package/src/components/primitives/icons.ts +38 -0
  74. package/src/components/primitives/tree-model.test.ts +99 -0
  75. package/src/components/primitives/tree-model.ts +137 -0
  76. package/src/components/quick-actions-editor.stories.tsx +1 -1
  77. package/src/components/session-tree.test.ts +114 -0
  78. package/src/components/session-tree.ts +94 -0
  79. package/src/components/stories-app.tsx +142 -82
  80. package/src/components/story-tree.test.ts +100 -0
  81. package/src/components/story.ts +55 -0
  82. package/src/components/theme-context.stories.tsx +1 -1
  83. package/src/diff-hunk-curate.ts +4 -104
  84. package/src/index.ts +1 -0
  85. package/src/intent-dispatch.test.ts +136 -3
  86. package/src/intent-dispatch.ts +92 -24
  87. package/src/key-bindings.test.ts +1 -1
  88. package/src/keymap.test.ts +4 -4
  89. package/src/keymap.ts +14 -7
  90. package/src/session-controller.curate.test.ts +89 -2
  91. package/src/session-controller.share.test.ts +195 -55
  92. package/src/session-controller.tree.test.ts +228 -0
  93. package/src/session-controller.ts +331 -72
  94. package/src/share.ts +114 -16
  95. package/src/test-support.test.ts +193 -1
  96. package/src/test-support.ts +64 -0
  97. package/src/theme.ts +4 -4
  98. package/src/thread-chords.test.ts +154 -0
  99. package/src/thread-chords.ts +144 -0
  100. package/src/thread-selection.test.ts +210 -0
  101. package/src/thread-selection.ts +243 -0
  102. package/src/tree-view.test.ts +128 -0
  103. package/src/tree-view.ts +148 -0
  104. package/src/use-settings-dialog.tsx +9 -0
  105. package/src/view-plan.ts +29 -12
  106. package/src/App.review-panel.test.tsx +0 -143
  107. package/src/components/PlanSheet.stories.tsx +0 -113
  108. package/src/components/PlanSheet.tsx +0 -419
  109. package/src/components/plan-sheet-run-style.ts +0 -88
@@ -11,6 +11,7 @@ import { SCHEMA_VERSION, type Annotation, type ReviewSession } from "@cueloop/sc
11
11
  import { createIntentDispatch, type IntentDispatchDeps } from "./intent-dispatch";
12
12
  import type { ControllerSnapshot, CurationItem, ReviewController } from "./session-controller";
13
13
  import type { DisplayBlock } from "./view-plan";
14
+ import type { TreeRow } from "./tree-view";
14
15
 
15
16
  function block(text: string): DisplayBlock {
16
17
  return { type: "same", kind: "p", work: { kind: "p", text, lineStart: 0, lineEnd: 0 } };
@@ -56,7 +57,7 @@ const EMPTY_SNAPSHOT: ControllerSnapshot = {
56
57
  walk: null,
57
58
  };
58
59
 
59
- /** A controller where every verb is a mock; annotate returns undefined by default. */
60
+ /** A controller where every primitive is a mock; annotate returns undefined by default. */
60
61
  function baseController(): ReviewController {
61
62
  return {
62
63
  readOnly: false,
@@ -83,6 +84,7 @@ function baseController(): ReviewController {
83
84
  restoreCuration: mock(),
84
85
  edit: mock(),
85
86
  annotate: mock(() => undefined),
87
+ reply: mock(() => undefined),
86
88
  annotatePrototype: mock(() => undefined),
87
89
  updateAnnotation: mock(),
88
90
  removeAnnotation: mock(),
@@ -93,8 +95,14 @@ function baseController(): ReviewController {
93
95
  walkLeave: mock(),
94
96
  submit: mock(),
95
97
  share: mock(),
98
+ treeRows: mock(() => []),
99
+ goToEntry: mock(),
100
+ branch: mock(),
101
+ labelTip: mock(),
102
+ fork: mock(),
103
+ forkAndShare: mock(),
96
104
  pullShared: mock(() => Promise.resolve()),
97
- startSharePoll: mock(() => () => {}),
105
+ startShareSync: mock(() => () => {}),
98
106
  finishReview: mock(),
99
107
  dismissCompletion: mock(),
100
108
  optInAutoClose: mock(),
@@ -120,18 +128,20 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
120
128
  terminalWidth: 120,
121
129
  focusedAnnotationId: undefined,
122
130
  selectedCurationId: undefined,
131
+ railTab: "review",
132
+ selectedEntryId: undefined,
123
133
  authorNames: {},
124
134
  quickActions: [],
125
135
  renameAuthor: mock(),
126
136
  liveInput: { current: "" },
127
137
  reviewWidthRef: { current: 34 },
128
- planSheetRef: { current: null },
129
138
  setCursor: mock(),
130
139
  setInboxCursor: mock(),
131
140
  setMode: mock(),
132
141
  setReviewMode: mock(),
133
142
  setReviewWidth: mock(),
134
143
  setRailTab: mock(),
144
+ setSelectedEntryId: mock(),
135
145
  setFocusedAnnotationId: mock(),
136
146
  setSelectedCurationId: mock(),
137
147
  setPulsedAnnotationId: mock(),
@@ -544,3 +554,126 @@ describe("collaborator self-name", () => {
544
554
  expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
545
555
  });
546
556
  });
557
+
558
+ describe("tree intents", () => {
559
+ const rows: TreeRow[] = [
560
+ {
561
+ entryId: "e1",
562
+ depth: 0,
563
+ glyph: "◉",
564
+ text: "revision 1",
565
+ onPath: true,
566
+ tips: [],
567
+ isCurrentTip: false,
568
+ },
569
+ {
570
+ entryId: "e2",
571
+ depth: 0,
572
+ glyph: "·",
573
+ text: "comment",
574
+ onPath: true,
575
+ tips: ["main"],
576
+ isCurrentTip: false,
577
+ },
578
+ {
579
+ entryId: "e3",
580
+ depth: 1,
581
+ glyph: "·",
582
+ text: "comment",
583
+ onPath: false,
584
+ tips: ["alt"],
585
+ isCurrentTip: true,
586
+ },
587
+ ];
588
+
589
+ function treeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDeps {
590
+ const deps = makeDeps({ railTab: "tree", ...overrides });
591
+
592
+ deps.controller.treeRows = mock(() => rows);
593
+
594
+ return deps;
595
+ }
596
+
597
+ test("toggleTree shows the Tree tab and hides it again", () => {
598
+ // Arrange
599
+ const shown = makeDeps({ railTab: "review" });
600
+ const hidden = makeDeps({ railTab: "tree" });
601
+
602
+ // Act
603
+ createIntentDispatch(shown)({ type: "toggleTree" });
604
+ createIntentDispatch(hidden)({ type: "toggleTree" });
605
+
606
+ // Assert
607
+ expect(shown.setRailTab).toHaveBeenCalledWith("tree");
608
+ expect(hidden.setRailTab).toHaveBeenCalledWith("review");
609
+ });
610
+
611
+ test("treeMove starts from the current tip and stays inside the rows", () => {
612
+ // Arrange
613
+ const fromTip = treeDeps();
614
+ const atTop = treeDeps({ selectedEntryId: "e1" });
615
+
616
+ // Act
617
+ createIntentDispatch(fromTip)({ type: "treeMove", direction: -1 });
618
+ createIntentDispatch(atTop)({ type: "treeMove", direction: -1 });
619
+
620
+ // Assert
621
+ expect(fromTip.setSelectedEntryId).toHaveBeenCalledWith("e2");
622
+ expect(atTop.setSelectedEntryId).toHaveBeenCalledWith("e1");
623
+ });
624
+
625
+ test("treeGo: the tip answers, a tip of another branch switches, an inner entry asks for a summary", () => {
626
+ // Arrange
627
+ const atTip = treeDeps({ selectedEntryId: "e3" });
628
+ const toTip = treeDeps({ selectedEntryId: "e2" });
629
+ const inner = treeDeps({ selectedEntryId: "e1" });
630
+
631
+ // Act
632
+ createIntentDispatch(atTip)({ type: "treeGo" });
633
+ createIntentDispatch(toTip)({ type: "treeGo" });
634
+ createIntentDispatch(inner)({ type: "treeGo" });
635
+
636
+ // Assert
637
+ expect(atTip.controller.setStatus).toHaveBeenCalledWith("already at the tip");
638
+ expect(toTip.controller.goToEntry).toHaveBeenCalledWith("e2");
639
+ expect(inner.setMode).toHaveBeenCalledWith({
640
+ type: "treePrompt",
641
+ ask: "navigate",
642
+ entryId: "e1",
643
+ text: "",
644
+ });
645
+ });
646
+
647
+ test("the prompts confirm into branch, label, and a move back with the typed summary", () => {
648
+ // Arrange
649
+ const branch = treeDeps({ mode: { type: "treePrompt", ask: "branch", text: "alt" } });
650
+ const label = treeDeps({ mode: { type: "treePrompt", ask: "label", text: "start" } });
651
+ const navigate = treeDeps({
652
+ mode: { type: "treePrompt", ask: "navigate", entryId: "e1", text: " too early " },
653
+ });
654
+
655
+ // Act
656
+ createIntentDispatch(branch)({ type: "confirmDialog" });
657
+ createIntentDispatch(label)({ type: "confirmDialog" });
658
+ createIntentDispatch(navigate)({ type: "confirmDialog" });
659
+
660
+ // Assert
661
+ expect(branch.controller.branch).toHaveBeenCalledWith("alt");
662
+ expect(label.controller.labelTip).toHaveBeenCalledWith("start");
663
+ expect(navigate.controller.goToEntry).toHaveBeenCalledWith("e1", "too early");
664
+ expect(navigate.setMode).toHaveBeenCalledWith({ type: "normal" });
665
+ });
666
+
667
+ test("treeFork and treeForkShare reach the controller", () => {
668
+ // Arrange
669
+ const deps = treeDeps();
670
+
671
+ // Act
672
+ createIntentDispatch(deps)({ type: "treeFork" });
673
+ createIntentDispatch(deps)({ type: "treeForkShare" });
674
+
675
+ // Assert
676
+ expect(deps.controller.fork).toHaveBeenCalledTimes(1);
677
+ expect(deps.controller.forkAndShare).toHaveBeenCalledTimes(1);
678
+ });
679
+ });
@@ -7,14 +7,15 @@
7
7
  * into React beyond the setters it is given.
8
8
  */
9
9
 
10
- import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from "react";
10
+ import type { Dispatch, MutableRefObject, SetStateAction } from "react";
11
11
  import { isAddressed, isAgentNote, type ReviewSession, type VerdictKind } from "@cueloop/schema";
12
12
  import { displayText, spanKey, startSpan, type DisplayBlock, type SpanState } from "./view-plan";
13
13
  import type { DiffRow } from "./view-diff";
14
14
  import type { ReviewController } from "./session-controller";
15
15
  import type { Intent } from "./keymap";
16
+ import type { TreeRow } from "./tree-view";
17
+ import type { RailTab } from "./components/ReviewRail";
16
18
  import { quickActionBody, type QuickAction } from "./config";
17
- import type { PlanSheetHandle } from "./components/PlanSheet";
18
19
  import { VERDICTS } from "./components/ConfirmCard";
19
20
  import {
20
21
  REVIEW_RESIZE_STEP,
@@ -40,7 +41,11 @@ export type Mode =
40
41
  | { type: "submit"; verdict: VerdictKind; summary: string }
41
42
  | { type: "confirmDelete"; sessionId: string; title: string }
42
43
  | { type: "rename"; authorId: string; text: string }
43
- | { type: "nameSelf"; text: string };
44
+ | { type: "nameSelf"; text: string }
45
+ | { type: "treePrompt"; ask: TreeAsk; entryId?: string; text: string };
46
+
47
+ /** What a tree prompt asks for: a branch name, a checkpoint name, or the summary a move back leaves. */
48
+ export type TreeAsk = "branch" | "label" | "navigate";
44
49
 
45
50
  /** The marked span for span mode and its quick-actions sub-mode; null otherwise. */
46
51
  export function activeSpanState(mode: Mode): SpanState | null {
@@ -88,6 +93,9 @@ export interface IntentDispatchDeps {
88
93
  focusedAnnotationId: string | undefined;
89
94
  /** The curation item selected in the rail, if any; the undo target when set. */
90
95
  selectedCurationId: string | undefined;
96
+ railTab: RailTab;
97
+ /** The tree row selected in the rail's Tree tab; the target of go. */
98
+ selectedEntryId: string | undefined;
91
99
  /** Planner-local author renames, for seeding the rename prompt. */
92
100
  authorNames: Record<string, string>;
93
101
  /** Marker-popover quick actions, in list order; picking one inserts a preset comment. */
@@ -97,14 +105,14 @@ export interface IntentDispatchDeps {
97
105
 
98
106
  liveInput: MutableRefObject<string>;
99
107
  reviewWidthRef: MutableRefObject<number>;
100
- planSheetRef: RefObject<PlanSheetHandle | null>;
101
108
 
102
109
  setCursor: Dispatch<SetStateAction<number>>;
103
110
  setInboxCursor: Dispatch<SetStateAction<number>>;
104
111
  setMode: Dispatch<SetStateAction<Mode>>;
105
112
  setReviewMode: Dispatch<SetStateAction<ReviewPanelMode>>;
106
113
  setReviewWidth: Dispatch<SetStateAction<number>>;
107
- setRailTab: Dispatch<SetStateAction<"review" | "agent">>;
114
+ setRailTab: Dispatch<SetStateAction<RailTab>>;
115
+ setSelectedEntryId: Dispatch<SetStateAction<string | undefined>>;
108
116
  setFocusedAnnotationId: Dispatch<SetStateAction<string | undefined>>;
109
117
  setSelectedCurationId: Dispatch<SetStateAction<string | undefined>>;
110
118
  setPulsedAnnotationId: Dispatch<SetStateAction<string | null>>;
@@ -187,6 +195,7 @@ function handleConfirmDialog(
187
195
  if (mode.type === "confirmDelete") controller.deleteSession(mode.sessionId);
188
196
  else if (mode.type === "rename") deps.renameAuthor(mode.authorId, mode.text.trim());
189
197
  else if (mode.type === "nameSelf") controller.setSelfName(mode.text.trim());
198
+ else if (mode.type === "treePrompt") confirmTreePrompt(mode, deps);
190
199
  deps.setMode({ type: "normal" });
191
200
  }
192
201
 
@@ -307,24 +316,17 @@ function handleOpenCompose(intent: IntentOfType<"openCompose">, deps: IntentDisp
307
316
  text: "",
308
317
  });
309
318
  } else {
310
- // a mouse drag leaves a native selection; it wins over the cursor block
311
- const native = deps.planSheetRef.current?.readSelection() ?? null;
312
-
313
- if (native) {
314
- deps.setMode({ type: "compose", kind: intent.kind, ...native, text: "" });
315
- } else {
316
- const block = deps.display[deps.cursor];
317
-
318
- if (block)
319
- deps.setMode({
320
- type: "compose",
321
- kind: intent.kind,
322
- displayIndex: deps.cursor,
323
- start: 0,
324
- end: displayText(block).length,
325
- text: "",
326
- });
327
- }
319
+ const block = deps.display[deps.cursor];
320
+
321
+ if (block)
322
+ deps.setMode({
323
+ type: "compose",
324
+ kind: intent.kind,
325
+ displayIndex: deps.cursor,
326
+ start: 0,
327
+ end: displayText(block).length,
328
+ text: "",
329
+ });
328
330
  }
329
331
  }
330
332
 
@@ -429,7 +431,6 @@ function handleRemoveAnnotation(
429
431
  }
430
432
 
431
433
  function handleDeselect(_intent: IntentOfType<"deselect">, deps: IntentDispatchDeps): void {
432
- deps.planSheetRef.current?.clearSelection();
433
434
  deps.setFocusedAnnotationId(undefined);
434
435
  deps.setPulsedAnnotationId(null);
435
436
  }
@@ -526,6 +527,66 @@ function handleResizeReviewPanel(
526
527
  deps.controller.saveReviewPanel({ width: next });
527
528
  }
528
529
 
530
+ function confirmTreePrompt(
531
+ mode: Extract<Mode, { type: "treePrompt" }>,
532
+ deps: IntentDispatchDeps,
533
+ ): void {
534
+ if (mode.ask === "branch") deps.controller.branch(mode.text);
535
+ else if (mode.ask === "label") deps.controller.labelTip(mode.text);
536
+ else if (mode.entryId !== undefined) deps.controller.goToEntry(mode.entryId, mode.text.trim());
537
+ }
538
+
539
+ function handleToggleTree(_intent: IntentOfType<"toggleTree">, deps: IntentDispatchDeps): void {
540
+ deps.setRailTab(deps.railTab === "tree" ? "review" : "tree");
541
+ }
542
+
543
+ /** The tree rows in drawing order; the selection falls back to the current tip. */
544
+ function selectedTreeIndex(deps: IntentDispatchDeps, rows: TreeRow[]): number {
545
+ const byId = rows.findIndex((row) => row.entryId === deps.selectedEntryId);
546
+
547
+ return byId === -1 ? rows.findIndex((row) => row.isCurrentTip) : byId;
548
+ }
549
+
550
+ function handleTreeMove(intent: IntentOfType<"treeMove">, deps: IntentDispatchDeps): void {
551
+ const rows = deps.controller.treeRows();
552
+
553
+ if (rows.length === 0) return;
554
+ const current = selectedTreeIndex(deps, rows);
555
+ const next = Math.min(rows.length - 1, Math.max(0, current + intent.direction));
556
+
557
+ deps.setSelectedEntryId(rows[next]!.entryId);
558
+ }
559
+
560
+ function handleTreeGo(_intent: IntentOfType<"treeGo">, deps: IntentDispatchDeps): void {
561
+ const rows = deps.controller.treeRows();
562
+ const row = rows[selectedTreeIndex(deps, rows)];
563
+
564
+ if (!row) return deps.controller.setStatus("select an entry in the tree first");
565
+ if (row.isCurrentTip) return deps.controller.setStatus("already at the tip");
566
+ // a switch needs no summary; a move back may leave one, so it asks
567
+ if (row.tips.length > 0) return deps.controller.goToEntry(row.entryId);
568
+ deps.setMode({ type: "treePrompt", ask: "navigate", entryId: row.entryId, text: "" });
569
+ }
570
+
571
+ function handleTreeBranch(_intent: IntentOfType<"treeBranch">, deps: IntentDispatchDeps): void {
572
+ deps.setMode({ type: "treePrompt", ask: "branch", text: "" });
573
+ }
574
+
575
+ function handleTreeLabel(_intent: IntentOfType<"treeLabel">, deps: IntentDispatchDeps): void {
576
+ deps.setMode({ type: "treePrompt", ask: "label", text: "" });
577
+ }
578
+
579
+ function handleTreeFork(_intent: IntentOfType<"treeFork">, deps: IntentDispatchDeps): void {
580
+ deps.controller.fork();
581
+ }
582
+
583
+ function handleTreeForkShare(
584
+ _intent: IntentOfType<"treeForkShare">,
585
+ deps: IntentDispatchDeps,
586
+ ): void {
587
+ deps.controller.forkAndShare();
588
+ }
589
+
529
590
  type IntentHandlers = {
530
591
  [Kind in Intent["type"]]: (intent: IntentOfType<Kind>, deps: IntentDispatchDeps) => void;
531
592
  };
@@ -572,6 +633,13 @@ const intentHandlers: IntentHandlers = {
572
633
  dismissCompletion: handleDismissCompletion,
573
634
  cycleReviewPanel: handleCycleReviewPanel,
574
635
  resizeReviewPanel: handleResizeReviewPanel,
636
+ toggleTree: handleToggleTree,
637
+ treeMove: handleTreeMove,
638
+ treeGo: handleTreeGo,
639
+ treeBranch: handleTreeBranch,
640
+ treeLabel: handleTreeLabel,
641
+ treeFork: handleTreeFork,
642
+ treeForkShare: handleTreeForkShare,
575
643
  };
576
644
 
577
645
  function dispatchIntent<Kind extends Intent["type"]>(
@@ -43,7 +43,7 @@ describe("grammar-layer resolution", () => {
43
43
  });
44
44
 
45
45
  describe("mode layers own their keys", () => {
46
- test("span mode resolves the span verbs, not the grammar", () => {
46
+ test("span mode resolves the span primitives, not the grammar", () => {
47
47
  // Arrange
48
48
  const resolver = bindings({ spanMode: true });
49
49
 
@@ -224,7 +224,7 @@ describe("plan normal mode", () => {
224
224
  });
225
225
  }
226
226
 
227
- test("resolved sessions guard the mutating verbs", () => {
227
+ test("resolved sessions guard the mutating primitives", () => {
228
228
  // Arrange
229
229
  const resolvedState = state({ resolved: true });
230
230
 
@@ -386,9 +386,9 @@ describe("diff mode", () => {
386
386
  ["p", [{ type: "prevAnnotation" }]],
387
387
  ["backspace", [{ type: "removeAnnotation" }]],
388
388
  ["return", [{ type: "openSubmit" }]],
389
- ["v", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
389
+ ["v", [{ type: "status", message: "plan-only primitive - diff review uses c on a line" }]],
390
390
  ["x", [{ type: "rejectChange" }]],
391
- ["e", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
391
+ ["e", [{ type: "status", message: "plan-only primitive - diff review uses c on a line" }]],
392
392
  ["q", [{ type: "exit" }]],
393
393
  ];
394
394
 
@@ -514,7 +514,7 @@ describe("review panel controls", () => {
514
514
  expect(reduceKey(observer, key("]"))).toEqual([{ type: "resizeReviewPanel", direction: 1 }]);
515
515
  });
516
516
 
517
- test("span mode still owns b as a span verb, not a panel cycle", () => {
517
+ test("span mode still owns b as a span primitive, not a panel cycle", () => {
518
518
  // Assert
519
519
  expect(reduceKey(state({ spanMode: true }), key("b"))).toEqual([
520
520
  { type: "spanKey", name: "b" },
package/src/keymap.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The keyboard grammar as a pure reducer: view state in, intents out.
3
3
  * App builds a KeyState per key event, calls reduceKey, and dispatches the
4
- * intents to controller verbs and view-state setters - no branch bodies live
4
+ * intents to controller primitives and view-state setters - no branch bodies live
5
5
  * in the key handler. Diff and plan reviews share one path for annotation
6
6
  * navigation, deletion, and submit; the read-only rule is one gate here.
7
7
  */
@@ -49,7 +49,14 @@ export type Intent =
49
49
  | { type: "optInAutoClose" }
50
50
  | { type: "dismissCompletion" }
51
51
  | { type: "cycleReviewPanel" }
52
- | { type: "resizeReviewPanel"; direction: -1 | 1 };
52
+ | { type: "resizeReviewPanel"; direction: -1 | 1 }
53
+ | { type: "toggleTree" }
54
+ | { type: "treeMove"; direction: -1 | 1 }
55
+ | { type: "treeGo" }
56
+ | { type: "treeBranch" }
57
+ | { type: "treeLabel" }
58
+ | { type: "treeFork" }
59
+ | { type: "treeForkShare" };
53
60
 
54
61
  export interface KeyInput {
55
62
  name: string;
@@ -63,7 +70,7 @@ export interface KeyState {
63
70
  keys: Record<string, string[]>;
64
71
  readOnly: boolean;
65
72
  /**
66
- * Owner-only verbs a share collaborator lacks (undefined = owner, allowed).
73
+ * Owner-only primitives a share collaborator lacks (undefined = owner, allowed).
67
74
  * A collaborator annotates but cannot edit the plan (cut / $EDITOR runs on
68
75
  * the gateway) or submit an agent verdict (there is no agent on a share).
69
76
  */
@@ -95,7 +102,7 @@ export interface KeyState {
95
102
  cursorAnnotatable: boolean;
96
103
  }
97
104
 
98
- /** Verbs that write session state; an observer never reaches their handlers. */
105
+ /** Primitives that write session state; an observer never reaches their handlers. */
99
106
  const MUTATING_ACTIONS = new Set([
100
107
  "comment",
101
108
  "cut",
@@ -136,7 +143,7 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
136
143
 
137
144
  if (state.readOnly && mutating) return status("observer - read-only");
138
145
 
139
- // share is a session-level verb: it works from any view, owner only
146
+ // share is a session-level primitive: it works from any view, owner only
140
147
  if (action === "share") {
141
148
  if (state.canShare === false) return status("only the plan owner can share");
142
149
 
@@ -305,7 +312,7 @@ function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
305
312
 
306
313
  if (shared) return shared;
307
314
  if (action === "span" || action === "edit") {
308
- return status("plan-only verb - diff review uses c on a line");
315
+ return status("plan-only primitive - diff review uses c on a line");
309
316
  }
310
317
 
311
318
  return [];
@@ -356,7 +363,7 @@ function planGrammar(state: KeyState, action: string | undefined, name: string):
356
363
  // the annotation and edit rewrites the card body in place
357
364
  if (state.hasFocusedAnnotation)
358
365
  return [action === "cut" ? { type: "removeAnnotation" } : { type: "editCard" }];
359
- // editing the plan itself is the owner's verb; a share viewer only annotates,
366
+ // editing the plan itself is the owner's primitive; a share viewer only annotates,
360
367
  // and has no edit affordance, so the key is silent rather than a nag
361
368
  if (state.canEditPlan === false) return [];
362
369
 
@@ -1,5 +1,14 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
- import { SCHEMA_VERSION, type DiffFileContents, type ReviewSession } from "@cueloop/schema";
2
+ import {
3
+ cutBlock,
4
+ parseBlocks,
5
+ restoreBlock,
6
+ SCHEMA_VERSION,
7
+ type DiffFileContents,
8
+ type HunkRejection,
9
+ type ReviewSession,
10
+ } from "@cueloop/schema";
11
+ import { curateDiff } from "@cueloop/daemon/curate";
3
12
  import type { SessionClient } from "@cueloop/daemon/client";
4
13
  import { createReviewController } from "./session-controller";
5
14
 
@@ -45,7 +54,9 @@ const unimplemented = (member: string) => () =>
45
54
  Promise.reject(new Error(`fakeClient does not implement ${member}`));
46
55
 
47
56
  /** A fake client that records the working copy the controller writes. */
48
- function fakeClient(session: ReviewSession, sink: WorkingCopySink): SessionClient {
57
+ function fakeClient(initial: ReviewSession, sink: WorkingCopySink): SessionClient {
58
+ let session = initial;
59
+
49
60
  return {
50
61
  onEvent: () => () => {},
51
62
  subscribe: async () => {},
@@ -58,6 +69,50 @@ function fakeClient(session: ReviewSession, sink: WorkingCopySink): SessionClien
58
69
 
59
70
  return { ...session, workingCopy: content };
60
71
  }),
72
+ // the daemon's block primitives, stood in for with the same pure helpers it uses
73
+ sessionCutBlock: mock(async (_id: string, blockIndex: number) => {
74
+ const working = session.workingCopy ?? session.artifact.content;
75
+ const content = cutBlock(working, parseBlocks(working)[blockIndex]!);
76
+
77
+ sink.workingCopy = content;
78
+ session = { ...session, workingCopy: content };
79
+
80
+ return session;
81
+ }),
82
+ sessionRestoreBlock: mock(async (_id: string, baseBlockIndex: number, line?: number) => {
83
+ const base = session.artifact.content;
84
+ const working = session.workingCopy ?? base;
85
+ const content = restoreBlock(
86
+ base,
87
+ working,
88
+ parseBlocks(base)[baseBlockIndex]!,
89
+ line ?? working.split("\n").length,
90
+ );
91
+
92
+ sink.workingCopy = content;
93
+ session = { ...session, workingCopy: content };
94
+ if (content === undefined) delete session.workingCopy;
95
+
96
+ return session;
97
+ }),
98
+ // the daemon's curation, stood in for: the record carries the decisions and the patch they leave
99
+ sessionCurate: mock(async (_id: string, rejections: HunkRejection[]) => {
100
+ const content = rejections.length
101
+ ? curateDiff(session.artifact.files!, rejections)
102
+ : undefined;
103
+
104
+ sink.workingCopy = content;
105
+ session = { ...session, workingCopy: content };
106
+ if (rejections.length) session.curation = rejections;
107
+ else delete session.curation;
108
+
109
+ return session;
110
+ }),
111
+ sessionNavigate: unimplemented("sessionNavigate"),
112
+ sessionBranch: unimplemented("sessionBranch"),
113
+ sessionSwitch: unimplemented("sessionSwitch"),
114
+ sessionLabel: unimplemented("sessionLabel"),
115
+ sessionFork: unimplemented("sessionFork"),
61
116
  sessionSetViewed: unimplemented("sessionSetViewed"),
62
117
  sessionSetShareId: unimplemented("sessionSetShareId"),
63
118
  sessionMergeShared: unimplemented("sessionMergeShared"),
@@ -211,6 +266,23 @@ describe("diff hunk curation", () => {
211
266
  expect(controller.curationItems().length).toBe(1);
212
267
  });
213
268
 
269
+ test("a second decision before the daemon answers builds on the first", async () => {
270
+ // Arrange
271
+ const { controller, client } = await connected(diffSession(FILES));
272
+
273
+ // Act - reject, then restore, with no answer in between
274
+ controller.toggleRejectChange(4);
275
+ controller.toggleRejectChange(4);
276
+ await tick();
277
+
278
+ // Assert - the second request restores; a stale snapshot would reject again
279
+ expect(client.sessionCurate).toHaveBeenCalledTimes(2);
280
+ expect(client.sessionCurate).toHaveBeenNthCalledWith(1, "ses_diff", [
281
+ { path: "src/store.ts", hunkIndex: 0, changeIndex: 1 },
282
+ ]);
283
+ expect(client.sessionCurate).toHaveBeenNthCalledWith(2, "ses_diff", []);
284
+ });
285
+
214
286
  test("curation is disabled without full file contents", async () => {
215
287
  // Arrange - a legacy diff with no artifact.files
216
288
  const { controller, sink } = await connected(diffSession(undefined));
@@ -272,4 +344,19 @@ describe("plan cut removals", () => {
272
344
  expect(controller.getSnapshot().status).toContain("removal restored");
273
345
  expect(sink.workingCopy).toBeUndefined();
274
346
  });
347
+
348
+ test("a second cut before the daemon answers indexes the already-cut copy", async () => {
349
+ // Arrange - pristine plan: title(0), first(1), second(2)
350
+ const { controller, client, sink } = await connected(planSession());
351
+
352
+ // Act - cut the first paragraph, then the second, with no answer in between
353
+ controller.cut(1);
354
+ controller.cut(2);
355
+ await tick();
356
+
357
+ // Assert - the second paragraph is block 1 once the first is gone
358
+ expect(client.sessionCutBlock).toHaveBeenNthCalledWith(1, "ses_plan", 1);
359
+ expect(client.sessionCutBlock).toHaveBeenNthCalledWith(2, "ses_plan", 1);
360
+ expect(sink.workingCopy).toBe("# Title\n");
361
+ });
275
362
  });