@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.
- package/package.json +5 -5
- package/src/App.autoclose.test.tsx +2 -2
- package/src/App.diff.test.tsx +8 -73
- package/src/App.export.test.tsx +2 -2
- package/src/App.plan-review.test.tsx +60 -412
- package/src/App.prototype.test.tsx +1 -8
- package/src/App.readonly.test.tsx +85 -62
- package/src/App.rendering.test.tsx +5 -6
- package/src/App.submit-confirm.test.tsx +32 -70
- package/src/App.test.tsx +136 -66
- package/src/App.toast.test.tsx +17 -8
- package/src/App.tsx +243 -284
- package/src/App.walk.test.tsx +1 -1
- package/src/app-screens.tsx +67 -52
- package/src/app-view-model.ts +6 -100
- package/src/components/AnnotationCard.stories.tsx +1 -1
- package/src/components/AnnotationCard.tsx +2 -2
- package/src/components/AppShell.stories.tsx +84 -0
- package/src/components/AppShell.tsx +127 -0
- package/src/components/Breadcrumb.stories.tsx +1 -1
- package/src/components/ChangesColumn.stories.tsx +39 -0
- package/src/components/ChangesColumn.tsx +147 -0
- package/src/components/CodeBlock.stories.tsx +1 -1
- package/src/components/CompletionOverlay.stories.tsx +1 -1
- package/src/components/ConfirmCard.stories.tsx +1 -1
- package/src/components/ConfirmCard.tsx +3 -4
- package/src/components/ConfirmDialog.stories.tsx +1 -1
- package/src/components/DiffSheet.stories.tsx +1 -1
- package/src/components/InboxList.stories.tsx +23 -8
- package/src/components/InboxList.tsx +45 -19
- package/src/components/KeybindsDialog.stories.tsx +1 -1
- package/src/components/KeybindsDialog.tsx +61 -17
- package/src/components/MarkerPopover.stories.tsx +1 -1
- package/src/components/MenuBar.stories.tsx +3 -12
- package/src/components/MenuBar.tsx +25 -56
- package/src/components/PromptDialog.stories.tsx +1 -1
- package/src/components/PrototypeSheet.stories.tsx +1 -1
- package/src/components/ReviewPanel.stories.tsx +15 -1
- package/src/components/ReviewRail.stories.tsx +15 -1
- package/src/components/ReviewRail.tsx +13 -6
- package/src/components/SettingsDialog.stories.tsx +7 -1
- package/src/components/SettingsDialog.tsx +112 -66
- package/src/components/SettingsRows.stories.tsx +1 -1
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.tsx +49 -0
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.slash.test.tsx +113 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +1708 -0
- package/src/components/ThreadsSidebar.stories.tsx +36 -0
- package/src/components/ThreadsSidebar.tsx +57 -0
- package/src/components/Toast.stories.tsx +1 -1
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +156 -0
- package/src/components/WalkWizard.stories.tsx +1 -1
- package/src/components/__snapshots__/stories.test.tsx.snap +463 -305
- package/src/components/agent-launcher.stories.tsx +1 -1
- package/src/components/file-tree.test.ts +45 -0
- package/src/components/file-tree.ts +57 -0
- package/src/components/pages/Workspace.stories.tsx +181 -0
- package/src/components/primitives/Button.stories.tsx +1 -1
- package/src/components/primitives/Card.stories.tsx +1 -1
- package/src/components/primitives/Dialog.stories.tsx +1 -1
- package/src/components/primitives/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +43 -0
- package/src/components/primitives/StatusBar.stories.tsx +1 -1
- package/src/components/primitives/Tabs.stories.tsx +1 -1
- package/src/components/primitives/Toolbar.stories.tsx +1 -1
- package/src/components/primitives/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +87 -0
- package/src/components/primitives/icons.ts +38 -0
- package/src/components/primitives/tree-model.test.ts +99 -0
- package/src/components/primitives/tree-model.ts +137 -0
- package/src/components/quick-actions-editor.stories.tsx +1 -1
- package/src/components/session-tree.test.ts +114 -0
- package/src/components/session-tree.ts +94 -0
- package/src/components/stories-app.tsx +142 -82
- package/src/components/story-tree.test.ts +100 -0
- package/src/components/story.ts +55 -0
- package/src/components/theme-context.stories.tsx +1 -1
- package/src/diff-hunk-curate.ts +4 -104
- package/src/index.ts +1 -0
- package/src/intent-dispatch.test.ts +136 -3
- package/src/intent-dispatch.ts +92 -24
- package/src/key-bindings.test.ts +1 -1
- package/src/keymap.test.ts +4 -4
- package/src/keymap.ts +14 -7
- package/src/session-controller.curate.test.ts +89 -2
- package/src/session-controller.share.test.ts +195 -55
- package/src/session-controller.tree.test.ts +228 -0
- package/src/session-controller.ts +331 -72
- package/src/share.ts +114 -16
- package/src/test-support.test.ts +193 -1
- package/src/test-support.ts +64 -0
- package/src/theme.ts +4 -4
- 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/use-settings-dialog.tsx +9 -0
- package/src/view-plan.ts +29 -12
- package/src/App.review-panel.test.tsx +0 -143
- package/src/components/PlanSheet.stories.tsx +0 -113
- package/src/components/PlanSheet.tsx +0 -419
- 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
|
|
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
|
-
|
|
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
|
+
});
|
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,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"]>(
|
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
|
|
package/src/keymap.test.ts
CHANGED
|
@@ -224,7 +224,7 @@ describe("plan normal mode", () => {
|
|
|
224
224
|
});
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
test("resolved sessions guard the mutating
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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(
|
|
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
|
});
|