@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.
- package/package.json +7 -6
- package/src/App.autoclose.test.tsx +2 -2
- package/src/App.export.test.tsx +2 -2
- package/src/App.plan-review.test.tsx +76 -247
- package/src/App.readonly.test.tsx +80 -35
- package/src/App.rendering.test.tsx +5 -6
- package/src/App.review-panel.test.tsx +29 -32
- package/src/App.submit-confirm.test.tsx +27 -12
- package/src/App.test.tsx +73 -49
- package/src/App.toast.test.tsx +34 -22
- package/src/App.tree.test.tsx +224 -0
- package/src/App.tsx +134 -87
- package/src/app-screens.tsx +27 -1
- package/src/app-view-model.ts +7 -109
- package/src/components/AnnotationCard.tsx +2 -2
- package/src/components/DiffSheet.scroll.test.tsx +5 -2
- package/src/components/DiffSheet.tsx +1 -6
- package/src/components/KeybindsDialog.tsx +61 -17
- package/src/components/PrototypeSheet.tsx +5 -9
- package/src/components/ReviewPanel.stories.tsx +14 -0
- package/src/components/ReviewRail.stories.tsx +14 -0
- package/src/components/ReviewRail.tsx +15 -6
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +1620 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +156 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +120 -172
- package/src/components/primitives/Tabs.tsx +1 -1
- package/src/components/story-fixtures.ts +6 -2
- package/src/components/story.ts +16 -4
- package/src/components/syntax-highlight.ts +10 -2
- package/src/components/terminal-pane.test.tsx +1 -1
- package/src/components/terminal-pane.ts +7 -1
- package/src/config.ts +112 -99
- package/src/diff-hunk-curate.ts +4 -104
- package/src/editor.ts +5 -1
- package/src/ghostty-terminal.test.ts +1 -1
- package/src/herdr.ts +10 -2
- package/src/index.ts +1 -0
- package/src/intent-dispatch.test.ts +235 -55
- package/src/intent-dispatch.ts +104 -34
- package/src/key-bindings.test.ts +1 -1
- package/src/key-bindings.ts +16 -7
- package/src/keymap.test.ts +9 -11
- package/src/keymap.ts +14 -7
- package/src/kitty-image.test.ts +3 -3
- package/src/prototype-browser.ts +7 -6
- package/src/serve.ts +1 -1
- package/src/session-controller.curate.test.ts +106 -4
- package/src/session-controller.share.test.ts +220 -70
- package/src/session-controller.tree.test.ts +228 -0
- package/src/session-controller.ts +370 -94
- package/src/share.ts +117 -17
- package/src/test-support.test.ts +193 -1
- package/src/test-support.ts +69 -1
- package/src/theme-presets.ts +9 -2
- package/src/thread-chords.test.ts +154 -0
- package/src/thread-chords.ts +144 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +148 -0
- package/src/view-diff.ts +1 -4
- package/src/view-plan.ts +35 -19
- package/src/components/PlanSheet.stories.tsx +0 -113
- package/src/components/PlanSheet.tsx +0 -422
- 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 {
|
|
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 {
|
|
17
|
+
return { type: "same", kind: "p", work: { kind: "p", text, lineStart: 0, lineEnd: 0 } };
|
|
15
18
|
}
|
|
16
19
|
|
|
17
|
-
function
|
|
20
|
+
function annotation(id: string, overrides: Partial<Annotation> = {}): Annotation {
|
|
18
21
|
return {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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:
|
|
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
|
|
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 =
|
|
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({
|
|
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({
|
|
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([
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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 }
|
|
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 }
|
|
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
|
-
|
|
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
|
|
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
|
|
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 = [
|
|
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 =
|
|
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 =
|
|
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
|
+
});
|
package/src/intent-dispatch.ts
CHANGED
|
@@ -7,14 +7,15 @@
|
|
|
7
7
|
* into React beyond the setters it is given.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type { Dispatch, MutableRefObject,
|
|
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<
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/key-bindings.test.ts
CHANGED
|
@@ -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
|
|
46
|
+
test("span mode resolves the span primitives, not the grammar", () => {
|
|
47
47
|
// Arrange
|
|
48
48
|
const resolver = bindings({ spanMode: true });
|
|
49
49
|
|