@cueloop/client 0.1.0-alpha.3 → 0.1.0-alpha.30

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 (105) hide show
  1. package/package.json +17 -11
  2. package/src/App.autoclose.test.tsx +133 -0
  3. package/src/App.diff.test.tsx +27 -20
  4. package/src/App.export.test.tsx +19 -28
  5. package/src/App.plan-review.test.tsx +507 -0
  6. package/src/App.readonly.test.tsx +26 -27
  7. package/src/App.rendering.test.tsx +119 -0
  8. package/src/App.review-panel.test.tsx +123 -0
  9. package/src/App.submit-confirm.test.tsx +216 -0
  10. package/src/App.test.tsx +83 -64
  11. package/src/App.tsx +0 -0
  12. package/src/App.walk.test.tsx +265 -0
  13. package/src/attribution.test.ts +31 -0
  14. package/src/attribution.ts +32 -0
  15. package/src/clipboard.test.ts +23 -0
  16. package/src/clipboard.ts +26 -0
  17. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  18. package/src/components/AnnotationCard.stories.tsx +75 -0
  19. package/src/components/AnnotationCard.tsx +208 -0
  20. package/src/components/Breadcrumb.stories.tsx +19 -0
  21. package/src/components/Breadcrumb.tsx +38 -0
  22. package/src/components/CodeBlock.stories.tsx +25 -0
  23. package/src/components/CodeBlock.tsx +68 -0
  24. package/src/components/CompletionOverlay.stories.tsx +23 -0
  25. package/src/components/CompletionOverlay.tsx +49 -0
  26. package/src/components/ComposeBar.stories.tsx +18 -0
  27. package/src/components/ComposeBar.tsx +54 -0
  28. package/src/components/ConfirmCard.stories.tsx +37 -0
  29. package/src/components/ConfirmCard.tsx +117 -0
  30. package/src/components/ConfirmDialog.stories.tsx +21 -0
  31. package/src/components/ConfirmDialog.tsx +56 -0
  32. package/src/components/DiffSheet.stories.tsx +44 -0
  33. package/src/components/DiffSheet.tsx +243 -0
  34. package/src/components/InboxList.stories.tsx +18 -0
  35. package/src/components/InboxList.tsx +55 -0
  36. package/src/components/PlanSheet.stories.tsx +81 -0
  37. package/src/components/PlanSheet.tsx +329 -0
  38. package/src/components/PromptDialog.stories.tsx +36 -0
  39. package/src/components/PromptDialog.tsx +62 -0
  40. package/src/components/ReviewPanel.stories.tsx +87 -0
  41. package/src/components/ReviewPanel.tsx +116 -0
  42. package/src/components/ReviewRail.stories.tsx +107 -0
  43. package/src/components/ReviewRail.tsx +180 -0
  44. package/src/components/SettingsDialog.stories.tsx +63 -0
  45. package/src/components/SettingsDialog.tsx +132 -0
  46. package/src/components/SettingsRows.stories.tsx +18 -0
  47. package/src/components/SettingsRows.tsx +92 -0
  48. package/src/components/WalkWizard.stories.tsx +89 -0
  49. package/src/components/WalkWizard.tsx +146 -0
  50. package/src/components/__snapshots__/stories.test.tsx.snap +1519 -0
  51. package/src/components/primitives/Button.stories.tsx +32 -0
  52. package/src/components/primitives/Button.tsx +44 -0
  53. package/src/components/primitives/Card.stories.tsx +25 -0
  54. package/src/components/primitives/Card.tsx +59 -0
  55. package/src/components/primitives/Dialog.stories.tsx +24 -0
  56. package/src/components/primitives/Dialog.tsx +53 -0
  57. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  58. package/src/components/primitives/StatusBar.tsx +22 -0
  59. package/src/components/primitives/Tabs.stories.tsx +29 -0
  60. package/src/components/primitives/Tabs.tsx +97 -0
  61. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  62. package/src/components/primitives/Toolbar.tsx +14 -0
  63. package/src/components/primitives/frame.test.ts +12 -0
  64. package/src/components/primitives/frame.ts +14 -0
  65. package/src/components/stories-app.tsx +95 -0
  66. package/src/components/stories.test.tsx +56 -0
  67. package/src/components/story-fixtures.ts +90 -0
  68. package/src/components/story.ts +66 -0
  69. package/src/components/syntax-highlight.ts +57 -0
  70. package/src/components/theme-context.stories.tsx +41 -0
  71. package/src/components/theme-context.tsx +30 -0
  72. package/src/components/truncate-text.ts +6 -0
  73. package/src/config.test.ts +196 -30
  74. package/src/config.ts +128 -16
  75. package/src/editor.test.ts +134 -0
  76. package/src/editor.ts +101 -13
  77. package/src/herdr.test.ts +41 -0
  78. package/src/herdr.ts +21 -0
  79. package/src/index.ts +2 -0
  80. package/src/integrations.test.ts +41 -0
  81. package/src/integrations.ts +28 -0
  82. package/src/intent-dispatch.test.ts +308 -0
  83. package/src/intent-dispatch.ts +295 -0
  84. package/src/key-bindings.test.ts +142 -0
  85. package/src/key-bindings.ts +363 -0
  86. package/src/keymap.test.ts +477 -0
  87. package/src/keymap.ts +272 -0
  88. package/src/review-panel.test.ts +111 -0
  89. package/src/review-panel.ts +76 -0
  90. package/src/run.ts +9 -5
  91. package/src/serve.test.ts +13 -6
  92. package/src/serve.ts +7 -7
  93. package/src/session-controller.share.test.ts +248 -0
  94. package/src/session-controller.ts +607 -0
  95. package/src/share.ts +80 -0
  96. package/src/test-support.ts +87 -0
  97. package/src/theme.ts +23 -1
  98. package/src/view-diff.test.ts +19 -8
  99. package/src/view-diff.ts +22 -22
  100. package/src/view-plan.test.ts +260 -0
  101. package/src/view-plan.ts +365 -0
  102. package/src/walk.test.ts +116 -0
  103. package/src/walk.ts +65 -0
  104. package/src/view.test.ts +0 -157
  105. package/src/view.ts +0 -0
@@ -0,0 +1,308 @@
1
+ /**
2
+ * The intent reducer in isolation: with the App component's dependencies
3
+ * mocked, each Intent maps to exactly the controller call or state update it
4
+ * should - the navigation clamps, the annotation wrap-around, the verdict
5
+ * cycle, and the submit-reveal fix, none of which needed a rendered TUI.
6
+ */
7
+
8
+ import { describe, expect, mock, test } from "bun:test";
9
+ import type { ReviewSession } from "@cueloop/schema";
10
+ import { createIntentDispatch, type IntentDispatchDeps } from "./intent-dispatch";
11
+ import type { DisplayBlock } from "./view-plan";
12
+
13
+ function block(text: string): DisplayBlock {
14
+ return { text, kind: "text", work: true } as unknown as DisplayBlock;
15
+ }
16
+
17
+ function sessionWith(annotationIds: string[]): ReviewSession {
18
+ return {
19
+ annotations: annotationIds.map((id) => ({ id })),
20
+ workingCopy: undefined,
21
+ } as unknown as ReviewSession;
22
+ }
23
+
24
+ /** A deps bag where every effect is a mock and every read has a plain default. */
25
+ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDeps {
26
+ const controller = {
27
+ setStatus: mock(),
28
+ open: mock(),
29
+ deleteSession: mock(),
30
+ setSelfName: mock(),
31
+ cut: mock(),
32
+ annotate: mock(),
33
+ updateAnnotation: mock(),
34
+ removeAnnotation: mock(),
35
+ walkStart: mock(),
36
+ walkForward: mock(),
37
+ walkBack: mock(),
38
+ walkLeave: mock(),
39
+ submit: mock(),
40
+ share: mock(),
41
+ finishReview: mock(),
42
+ optInAutoClose: mock(),
43
+ dismissCompletion: mock(),
44
+ saveReviewPanel: mock(),
45
+ };
46
+ return {
47
+ controller: controller as unknown as IntentDispatchDeps["controller"],
48
+ onExit: mock(),
49
+ isDiff: false,
50
+ display: [],
51
+ rows: [],
52
+ cursor: 0,
53
+ inbox: null,
54
+ inboxCursor: 0,
55
+ mode: { type: "normal" },
56
+ session: null,
57
+ reviewMode: "expanded",
58
+ reviewWidth: 34,
59
+ terminalWidth: 120,
60
+ focusedAnnotationId: undefined,
61
+ authorNames: {},
62
+ renameAuthor: mock(),
63
+ liveInput: { current: "" },
64
+ reviewWidthRef: { current: 34 },
65
+ planSheetRef: { current: null },
66
+ setCursor: mock(),
67
+ setInboxCursor: mock(),
68
+ setMode: mock(),
69
+ setReviewMode: mock(),
70
+ setReviewWidth: mock(),
71
+ setRailTab: mock(),
72
+ setFocusedAnnotationId: mock(),
73
+ setPulsedAnnotationId: mock(),
74
+ selectCardFromDocument: mock(),
75
+ runEditorHandOff: mock(),
76
+ openCardEdit: mock(),
77
+ ...overrides,
78
+ };
79
+ }
80
+
81
+ describe("move", () => {
82
+ test("down clamps at the last row and up clamps at the first", () => {
83
+ // Arrange
84
+ const deps = makeDeps({ display: [block("a"), block("b"), block("c")] });
85
+ const dispatch = createIntentDispatch(deps);
86
+
87
+ // Act
88
+ dispatch({ type: "move", to: "down" });
89
+
90
+ // Assert
91
+ const advance = (deps.setCursor as ReturnType<typeof mock>).mock.calls[0]![0] as (current: number) => number;
92
+ expect(advance(2)).toBe(2); // already at the end, stays
93
+ expect(advance(0)).toBe(1);
94
+ });
95
+
96
+ test("bottom jumps to the last navigable row", () => {
97
+ // Arrange
98
+ const deps = makeDeps({ display: [block("a"), block("b"), block("c")] });
99
+ const dispatch = createIntentDispatch(deps);
100
+
101
+ // Act
102
+ dispatch({ type: "move", to: "bottom" });
103
+
104
+ // Assert
105
+ expect(deps.setCursor).toHaveBeenCalledWith(2);
106
+ });
107
+ });
108
+
109
+ describe("annotation navigation", () => {
110
+ test("nextAnnotation wraps from the last annotation back to the first", () => {
111
+ // Arrange
112
+ const deps = makeDeps({ session: sessionWith(["a1", "a2", "a3"]), focusedAnnotationId: "a3" });
113
+ const dispatch = createIntentDispatch(deps);
114
+
115
+ // Act
116
+ dispatch({ type: "nextAnnotation" });
117
+
118
+ // Assert
119
+ expect(deps.selectCardFromDocument).toHaveBeenCalledWith("a1");
120
+ });
121
+
122
+ test("prevAnnotation wraps from the first annotation to the last", () => {
123
+ // Arrange
124
+ const deps = makeDeps({ session: sessionWith(["a1", "a2", "a3"]), focusedAnnotationId: "a1" });
125
+ const dispatch = createIntentDispatch(deps);
126
+
127
+ // Act
128
+ dispatch({ type: "prevAnnotation" });
129
+
130
+ // Assert
131
+ expect(deps.selectCardFromDocument).toHaveBeenCalledWith("a3");
132
+ });
133
+
134
+ test("cycling skips annotations a revision already addressed", () => {
135
+ // Arrange: a2 is addressed, so next from a1 lands on a3
136
+ const session = sessionWith(["a1", "a2", "a3"]);
137
+ (session.annotations[1] as { resolution?: object }).resolution = { revision: 2, source: "agent" };
138
+ const deps = makeDeps({ session, focusedAnnotationId: "a1" });
139
+ const dispatch = createIntentDispatch(deps);
140
+
141
+ // Act
142
+ dispatch({ type: "nextAnnotation" });
143
+
144
+ // Assert
145
+ expect(deps.selectCardFromDocument).toHaveBeenCalledWith("a3");
146
+ });
147
+ });
148
+
149
+ describe("cycleVerdict", () => {
150
+ test("advances through the verdict list in the submit overlay", () => {
151
+ // Arrange
152
+ const deps = makeDeps({ mode: { type: "submit", verdict: "approve", summary: "" } });
153
+ const dispatch = createIntentDispatch(deps);
154
+
155
+ // Act
156
+ dispatch({ type: "cycleVerdict", direction: 1 });
157
+
158
+ // Assert
159
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "submit", verdict: "request_changes", summary: "" });
160
+ });
161
+ });
162
+
163
+ describe("openSubmit", () => {
164
+ test("force-opens the review rail so the confirm card can never be hidden", () => {
165
+ // Arrange
166
+ const deps = makeDeps({ session: sessionWith([]), reviewMode: "hidden" });
167
+ const dispatch = createIntentDispatch(deps);
168
+
169
+ // Act
170
+ dispatch({ type: "openSubmit" });
171
+
172
+ // Assert
173
+ expect(deps.setReviewMode).toHaveBeenCalledWith("expanded");
174
+ expect(deps.setRailTab).toHaveBeenCalledWith("review");
175
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "submit", verdict: "approve", summary: "" });
176
+ });
177
+
178
+ test("does nothing without a session", () => {
179
+ // Arrange
180
+ const deps = makeDeps({ session: null });
181
+ const dispatch = createIntentDispatch(deps);
182
+
183
+ // Act
184
+ dispatch({ type: "openSubmit" });
185
+
186
+ // Assert
187
+ expect(deps.setMode).not.toHaveBeenCalled();
188
+ });
189
+ });
190
+
191
+ describe("share", () => {
192
+ test("dispatches to the controller's share", () => {
193
+ // Arrange
194
+ const deps = makeDeps();
195
+ const dispatch = createIntentDispatch(deps);
196
+
197
+ // Act
198
+ dispatch({ type: "share" });
199
+
200
+ // Assert
201
+ expect(deps.controller.share).toHaveBeenCalled();
202
+ });
203
+ });
204
+
205
+ describe("exit", () => {
206
+ test("calls the exit hook with 0", () => {
207
+ // Arrange
208
+ const deps = makeDeps();
209
+ const dispatch = createIntentDispatch(deps);
210
+
211
+ // Act
212
+ dispatch({ type: "exit" });
213
+
214
+ // Assert
215
+ expect(deps.onExit).toHaveBeenCalledWith(0);
216
+ });
217
+ });
218
+
219
+ describe("inbox delete", () => {
220
+ test("requestDeleteSession opens the confirm on the cursor row", () => {
221
+ // Arrange
222
+ const inbox = [{ id: "ses_1", artifact: { meta: { title: "Plan A" } } }] as never;
223
+ const deps = makeDeps({ inbox, inboxCursor: 0 });
224
+
225
+ // Act
226
+ createIntentDispatch(deps)({ type: "requestDeleteSession" });
227
+
228
+ // Assert
229
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "confirmDelete", sessionId: "ses_1", title: "Plan A" });
230
+ });
231
+
232
+ test("confirmDialog deletes the session and closes", () => {
233
+ // Arrange
234
+ const deps = makeDeps({ mode: { type: "confirmDelete", sessionId: "ses_1", title: "Plan A" } });
235
+
236
+ // Act
237
+ createIntentDispatch(deps)({ type: "confirmDialog" });
238
+
239
+ // Assert
240
+ expect(deps.controller.deleteSession).toHaveBeenCalledWith("ses_1");
241
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
242
+ });
243
+ });
244
+
245
+ describe("rename author", () => {
246
+ test("openRename seeds the prompt with the author's current local name", () => {
247
+ // Arrange
248
+ const session = { annotations: [{ id: "a1", author: "SHA256:x" }] } as never;
249
+ const deps = makeDeps({ session, focusedAnnotationId: "a1", authorNames: { "SHA256:x": "Alex" } });
250
+
251
+ // Act
252
+ createIntentDispatch(deps)({ type: "openRename" });
253
+
254
+ // Assert
255
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "rename", authorId: "SHA256:x", text: "Alex" });
256
+ });
257
+
258
+ test("openRename on your own note does nothing but explain", () => {
259
+ // Arrange
260
+ const session = { annotations: [{ id: "a1" }] } as never;
261
+ const deps = makeDeps({ session, focusedAnnotationId: "a1" });
262
+
263
+ // Act
264
+ createIntentDispatch(deps)({ type: "openRename" });
265
+
266
+ // Assert
267
+ expect(deps.controller.setStatus).toHaveBeenCalled();
268
+ expect(deps.setMode).not.toHaveBeenCalled();
269
+ });
270
+
271
+ test("confirmDialog in rename mode persists the trimmed name and closes", () => {
272
+ // Arrange
273
+ const deps = makeDeps({ mode: { type: "rename", authorId: "SHA256:x", text: " Alex " } });
274
+
275
+ // Act
276
+ createIntentDispatch(deps)({ type: "confirmDialog" });
277
+
278
+ // Assert
279
+ expect(deps.renameAuthor).toHaveBeenCalledWith("SHA256:x", "Alex");
280
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
281
+ });
282
+ });
283
+
284
+ describe("collaborator self-name", () => {
285
+ test("confirmDialog in nameSelf mode records the trimmed name and closes", () => {
286
+ // Arrange
287
+ const deps = makeDeps({ mode: { type: "nameSelf", text: " Robin " } });
288
+
289
+ // Act
290
+ createIntentDispatch(deps)({ type: "confirmDialog" });
291
+
292
+ // Assert
293
+ expect(deps.controller.setSelfName).toHaveBeenCalledWith("Robin");
294
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
295
+ });
296
+
297
+ test("an empty name still closes - the collaborator stays anonymous", () => {
298
+ // Arrange
299
+ const deps = makeDeps({ mode: { type: "nameSelf", text: " " } });
300
+
301
+ // Act
302
+ createIntentDispatch(deps)({ type: "confirmDialog" });
303
+
304
+ // Assert
305
+ expect(deps.controller.setSelfName).toHaveBeenCalledWith("");
306
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
307
+ });
308
+ });
@@ -0,0 +1,295 @@
1
+ /**
2
+ * The intent reducer: one effectful handler per keymap Intent, lifted out of
3
+ * the App component so the 30-case grammar can be read and tested on its own.
4
+ * keymap.ts turns keys into Intents (pure); this turns an Intent into the
5
+ * matching controller call or view-state update. App builds the dependency
6
+ * bag once per render and hands it here; nothing in this module reaches back
7
+ * into React beyond the setters it is given.
8
+ */
9
+
10
+ import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from "react";
11
+ import { isAddressed, isAgentNote, type ReviewSession, type VerdictKind } from "@cueloop/schema";
12
+ import { displayText, spanKey, startSpan, type DisplayBlock, type SpanState } from "./view-plan";
13
+ import type { DiffRow } from "./view-diff";
14
+ import type { ReviewController } from "./session-controller";
15
+ import type { Intent } from "./keymap";
16
+ import type { PlanSheetHandle } from "./components/PlanSheet";
17
+ import { VERDICTS } from "./components/ConfirmCard";
18
+ import { REVIEW_RESIZE_STEP, cycleReviewPanelMode, resolveReviewWidth, type ReviewPanelMode } from "./review-panel";
19
+
20
+ /** The one overlay/mode the TUI is in; every compose/submit/edit flow is a variant. */
21
+ export type Mode =
22
+ | { type: "normal" }
23
+ | { type: "span"; span: SpanState }
24
+ | { type: "compose"; kind: "comment" | "suggestion"; displayIndex: number; start: number; end: number; text: string }
25
+ | { type: "railEdit"; id: string; text: string }
26
+ | { type: "submit"; verdict: VerdictKind; summary: string }
27
+ | { type: "confirmDelete"; sessionId: string; title: string }
28
+ | { type: "rename"; authorId: string; text: string }
29
+ | { type: "nameSelf"; text: string };
30
+
31
+ /**
32
+ * Annotations that still count as feedback: agent notes never do, and an
33
+ * annotation a revision already addressed is settled - it neither blocks the
34
+ * verdict default nor re-enters the next feedback document.
35
+ */
36
+ export function reviewerAnnotations(session: ReviewSession) {
37
+ return session.annotations.filter((annotation) => !isAgentNote(annotation) && !isAddressed(annotation));
38
+ }
39
+
40
+ export function defaultVerdict(session: ReviewSession): VerdictKind {
41
+ return reviewerAnnotations(session).length || session.workingCopy !== undefined ? "request_changes" : "approve";
42
+ }
43
+
44
+ /**
45
+ * Everything the reducer reads or writes, supplied fresh each render: the
46
+ * controller and exit hook, the derived reads (view rows, cursor, mode,
47
+ * session), the refs it pokes, the state setters, and the three App-owned
48
+ * selection helpers it composes.
49
+ */
50
+ export interface IntentDispatchDeps {
51
+ controller: ReviewController;
52
+ onExit?: (code: number) => void;
53
+
54
+ isDiff: boolean;
55
+ display: DisplayBlock[];
56
+ rows: DiffRow[];
57
+ cursor: number;
58
+ inbox: ReviewSession[] | null;
59
+ inboxCursor: number;
60
+ mode: Mode;
61
+ session: ReviewSession | null;
62
+ reviewMode: ReviewPanelMode;
63
+ reviewWidth: number;
64
+ terminalWidth: number;
65
+ focusedAnnotationId: string | undefined;
66
+ /** Planner-local author renames, for seeding the rename prompt. */
67
+ authorNames: Record<string, string>;
68
+ /** Persist an author rename and update the live overrides (App-owned). */
69
+ renameAuthor: (id: string, name: string) => void;
70
+
71
+ liveInput: MutableRefObject<string>;
72
+ reviewWidthRef: MutableRefObject<number>;
73
+ planSheetRef: RefObject<PlanSheetHandle | null>;
74
+
75
+ setCursor: Dispatch<SetStateAction<number>>;
76
+ setInboxCursor: Dispatch<SetStateAction<number>>;
77
+ setMode: Dispatch<SetStateAction<Mode>>;
78
+ setReviewMode: Dispatch<SetStateAction<ReviewPanelMode>>;
79
+ setReviewWidth: Dispatch<SetStateAction<number>>;
80
+ setRailTab: Dispatch<SetStateAction<"review" | "agent">>;
81
+ setFocusedAnnotationId: Dispatch<SetStateAction<string | undefined>>;
82
+ setPulsedAnnotationId: Dispatch<SetStateAction<string | null>>;
83
+
84
+ selectCardFromDocument: (annotationId: string) => void;
85
+ runEditorHandOff: () => void;
86
+ openCardEdit: (annotationId: string) => void;
87
+ }
88
+
89
+ /** Build the intent handler for one render from its dependency bag. */
90
+ export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent) => void {
91
+ const {
92
+ controller,
93
+ onExit,
94
+ isDiff,
95
+ display,
96
+ rows,
97
+ cursor,
98
+ inbox,
99
+ inboxCursor,
100
+ mode,
101
+ session,
102
+ reviewMode,
103
+ reviewWidth,
104
+ terminalWidth,
105
+ focusedAnnotationId,
106
+ authorNames,
107
+ renameAuthor,
108
+ liveInput,
109
+ reviewWidthRef,
110
+ planSheetRef,
111
+ setCursor,
112
+ setInboxCursor,
113
+ setMode,
114
+ setReviewMode,
115
+ setReviewWidth,
116
+ setRailTab,
117
+ setFocusedAnnotationId,
118
+ setPulsedAnnotationId,
119
+ selectCardFromDocument,
120
+ runEditorHandOff,
121
+ openCardEdit,
122
+ } = deps;
123
+
124
+ return (intent: Intent): void => {
125
+ switch (intent.type) {
126
+ case "exit":
127
+ return void onExit?.(0);
128
+ case "status":
129
+ return controller.setStatus(intent.message);
130
+ case "move": {
131
+ const navigableCount = isDiff ? rows.length : display.length;
132
+ if (intent.to === "down") setCursor((current) => Math.min(navigableCount - 1, current + 1));
133
+ else if (intent.to === "up") setCursor((current) => Math.max(0, current - 1));
134
+ else if (intent.to === "top") setCursor(0);
135
+ else setCursor(navigableCount - 1);
136
+ return;
137
+ }
138
+ case "inboxMove": {
139
+ const navigableCount = inbox?.length ?? 0;
140
+ setInboxCursor((current) => (intent.to === "down" ? Math.min(navigableCount - 1, current + 1) : Math.max(0, current - 1)));
141
+ return;
142
+ }
143
+ case "openSession": {
144
+ const selected = inbox?.[inboxCursor];
145
+ if (selected) controller.open(selected.id);
146
+ return;
147
+ }
148
+ case "requestDeleteSession": {
149
+ const selected = inbox?.[inboxCursor];
150
+ if (selected) setMode({ type: "confirmDelete", sessionId: selected.id, title: selected.artifact.meta.title ?? selected.id });
151
+ return;
152
+ }
153
+ case "openRename": {
154
+ const focused = session?.annotations.find((annotation) => annotation.id === focusedAnnotationId);
155
+ if (!focused?.author) return void controller.setStatus("that is your own note - nothing to rename");
156
+ return void setMode({ type: "rename", authorId: focused.author, text: authorNames[focused.author] ?? "" });
157
+ }
158
+ case "confirmDialog": {
159
+ if (mode.type === "confirmDelete") controller.deleteSession(mode.sessionId);
160
+ else if (mode.type === "rename") renameAuthor(mode.authorId, mode.text.trim());
161
+ else if (mode.type === "nameSelf") controller.setSelfName(mode.text.trim());
162
+ return void setMode({ type: "normal" });
163
+ }
164
+ case "startSpan": {
165
+ const block = display[cursor];
166
+ if (!block?.work) return;
167
+ const span = startSpan(cursor, displayText(block));
168
+ if (span) setMode({ type: "span", span });
169
+ return;
170
+ }
171
+ case "spanKey":
172
+ if (mode.type === "span") {
173
+ const span = spanKey(mode.span, intent.name, displayText(display[mode.span.displayIndex]!));
174
+ setMode({ type: "span", span });
175
+ }
176
+ return;
177
+ case "openCompose": {
178
+ liveInput.current = "";
179
+ if (intent.from === "span" && mode.type === "span") {
180
+ setMode({
181
+ type: "compose",
182
+ kind: intent.kind,
183
+ displayIndex: mode.span.displayIndex,
184
+ start: mode.span.start,
185
+ end: mode.span.end,
186
+ text: "",
187
+ });
188
+ } else if (isDiff) {
189
+ const row = rows[cursor];
190
+ if (row) setMode({ type: "compose", kind: intent.kind, displayIndex: cursor, start: 0, end: row.text.length, text: "" });
191
+ } else {
192
+ // a mouse drag leaves a native selection; it wins over the cursor block
193
+ const native = planSheetRef.current?.readSelection() ?? null;
194
+ if (native) {
195
+ setMode({ type: "compose", kind: intent.kind, ...native, text: "" });
196
+ } else {
197
+ const block = display[cursor];
198
+ if (block) setMode({ type: "compose", kind: intent.kind, displayIndex: cursor, start: 0, end: displayText(block).length, text: "" });
199
+ }
200
+ }
201
+ return;
202
+ }
203
+ case "openSubmit":
204
+ if (!session) return;
205
+ liveInput.current = "";
206
+ // the confirm card lives in the expanded review rail; a compact or hidden
207
+ // panel would swallow the whole submit flow, so force the rail open (live
208
+ // only - the saved panel preference is left untouched)
209
+ setReviewMode("expanded");
210
+ setRailTab("review");
211
+ return void setMode({ type: "submit", verdict: defaultVerdict(session), summary: "" });
212
+ case "share":
213
+ return controller.share();
214
+ case "cut":
215
+ return controller.cut(cursor);
216
+ case "edit":
217
+ return runEditorHandOff();
218
+ case "editCard":
219
+ if (focusedAnnotationId) openCardEdit(focusedAnnotationId);
220
+ return;
221
+ case "nextAnnotation":
222
+ case "prevAnnotation": {
223
+ // cycle open cards only - addressed ones are out of the rail
224
+ const annotations = (session?.annotations ?? []).filter((annotation) => !isAddressed(annotation));
225
+ if (!annotations.length) return;
226
+ const focusedIndex = annotations.findIndex((annotation) => annotation.id === focusedAnnotationId);
227
+ const nextIndex =
228
+ focusedIndex === -1
229
+ ? 0
230
+ : (focusedIndex + (intent.type === "nextAnnotation" ? 1 : -1) + annotations.length) % annotations.length;
231
+ return void selectCardFromDocument(annotations[nextIndex]!.id);
232
+ }
233
+ case "walkStart":
234
+ return controller.walkStart();
235
+ case "walkForward":
236
+ return controller.walkForward();
237
+ case "walkBack":
238
+ return controller.walkBack();
239
+ case "walkLeave":
240
+ return controller.walkLeave();
241
+ case "removeAnnotation":
242
+ if (focusedAnnotationId) {
243
+ controller.removeAnnotation(focusedAnnotationId);
244
+ setFocusedAnnotationId(undefined);
245
+ }
246
+ return;
247
+ case "deselect":
248
+ planSheetRef.current?.clearSelection();
249
+ setFocusedAnnotationId(undefined);
250
+ setPulsedAnnotationId(null);
251
+ return;
252
+ case "closeOverlay":
253
+ return void setMode({ type: "normal" });
254
+ case "saveCompose": {
255
+ const body = liveInput.current.trim();
256
+ if (mode.type === "railEdit") {
257
+ if (session && body) controller.updateAnnotation(mode.id, body);
258
+ return void setMode({ type: "normal" });
259
+ }
260
+ if (mode.type !== "compose") return;
261
+ if (session && body) {
262
+ const annotationId = controller.annotate(mode.kind, mode.displayIndex, mode.start, mode.end, body);
263
+ if (annotationId) setFocusedAnnotationId(annotationId);
264
+ }
265
+ return void setMode({ type: "normal" });
266
+ }
267
+ case "submitVerdict":
268
+ if (mode.type === "submit") controller.submit(mode.verdict, liveInput.current);
269
+ return void setMode({ type: "normal" });
270
+ case "cycleVerdict": {
271
+ if (mode.type !== "submit") return;
272
+ const verdictIndex = (VERDICTS.indexOf(mode.verdict) + intent.direction + VERDICTS.length) % VERDICTS.length;
273
+ return void setMode({ ...mode, verdict: VERDICTS[verdictIndex]! });
274
+ }
275
+ case "finishReview":
276
+ return controller.finishReview();
277
+ case "optInAutoClose":
278
+ return controller.optInAutoClose();
279
+ case "dismissCompletion":
280
+ return controller.dismissCompletion();
281
+ case "cycleReviewPanel": {
282
+ const next = cycleReviewPanelMode(reviewMode);
283
+ setReviewMode(next);
284
+ return controller.saveReviewPanel({ mode: next });
285
+ }
286
+ case "resizeReviewPanel": {
287
+ if (reviewMode !== "expanded") return;
288
+ const next = resolveReviewWidth(reviewWidth + intent.direction * REVIEW_RESIZE_STEP, terminalWidth);
289
+ reviewWidthRef.current = next;
290
+ setReviewWidth(next);
291
+ return controller.saveReviewPanel({ width: next });
292
+ }
293
+ }
294
+ };
295
+ }