@cueloop/client 0.1.0-alpha.63 → 0.1.0-alpha.65

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 (68) hide show
  1. package/package.json +7 -6
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.export.test.tsx +2 -2
  4. package/src/App.plan-review.test.tsx +76 -247
  5. package/src/App.readonly.test.tsx +80 -35
  6. package/src/App.rendering.test.tsx +5 -6
  7. package/src/App.review-panel.test.tsx +29 -32
  8. package/src/App.submit-confirm.test.tsx +27 -12
  9. package/src/App.test.tsx +73 -49
  10. package/src/App.toast.test.tsx +34 -22
  11. package/src/App.tree.test.tsx +224 -0
  12. package/src/App.tsx +134 -87
  13. package/src/app-screens.tsx +27 -1
  14. package/src/app-view-model.ts +7 -109
  15. package/src/components/AnnotationCard.tsx +2 -2
  16. package/src/components/DiffSheet.scroll.test.tsx +5 -2
  17. package/src/components/DiffSheet.tsx +1 -6
  18. package/src/components/KeybindsDialog.tsx +61 -17
  19. package/src/components/PrototypeSheet.tsx +5 -9
  20. package/src/components/ReviewPanel.stories.tsx +14 -0
  21. package/src/components/ReviewRail.stories.tsx +14 -0
  22. package/src/components/ReviewRail.tsx +15 -6
  23. package/src/components/ThreadView.selection.test.tsx +812 -0
  24. package/src/components/ThreadView.stories.tsx +26 -0
  25. package/src/components/ThreadView.tsx +1620 -0
  26. package/src/components/TreePane.stories.tsx +106 -0
  27. package/src/components/TreePane.tsx +156 -0
  28. package/src/components/__snapshots__/stories.test.tsx.snap +120 -172
  29. package/src/components/primitives/Tabs.tsx +1 -1
  30. package/src/components/story-fixtures.ts +6 -2
  31. package/src/components/story.ts +16 -4
  32. package/src/components/syntax-highlight.ts +10 -2
  33. package/src/components/terminal-pane.test.tsx +1 -1
  34. package/src/components/terminal-pane.ts +7 -1
  35. package/src/config.ts +112 -99
  36. package/src/diff-hunk-curate.ts +4 -104
  37. package/src/editor.ts +5 -1
  38. package/src/ghostty-terminal.test.ts +1 -1
  39. package/src/herdr.ts +10 -2
  40. package/src/index.ts +1 -0
  41. package/src/intent-dispatch.test.ts +235 -55
  42. package/src/intent-dispatch.ts +104 -34
  43. package/src/key-bindings.test.ts +1 -1
  44. package/src/key-bindings.ts +16 -7
  45. package/src/keymap.test.ts +9 -11
  46. package/src/keymap.ts +14 -7
  47. package/src/kitty-image.test.ts +3 -3
  48. package/src/prototype-browser.ts +7 -6
  49. package/src/serve.ts +1 -1
  50. package/src/session-controller.curate.test.ts +106 -4
  51. package/src/session-controller.share.test.ts +220 -70
  52. package/src/session-controller.tree.test.ts +228 -0
  53. package/src/session-controller.ts +370 -94
  54. package/src/share.ts +117 -17
  55. package/src/test-support.test.ts +193 -1
  56. package/src/test-support.ts +69 -1
  57. package/src/theme-presets.ts +9 -2
  58. package/src/thread-chords.test.ts +154 -0
  59. package/src/thread-chords.ts +144 -0
  60. package/src/thread-selection.test.ts +210 -0
  61. package/src/thread-selection.ts +243 -0
  62. package/src/tree-view.test.ts +128 -0
  63. package/src/tree-view.ts +148 -0
  64. package/src/view-diff.ts +1 -4
  65. package/src/view-plan.ts +35 -19
  66. package/src/components/PlanSheet.stories.tsx +0 -113
  67. package/src/components/PlanSheet.tsx +0 -422
  68. package/src/components/plan-sheet-run-style.ts +0 -90
@@ -6,54 +6,114 @@
6
6
  */
7
7
 
8
8
  import { describe, expect, mock, test } from "bun:test";
9
- import type { ReviewSession } from "@cueloop/schema";
9
+ import type { SetStateAction } from "react";
10
+ import { SCHEMA_VERSION, type Annotation, type ReviewSession } from "@cueloop/schema";
10
11
  import { createIntentDispatch, type IntentDispatchDeps } from "./intent-dispatch";
12
+ import type { ControllerSnapshot, CurationItem, ReviewController } from "./session-controller";
11
13
  import type { DisplayBlock } from "./view-plan";
14
+ import type { TreeRow } from "./tree-view";
12
15
 
13
16
  function block(text: string): DisplayBlock {
14
- return { text, kind: "text", work: true } as unknown as DisplayBlock;
17
+ return { type: "same", kind: "p", work: { kind: "p", text, lineStart: 0, lineEnd: 0 } };
15
18
  }
16
19
 
17
- function sessionWith(annotationIds: string[]): ReviewSession {
20
+ function annotation(id: string, overrides: Partial<Annotation> = {}): Annotation {
18
21
  return {
19
- annotations: annotationIds.map((id) => ({ id })),
20
- workingCopy: undefined,
21
- } as unknown as ReviewSession;
22
+ id,
23
+ kind: "comment",
24
+ anchor: { quote: "", prefix: "", suffix: "" },
25
+ body: "",
26
+ createdAt: "2026-01-01T00:00:00.000Z",
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ function sessionWith(
32
+ annotations: Annotation[],
33
+ overrides: Partial<ReviewSession> = {},
34
+ ): ReviewSession {
35
+ return {
36
+ schemaVersion: SCHEMA_VERSION,
37
+ id: "ses_1",
38
+ workspace: { repoRoot: "/repo", branch: "main" },
39
+ artifact: { type: "plan", content: "# Plan\n", meta: {} },
40
+ revisions: [],
41
+ annotations,
42
+ verdict: null,
43
+ status: "pending",
44
+ createdAt: "2026-01-01T00:00:00.000Z",
45
+ ...overrides,
46
+ };
22
47
  }
23
48
 
24
- /** A controller where every verb is a mock; annotate returns undefined by default. */
25
- function baseController() {
49
+ const EMPTY_SNAPSHOT: ControllerSnapshot = {
50
+ session: null,
51
+ inbox: null,
52
+ status: "",
53
+ toast: null,
54
+ error: null,
55
+ completion: { phase: "idle" },
56
+ editOrphanCount: 0,
57
+ walk: null,
58
+ };
59
+
60
+ /** A controller where every primitive is a mock; annotate returns undefined by default. */
61
+ function baseController(): ReviewController {
26
62
  return {
63
+ readOnly: false,
64
+ subscribe: mock(() => () => {}),
65
+ getSnapshot: mock(() => EMPTY_SNAPSHOT),
66
+ connect: mock(),
67
+ close: mock(),
68
+ applyConfig: mock(),
27
69
  setStatus: mock(),
70
+ showToast: mock(),
71
+ dismissToast: mock(),
72
+ display: mock(() => []),
73
+ rows: mock(() => []),
74
+ files: mock(() => []),
75
+ working: mock(() => ""),
28
76
  open: mock(),
29
77
  deleteSession: mock(),
30
78
  setSelfName: mock(),
31
79
  cut: mock(),
80
+ toggleRejectHunk: mock(),
81
+ toggleRejectChange: mock(),
82
+ rejectedRows: mock(() => new Set<number>()),
32
83
  curationItems: mock(() => []),
33
84
  restoreCuration: mock(),
34
- annotate: mock(),
35
- annotatePrototype: mock(),
85
+ edit: mock(),
86
+ annotate: mock(() => undefined),
87
+ reply: mock(() => undefined),
88
+ annotatePrototype: mock(() => undefined),
36
89
  updateAnnotation: mock(),
37
90
  removeAnnotation: mock(),
91
+ setWorkingCopy: mock(),
38
92
  walkStart: mock(),
39
93
  walkForward: mock(),
40
94
  walkBack: mock(),
41
95
  walkLeave: mock(),
42
96
  submit: mock(),
43
97
  share: mock(),
98
+ treeRows: mock(() => []),
99
+ goToEntry: mock(),
100
+ branch: mock(),
101
+ labelTip: mock(),
102
+ fork: mock(),
103
+ forkAndShare: mock(),
104
+ pullShared: mock(() => Promise.resolve()),
105
+ startShareSync: mock(() => () => {}),
44
106
  finishReview: mock(),
45
- optInAutoClose: mock(),
46
107
  dismissCompletion: mock(),
108
+ optInAutoClose: mock(),
47
109
  saveReviewPanel: mock(),
48
110
  };
49
111
  }
50
112
 
51
113
  /** A deps bag where every effect is a mock and every read has a plain default. */
52
114
  function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDeps {
53
- const controller = baseController();
54
-
55
115
  return {
56
- controller: controller as unknown as IntentDispatchDeps["controller"],
116
+ controller: baseController(),
57
117
  onExit: mock(),
58
118
  isDiff: false,
59
119
  display: [],
@@ -68,18 +128,20 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
68
128
  terminalWidth: 120,
69
129
  focusedAnnotationId: undefined,
70
130
  selectedCurationId: undefined,
131
+ railTab: "review",
132
+ selectedEntryId: undefined,
71
133
  authorNames: {},
72
134
  quickActions: [],
73
135
  renameAuthor: mock(),
74
136
  liveInput: { current: "" },
75
137
  reviewWidthRef: { current: 34 },
76
- planSheetRef: { current: null },
77
138
  setCursor: mock(),
78
139
  setInboxCursor: mock(),
79
140
  setMode: mock(),
80
141
  setReviewMode: mock(),
81
142
  setReviewWidth: mock(),
82
143
  setRailTab: mock(),
144
+ setSelectedEntryId: mock(),
83
145
  setFocusedAnnotationId: mock(),
84
146
  setSelectedCurationId: mock(),
85
147
  setPulsedAnnotationId: mock(),
@@ -90,20 +152,24 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
90
152
  };
91
153
  }
92
154
 
155
+ function isUpdater(action: SetStateAction<number>): action is (current: number) => number {
156
+ return typeof action === "function";
157
+ }
158
+
93
159
  describe("move", () => {
94
160
  test("down clamps at the last row and up clamps at the first", () => {
95
161
  // Arrange
96
- const deps = makeDeps({ display: [block("a"), block("b"), block("c")] });
162
+ const setCursor = mock((_action: SetStateAction<number>) => {});
163
+ const deps = makeDeps({ display: [block("a"), block("b"), block("c")], setCursor });
97
164
  const dispatch = createIntentDispatch(deps);
98
165
 
99
166
  // Act
100
167
  dispatch({ type: "move", to: "down" });
101
168
 
102
169
  // Assert
103
- const advance = (deps.setCursor as ReturnType<typeof mock>).mock.calls[0]![0] as (
104
- current: number,
105
- ) => number;
170
+ const advance = setCursor.mock.calls[0]![0];
106
171
 
172
+ if (!isUpdater(advance)) throw new Error("expected setCursor to receive an updater");
107
173
  expect(advance(2)).toBe(2); // already at the end, stays
108
174
  expect(advance(0)).toBe(1);
109
175
  });
@@ -124,7 +190,10 @@ describe("move", () => {
124
190
  describe("annotation navigation", () => {
125
191
  test("nextAnnotation wraps from the last annotation back to the first", () => {
126
192
  // Arrange
127
- const deps = makeDeps({ session: sessionWith(["a1", "a2", "a3"]), focusedAnnotationId: "a3" });
193
+ const deps = makeDeps({
194
+ session: sessionWith([annotation("a1"), annotation("a2"), annotation("a3")]),
195
+ focusedAnnotationId: "a3",
196
+ });
128
197
  const dispatch = createIntentDispatch(deps);
129
198
 
130
199
  // Act
@@ -136,7 +205,10 @@ describe("annotation navigation", () => {
136
205
 
137
206
  test("prevAnnotation wraps from the first annotation to the last", () => {
138
207
  // Arrange
139
- const deps = makeDeps({ session: sessionWith(["a1", "a2", "a3"]), focusedAnnotationId: "a1" });
208
+ const deps = makeDeps({
209
+ session: sessionWith([annotation("a1"), annotation("a2"), annotation("a3")]),
210
+ focusedAnnotationId: "a1",
211
+ });
140
212
  const dispatch = createIntentDispatch(deps);
141
213
 
142
214
  // Act
@@ -148,12 +220,11 @@ describe("annotation navigation", () => {
148
220
 
149
221
  test("cycling skips annotations a revision already addressed", () => {
150
222
  // Arrange: a2 is addressed, so next from a1 lands on a3
151
- const session = sessionWith(["a1", "a2", "a3"]);
152
-
153
- (session.annotations[1] as { resolution?: object }).resolution = {
154
- revision: 2,
155
- source: "agent",
156
- };
223
+ const session = sessionWith([
224
+ annotation("a1"),
225
+ annotation("a2", { resolution: { revision: 2, source: "agent" } }),
226
+ annotation("a3"),
227
+ ]);
157
228
  const deps = makeDeps({ session, focusedAnnotationId: "a1" });
158
229
  const dispatch = createIntentDispatch(deps);
159
230
 
@@ -233,7 +304,7 @@ describe("marker-actions popover", () => {
233
304
  mode: { type: "spanActions", span, index: 1 },
234
305
  session: sessionWith([]),
235
306
  quickActions: [{ prompt: "Needs a test" }, { prompt: "YAGNI", metadata: "cut scope" }],
236
- controller: { ...baseController(), annotate } as unknown as IntentDispatchDeps["controller"],
307
+ controller: { ...baseController(), annotate },
237
308
  });
238
309
  const dispatch = createIntentDispatch(deps);
239
310
 
@@ -253,7 +324,7 @@ describe("marker-actions popover", () => {
253
324
  mode: { type: "spanActions", span, index: 0 },
254
325
  session: sessionWith([]),
255
326
  quickActions: [{ prompt: "Needs a test" }, { prompt: "Extract the duplication" }],
256
- controller: { ...baseController(), annotate } as unknown as IntentDispatchDeps["controller"],
327
+ controller: { ...baseController(), annotate },
257
328
  });
258
329
  const dispatch = createIntentDispatch(deps);
259
330
 
@@ -320,28 +391,15 @@ describe("share", () => {
320
391
  });
321
392
 
322
393
  describe("restoreCuration", () => {
323
- const items = [
324
- {
325
- id: "diff:f#0#hunk",
326
- source: "diff" as const,
327
- label: "f:1 - hunk",
328
- preview: [],
329
- revealIndex: 1,
330
- },
331
- {
332
- id: "diff:f#1#2",
333
- source: "diff" as const,
334
- label: "f:9 - change",
335
- preview: [],
336
- revealIndex: 9,
337
- },
394
+ const items: CurationItem[] = [
395
+ { id: "diff:f#0#hunk", source: "diff", preview: [], revealIndex: 1 },
396
+ { id: "diff:f#1#2", source: "diff", preview: [], revealIndex: 9 },
338
397
  ];
339
398
 
340
399
  test("restores the selected item and clears the selection", () => {
341
400
  // Arrange
342
- const deps = makeDeps({ selectedCurationId: "diff:f#1#2" });
343
-
344
- (deps.controller.curationItems as ReturnType<typeof mock>).mockReturnValue(items);
401
+ const controller = { ...baseController(), curationItems: mock(() => items) };
402
+ const deps = makeDeps({ selectedCurationId: "diff:f#1#2", controller });
345
403
  const dispatch = createIntentDispatch(deps);
346
404
 
347
405
  // Act
@@ -354,9 +412,8 @@ describe("restoreCuration", () => {
354
412
 
355
413
  test("with nothing selected, undoes the last removal", () => {
356
414
  // Arrange
357
- const deps = makeDeps({ selectedCurationId: undefined });
358
-
359
- (deps.controller.curationItems as ReturnType<typeof mock>).mockReturnValue(items);
415
+ const controller = { ...baseController(), curationItems: mock(() => items) };
416
+ const deps = makeDeps({ selectedCurationId: undefined, controller });
360
417
  const dispatch = createIntentDispatch(deps);
361
418
 
362
419
  // Act
@@ -369,8 +426,6 @@ describe("restoreCuration", () => {
369
426
  test("does nothing when there is nothing curated out", () => {
370
427
  // Arrange
371
428
  const deps = makeDeps();
372
-
373
- (deps.controller.curationItems as ReturnType<typeof mock>).mockReturnValue([]);
374
429
  const dispatch = createIntentDispatch(deps);
375
430
 
376
431
  // Act
@@ -398,7 +453,9 @@ describe("exit", () => {
398
453
  describe("inbox delete", () => {
399
454
  test("requestDeleteSession opens the confirm on the cursor row", () => {
400
455
  // Arrange
401
- const inbox = [{ id: "ses_1", artifact: { meta: { title: "Plan A" } } }] as never;
456
+ const inbox = [
457
+ sessionWith([], { artifact: { type: "plan", content: "", meta: { title: "Plan A" } } }),
458
+ ];
402
459
  const deps = makeDeps({ inbox, inboxCursor: 0 });
403
460
 
404
461
  // Act
@@ -428,7 +485,7 @@ describe("inbox delete", () => {
428
485
  describe("rename author", () => {
429
486
  test("openRename seeds the prompt with the author's current local name", () => {
430
487
  // Arrange
431
- const session = { annotations: [{ id: "a1", author: "SHA256:x" }] } as never;
488
+ const session = sessionWith([annotation("a1", { author: "SHA256:x" })]);
432
489
  const deps = makeDeps({
433
490
  session,
434
491
  focusedAnnotationId: "a1",
@@ -448,7 +505,7 @@ describe("rename author", () => {
448
505
 
449
506
  test("openRename on your own note does nothing but explain", () => {
450
507
  // Arrange
451
- const session = { annotations: [{ id: "a1" }] } as never;
508
+ const session = sessionWith([annotation("a1")]);
452
509
  const deps = makeDeps({ session, focusedAnnotationId: "a1" });
453
510
 
454
511
  // Act
@@ -497,3 +554,126 @@ describe("collaborator self-name", () => {
497
554
  expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
498
555
  });
499
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,9 +527,71 @@ function handleResizeReviewPanel(
526
527
  deps.controller.saveReviewPanel({ width: next });
527
528
  }
528
529
 
529
- const intentHandlers: {
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
+
590
+ type IntentHandlers = {
530
591
  [Kind in Intent["type"]]: (intent: IntentOfType<Kind>, deps: IntentDispatchDeps) => void;
531
- } = {
592
+ };
593
+
594
+ const intentHandlers: IntentHandlers = {
532
595
  exit: handleExit,
533
596
  status: handleStatus,
534
597
  move: handleMove,
@@ -570,16 +633,23 @@ const intentHandlers: {
570
633
  dismissCompletion: handleDismissCompletion,
571
634
  cycleReviewPanel: handleCycleReviewPanel,
572
635
  resizeReviewPanel: handleResizeReviewPanel,
636
+ toggleTree: handleToggleTree,
637
+ treeMove: handleTreeMove,
638
+ treeGo: handleTreeGo,
639
+ treeBranch: handleTreeBranch,
640
+ treeLabel: handleTreeLabel,
641
+ treeFork: handleTreeFork,
642
+ treeForkShare: handleTreeForkShare,
573
643
  };
574
644
 
645
+ function dispatchIntent<Kind extends Intent["type"]>(
646
+ intent: IntentOfType<Kind>,
647
+ deps: IntentDispatchDeps,
648
+ ): void {
649
+ intentHandlers[intent.type](intent, deps);
650
+ }
651
+
575
652
  /** Build the intent handler for one render from its dependency bag. */
576
653
  export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent) => void {
577
- return (intent: Intent): void => {
578
- const handler = intentHandlers[intent.type] as (
579
- intent: Intent,
580
- deps: IntentDispatchDeps,
581
- ) => void;
582
-
583
- handler(intent, deps);
584
- };
654
+ return (intent: Intent): void => dispatchIntent(intent, deps);
585
655
  }
@@ -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