@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Observer mode (tier 2): the App with readOnly renders and navigates like the controller's, but every mutating
|
|
1
|
+
/** Observer mode (tier 2): the App with readOnly renders and navigates like the controller's, but every mutating primitive answers "observer - read-only" and leaves daemon state untouched. */
|
|
2
2
|
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
4
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
@@ -10,7 +10,16 @@ import { DaemonServer } from "@cueloop/daemon";
|
|
|
10
10
|
import { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
|
|
11
11
|
import { App } from "./App";
|
|
12
12
|
import { DARK } from "./theme";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
clickText,
|
|
15
|
+
dragText,
|
|
16
|
+
frameRow,
|
|
17
|
+
isolateUserConfig,
|
|
18
|
+
pressKey,
|
|
19
|
+
typeText,
|
|
20
|
+
waitForState,
|
|
21
|
+
waitForText,
|
|
22
|
+
} from "./test-support";
|
|
14
23
|
|
|
15
24
|
const PLAN = `# Migration Plan
|
|
16
25
|
|
|
@@ -60,7 +69,9 @@ async function renderObserver() {
|
|
|
60
69
|
return setup;
|
|
61
70
|
}
|
|
62
71
|
|
|
63
|
-
|
|
72
|
+
type Setup = Awaited<ReturnType<typeof renderObserver>>;
|
|
73
|
+
|
|
74
|
+
/** Session state that any mutating primitive would change. */
|
|
64
75
|
function snapshot() {
|
|
65
76
|
const stored = server.core.sessionGet(session.id);
|
|
66
77
|
|
|
@@ -84,67 +95,101 @@ describe("observer rendering", () => {
|
|
|
84
95
|
});
|
|
85
96
|
});
|
|
86
97
|
|
|
87
|
-
describe("observer
|
|
88
|
-
|
|
89
|
-
["
|
|
90
|
-
["x", "
|
|
91
|
-
["e", "
|
|
92
|
-
["enter", "
|
|
93
|
-
]
|
|
94
|
-
|
|
98
|
+
describe("observer primitives are blocked", () => {
|
|
99
|
+
const attempts: Array<[string, (setup: Setup) => Promise<void>]> = [
|
|
100
|
+
["comment (typing)", (setup) => typeText(setup, "c")],
|
|
101
|
+
["cut (option+x)", (setup) => pressKey(setup, "x", { meta: true })],
|
|
102
|
+
["edit (ctrl+e)", (setup) => pressKey(setup, "e", { ctrl: true })],
|
|
103
|
+
["submit (cmd+enter)", (setup) => pressKey(setup, "RETURN", { meta: true })],
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
for (const [primitive, attempt] of attempts) {
|
|
107
|
+
test(`${primitive} answers observer - read-only and mutates nothing`, async () => {
|
|
95
108
|
const setup = await renderObserver();
|
|
96
109
|
const before = snapshot();
|
|
97
110
|
|
|
98
|
-
await
|
|
99
|
-
await
|
|
100
|
-
await press(setup, key);
|
|
111
|
+
await clickText(setup, "daemon");
|
|
112
|
+
await attempt(setup);
|
|
101
113
|
const frame = await waitForText(setup, "observer - read-only");
|
|
102
114
|
|
|
103
|
-
// no
|
|
104
|
-
expect(frame).not.toContain(
|
|
105
|
-
expect(frame).not.toContain("
|
|
115
|
+
// no draft card and no submit card ever appear
|
|
116
|
+
expect(frame).not.toContain("● c");
|
|
117
|
+
expect(frame).not.toContain("[Approve]");
|
|
106
118
|
expect(snapshot()).toEqual(before);
|
|
107
119
|
});
|
|
108
120
|
}
|
|
109
121
|
|
|
110
|
-
test("
|
|
122
|
+
test("typing over a marked span is blocked too", async () => {
|
|
111
123
|
// Arrange
|
|
112
124
|
const setup = await renderObserver();
|
|
113
125
|
|
|
114
126
|
// Act
|
|
115
|
-
await
|
|
116
|
-
await
|
|
117
|
-
await press(setup, "v");
|
|
118
|
-
await press(setup, "c");
|
|
119
|
-
await setup.renderOnce();
|
|
127
|
+
await dragText(setup, "The daemon", "daemon persists", "daemon".length);
|
|
128
|
+
await typeText(setup, "c");
|
|
120
129
|
|
|
121
130
|
// Assert
|
|
122
|
-
const frame = setup
|
|
131
|
+
const frame = await waitForText(setup, "observer - read-only");
|
|
123
132
|
|
|
124
|
-
expect(frame).toContain("
|
|
125
|
-
expect(frame).not.toContain('comment on "');
|
|
133
|
+
expect(frame).not.toContain("● c");
|
|
126
134
|
expect(snapshot().annotations).toBe(0);
|
|
127
135
|
});
|
|
128
136
|
});
|
|
129
137
|
|
|
138
|
+
describe("a resolved review is read-only for its owner too", () => {
|
|
139
|
+
test("typing, deleting a card, and editing answer review submitted - read-only", async () => {
|
|
140
|
+
// Arrange: a comment exists and the verdict is in
|
|
141
|
+
server.core.sessionAnnotate(session.id, {
|
|
142
|
+
id: "a_done",
|
|
143
|
+
kind: "comment",
|
|
144
|
+
anchor: makeAnchor(parseBlocks(PLAN), 2, 0, 10),
|
|
145
|
+
body: "settled",
|
|
146
|
+
});
|
|
147
|
+
server.core.sessionResolve(session.id, "approve", "");
|
|
148
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, {
|
|
149
|
+
width: 120,
|
|
150
|
+
height: 32,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await waitForText(setup, "settled");
|
|
154
|
+
const before = snapshot();
|
|
155
|
+
|
|
156
|
+
// Act + Assert: a draft never opens
|
|
157
|
+
await clickText(setup, "daemon");
|
|
158
|
+
await typeText(setup, "c");
|
|
159
|
+
await waitForText(setup, "review submitted - read-only");
|
|
160
|
+
expect(setup.captureCharFrame()).not.toContain("● c");
|
|
161
|
+
|
|
162
|
+
// Act + Assert: the focused card cannot be deleted or edited
|
|
163
|
+
await pressKey(setup, "n", { meta: true });
|
|
164
|
+
await pressKey(setup, "BACKSPACE", { meta: true });
|
|
165
|
+
await pressKey(setup, "e", { meta: true });
|
|
166
|
+
await pressKey(setup, "x", { meta: true });
|
|
167
|
+
expect(snapshot()).toEqual(before);
|
|
168
|
+
expect(server.core.sessionGet(session.id).annotations).toHaveLength(1);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
130
172
|
describe("observer navigation still works", () => {
|
|
131
|
-
test("
|
|
173
|
+
test("↓ moves the caret between blocks", async () => {
|
|
132
174
|
// Arrange
|
|
133
175
|
const setup = await renderObserver();
|
|
134
176
|
|
|
135
177
|
// Act
|
|
136
|
-
await
|
|
137
|
-
await
|
|
178
|
+
await pressKey(setup, "ARROW_DOWN");
|
|
179
|
+
await pressKey(setup, "ARROW_DOWN");
|
|
138
180
|
await setup.renderOnce();
|
|
139
181
|
|
|
140
|
-
// Assert
|
|
141
|
-
const
|
|
142
|
-
|
|
182
|
+
// Assert: the caret cell is painted on the paragraph's row
|
|
183
|
+
const caretRow = setup
|
|
184
|
+
.captureSpans()
|
|
185
|
+
.lines.findIndex((line) =>
|
|
186
|
+
line.spans.some((span) => span.bg.toInts().slice(0, 3).join() === "86,91,104"),
|
|
187
|
+
);
|
|
143
188
|
|
|
144
|
-
expect(
|
|
189
|
+
expect(caretRow).toBe(frameRow(setup, "persists sessions"));
|
|
145
190
|
});
|
|
146
191
|
|
|
147
|
-
test("n focuses annotations made by the controller", async () => {
|
|
192
|
+
test("option+n focuses annotations made by the controller", async () => {
|
|
148
193
|
// Arrange
|
|
149
194
|
server.core.sessionAnnotate(session.id, {
|
|
150
195
|
id: "a_obs1",
|
|
@@ -155,7 +200,7 @@ describe("observer navigation still works", () => {
|
|
|
155
200
|
const setup = await renderObserver();
|
|
156
201
|
|
|
157
202
|
// Act
|
|
158
|
-
await
|
|
203
|
+
await pressKey(setup, "n", { meta: true });
|
|
159
204
|
await setup.renderOnce();
|
|
160
205
|
|
|
161
206
|
// Assert - focus shows as the card's blue border (transparent fill); a
|
|
@@ -83,10 +83,9 @@ describe("code blocks", () => {
|
|
|
83
83
|
});
|
|
84
84
|
|
|
85
85
|
describe("block spacing", () => {
|
|
86
|
-
// the review
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
const planColumn = (line: string): string => line.split("│")[1]?.trimEnd() ?? "";
|
|
86
|
+
// the review rail paints box edges on the right, so read the document
|
|
87
|
+
// column (everything left of the first rail glyph) before asserting spacing
|
|
88
|
+
const planColumn = (line: string): string => line.split(/[│╭╰]/)[0]!.trimEnd();
|
|
90
89
|
|
|
91
90
|
test("a code block never glues to the list item above it", async () => {
|
|
92
91
|
// Arrange
|
|
@@ -129,8 +128,8 @@ describe("block spacing", () => {
|
|
|
129
128
|
.captureCharFrame()
|
|
130
129
|
.split("\n")
|
|
131
130
|
.map((line) => line.trimEnd());
|
|
132
|
-
const one = lines.findIndex((line) => line.includes("
|
|
131
|
+
const one = lines.findIndex((line) => line.includes("· one"));
|
|
133
132
|
|
|
134
|
-
expect(lines[one + 1]).toContain("
|
|
133
|
+
expect(lines[one + 1]).toContain("· two");
|
|
135
134
|
});
|
|
136
135
|
});
|
|
@@ -10,7 +10,7 @@ import { DaemonServer } from "@cueloop/daemon";
|
|
|
10
10
|
import type { ReviewSession } from "@cueloop/schema";
|
|
11
11
|
import { App } from "./App";
|
|
12
12
|
import { loadConfig } from "./config";
|
|
13
|
-
import { isolateUserConfig,
|
|
13
|
+
import { isolateUserConfig, settle, waitForText, waitForTextGone, pressKey } from "./test-support";
|
|
14
14
|
|
|
15
15
|
const PLAN = "# Plan\n\nShip the thing.\n";
|
|
16
16
|
|
|
@@ -49,43 +49,40 @@ async function renderApp() {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
describe("review panel", () => {
|
|
52
|
-
test("
|
|
52
|
+
test("ctrl+r cycles expanded -> compact -> hidden -> expanded", async () => {
|
|
53
53
|
// Arrange
|
|
54
54
|
const setup = await renderApp();
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
const
|
|
58
|
-
setup
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
55
|
+
// each mode has its own signature: expanded draws the tab box and the
|
|
56
|
+
// submit button, compact keeps only the count strip, hidden draws nothing
|
|
57
|
+
const railSignature = () => {
|
|
58
|
+
const frame = setup.captureCharFrame();
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
tabs: frame.includes("Review Tree Agent"),
|
|
62
|
+
submit: frame.includes("Submit review"),
|
|
63
|
+
strip: /\s0\s*\n/.test(frame) && frame.includes("<"),
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Assert: expanded
|
|
68
|
+
expect(railSignature()).toEqual({ tabs: true, submit: true, strip: false });
|
|
67
69
|
|
|
68
70
|
// Act
|
|
69
|
-
await
|
|
71
|
+
await pressKey(setup, "r", { ctrl: true });
|
|
70
72
|
|
|
71
|
-
// Assert: compact drops the
|
|
73
|
+
// Assert: compact drops the tabs and the button, keeps the strip
|
|
72
74
|
await waitForTextGone(setup, "Submit review");
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
expect(compactWidth).toBeGreaterThan(expandedWidth);
|
|
75
|
+
expect(railSignature()).toEqual({ tabs: false, submit: false, strip: true });
|
|
76
76
|
|
|
77
77
|
// Act
|
|
78
|
-
await
|
|
78
|
+
await pressKey(setup, "r", { ctrl: true });
|
|
79
79
|
await settle(setup);
|
|
80
80
|
|
|
81
|
-
// Assert: hidden
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
expect(hiddenWidth).toBeGreaterThan(compactWidth);
|
|
85
|
-
expect(hiddenWidth).toBeGreaterThan(115);
|
|
81
|
+
// Assert: hidden draws no rail at all
|
|
82
|
+
expect(railSignature()).toEqual({ tabs: false, submit: false, strip: false });
|
|
86
83
|
|
|
87
84
|
// Act
|
|
88
|
-
await
|
|
85
|
+
await pressKey(setup, "r", { ctrl: true });
|
|
89
86
|
|
|
90
87
|
// Assert
|
|
91
88
|
await waitForText(setup, "Submit review"); // back to expanded
|
|
@@ -96,19 +93,19 @@ describe("review panel", () => {
|
|
|
96
93
|
const setup = await renderApp();
|
|
97
94
|
|
|
98
95
|
// Act
|
|
99
|
-
await
|
|
96
|
+
await pressKey(setup, "r", { ctrl: true }); // -> compact
|
|
100
97
|
|
|
101
98
|
// Assert
|
|
102
99
|
expect(loadConfig({ userConfigPath: configPath }).ui.reviewState).toBe("compact");
|
|
103
100
|
});
|
|
104
101
|
|
|
105
|
-
test("
|
|
102
|
+
test("option+w widens the rail and persists the new width", async () => {
|
|
106
103
|
// Arrange
|
|
107
104
|
const setup = await renderApp();
|
|
108
105
|
const startWidth = loadConfig({ userConfigPath: configPath }).ui.reviewWidth;
|
|
109
106
|
|
|
110
107
|
// Act
|
|
111
|
-
await
|
|
108
|
+
await pressKey(setup, "w", { meta: true });
|
|
112
109
|
|
|
113
110
|
// Assert
|
|
114
111
|
const widened = loadConfig({ userConfigPath: configPath }).ui.reviewWidth;
|
|
@@ -120,15 +117,15 @@ describe("review panel", () => {
|
|
|
120
117
|
// Arrange
|
|
121
118
|
const setup = await renderApp();
|
|
122
119
|
|
|
123
|
-
await
|
|
124
|
-
await
|
|
120
|
+
await pressKey(setup, "r", { ctrl: true }); // -> compact
|
|
121
|
+
await pressKey(setup, "r", { ctrl: true }); // -> hidden
|
|
125
122
|
|
|
126
123
|
// Assert
|
|
127
124
|
await waitForTextGone(setup, "Submit review"); // rail is gone
|
|
128
125
|
expect(setup.captureCharFrame()).not.toContain("Submit review");
|
|
129
126
|
|
|
130
127
|
// Act
|
|
131
|
-
await
|
|
128
|
+
await pressKey(setup, "RETURN", { meta: true }); // submit while hidden must not trap in an invisible modal
|
|
132
129
|
|
|
133
130
|
// Assert
|
|
134
131
|
await waitForText(setup, "submit review");
|
|
@@ -9,7 +9,14 @@ import { testRender } from "@opentui/react/test-utils";
|
|
|
9
9
|
import { DaemonServer } from "@cueloop/daemon";
|
|
10
10
|
import { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
|
|
11
11
|
import { App } from "./App";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
isolateUserConfig,
|
|
14
|
+
press,
|
|
15
|
+
pressKey,
|
|
16
|
+
settle,
|
|
17
|
+
waitForText,
|
|
18
|
+
waitForTextGone,
|
|
19
|
+
} from "./test-support";
|
|
13
20
|
|
|
14
21
|
const PLAN = `# Migration Plan
|
|
15
22
|
|
|
@@ -85,7 +92,7 @@ describe("rail submit confirm", () => {
|
|
|
85
92
|
expect(setup.captureCharFrame()).toContain("Submit review");
|
|
86
93
|
|
|
87
94
|
// Act
|
|
88
|
-
await
|
|
95
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
89
96
|
|
|
90
97
|
// Assert
|
|
91
98
|
await waitForText(setup, "submit review");
|
|
@@ -102,7 +109,7 @@ describe("rail submit confirm", () => {
|
|
|
102
109
|
// Arrange
|
|
103
110
|
const setup = await renderApp();
|
|
104
111
|
|
|
105
|
-
await
|
|
112
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
106
113
|
|
|
107
114
|
// Assert
|
|
108
115
|
await waitForText(setup, "[Approve]");
|
|
@@ -130,7 +137,7 @@ describe("rail submit confirm", () => {
|
|
|
130
137
|
// Arrange
|
|
131
138
|
const setup = await renderApp();
|
|
132
139
|
|
|
133
|
-
await
|
|
140
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
134
141
|
|
|
135
142
|
// Assert
|
|
136
143
|
await waitForText(setup, "[Approve]");
|
|
@@ -152,7 +159,7 @@ describe("rail submit confirm", () => {
|
|
|
152
159
|
const setup = await renderApp();
|
|
153
160
|
|
|
154
161
|
// Act
|
|
155
|
-
await
|
|
162
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
156
163
|
|
|
157
164
|
// Assert
|
|
158
165
|
await waitForText(setup, "[Changes]"); // pending items: request changes default
|
|
@@ -173,26 +180,34 @@ describe("rail submit confirm", () => {
|
|
|
173
180
|
expect(stored.verdict!.kind).toBe("request_changes");
|
|
174
181
|
});
|
|
175
182
|
|
|
183
|
+
/** The rail's columns of the frame, so document text never leaks into a rail assertion. */
|
|
184
|
+
function railText(frame: string): string {
|
|
185
|
+
const rows = frame.split("\n");
|
|
186
|
+
const railColumn = rows.find((row) => row.includes("Review Tree Agent"))!.indexOf("│");
|
|
187
|
+
|
|
188
|
+
return rows.map((row) => row.slice(railColumn)).join("\n");
|
|
189
|
+
}
|
|
190
|
+
|
|
176
191
|
test("the annotation stack stays scrollable while the card is open", async () => {
|
|
177
192
|
// Arrange
|
|
178
193
|
seedAnnotations(12);
|
|
179
194
|
const setup = await renderApp();
|
|
180
195
|
|
|
181
196
|
// Act
|
|
182
|
-
// walk
|
|
183
|
-
for (let index = 0; index < 12; index++) await
|
|
197
|
+
// walk card focus to the last one: the rail scrolls it into view
|
|
198
|
+
for (let index = 0; index < 12; index++) await pressKey(setup, "n", { meta: true });
|
|
184
199
|
|
|
185
200
|
// Assert
|
|
186
201
|
await waitForText(setup, "note 12");
|
|
187
|
-
expect(setup.captureCharFrame()).not.toContain("note 01");
|
|
202
|
+
expect(railText(setup.captureCharFrame())).not.toContain("note 01");
|
|
188
203
|
|
|
189
204
|
// Act
|
|
190
|
-
await
|
|
205
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
191
206
|
|
|
192
207
|
// Assert
|
|
193
208
|
await waitForText(setup, "[Changes]");
|
|
194
209
|
// the card is pinned outside the scrollbox; the stack stays scrolled off the top
|
|
195
|
-
expect(setup.captureCharFrame()).not.toContain("note 01");
|
|
210
|
+
expect(railText(setup.captureCharFrame())).not.toContain("note 01");
|
|
196
211
|
|
|
197
212
|
// Act
|
|
198
213
|
// the stack still scrolls with the card open: wheel over the rail
|
|
@@ -202,7 +217,7 @@ describe("rail submit confirm", () => {
|
|
|
202
217
|
|
|
203
218
|
// Assert
|
|
204
219
|
await waitForText(setup, "note 12");
|
|
205
|
-
const scrolledWithCard = setup.captureCharFrame();
|
|
220
|
+
const scrolledWithCard = railText(setup.captureCharFrame());
|
|
206
221
|
|
|
207
222
|
expect(scrolledWithCard).toContain("submit review");
|
|
208
223
|
expect(scrolledWithCard).not.toContain("note 01");
|
|
@@ -214,7 +229,7 @@ describe("rail submit confirm", () => {
|
|
|
214
229
|
const setup = await renderApp({ readOnly: true });
|
|
215
230
|
|
|
216
231
|
// Act
|
|
217
|
-
await
|
|
232
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
218
233
|
|
|
219
234
|
// Assert
|
|
220
235
|
await waitForText(setup, "observer - read-only");
|
package/src/App.test.tsx
CHANGED
|
@@ -10,8 +10,12 @@ import { DaemonServer } from "@cueloop/daemon";
|
|
|
10
10
|
import type { ReviewSession } from "@cueloop/schema";
|
|
11
11
|
import { App } from "./App";
|
|
12
12
|
import {
|
|
13
|
+
clickText,
|
|
14
|
+
dragText,
|
|
15
|
+
frameRow,
|
|
13
16
|
isolateUserConfig,
|
|
14
17
|
press,
|
|
18
|
+
pressKey,
|
|
15
19
|
typeText as type,
|
|
16
20
|
waitForState,
|
|
17
21
|
waitForText,
|
|
@@ -77,45 +81,36 @@ describe("plan rendering", () => {
|
|
|
77
81
|
expect(frame).toContain("Migration Plan");
|
|
78
82
|
expect(frame).toContain("Context");
|
|
79
83
|
expect(frame).toContain("persists sessions to disk atomically");
|
|
80
|
-
expect(frame).toContain("
|
|
84
|
+
expect(frame).toContain("· move the store");
|
|
81
85
|
expect(frame).toContain("Review");
|
|
82
86
|
expect(frame).toContain("Submit review");
|
|
83
87
|
});
|
|
84
88
|
});
|
|
85
89
|
|
|
86
|
-
describe("
|
|
87
|
-
test("
|
|
90
|
+
describe("thread view grammar", () => {
|
|
91
|
+
test("↓ moves the caret between blocks and typing anchors to the block under it", async () => {
|
|
88
92
|
// Arrange
|
|
89
93
|
const setup = await renderApp();
|
|
90
94
|
|
|
91
|
-
// Act
|
|
92
|
-
await
|
|
93
|
-
await
|
|
94
|
-
await setup
|
|
95
|
+
// Act: two blocks down lands on the paragraph; a printable opens a draft there
|
|
96
|
+
await pressKey(setup, "ARROW_DOWN");
|
|
97
|
+
await pressKey(setup, "ARROW_DOWN");
|
|
98
|
+
await type(setup, "x");
|
|
95
99
|
|
|
96
|
-
// Assert
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
expect(cursorLine).toContain("persists sessions");
|
|
100
|
+
// Assert: the draft card sits right under the paragraph's row
|
|
101
|
+
await waitForText(setup, "● x");
|
|
102
|
+
expect(frameRow(setup, "● x")).toBeGreaterThan(frameRow(setup, "persists sessions"));
|
|
103
|
+
expect(frameRow(setup, "● x")).toBeLessThan(frameRow(setup, "Steps"));
|
|
101
104
|
});
|
|
102
105
|
|
|
103
|
-
test("comment flow:
|
|
106
|
+
test("comment flow: click a word, type a body, cmd+⏎ saves to the daemon", async () => {
|
|
104
107
|
// Arrange
|
|
105
108
|
const setup = await renderApp();
|
|
106
109
|
|
|
107
110
|
// Act
|
|
108
|
-
await
|
|
109
|
-
await press(setup, "j");
|
|
110
|
-
await press(setup, "c");
|
|
111
|
-
await setup.renderOnce();
|
|
112
|
-
|
|
113
|
-
// Assert
|
|
114
|
-
await waitForText(setup, 'comment on "The daemon');
|
|
115
|
-
|
|
116
|
-
// Act
|
|
111
|
+
await clickText(setup, "daemon");
|
|
117
112
|
await type(setup, "Define atomically.");
|
|
118
|
-
await
|
|
113
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
119
114
|
|
|
120
115
|
// Assert
|
|
121
116
|
await waitForText(setup, "COMMENT");
|
|
@@ -123,50 +118,44 @@ describe("keyboard grammar", () => {
|
|
|
123
118
|
|
|
124
119
|
expect(stored.annotations.length).toBe(1);
|
|
125
120
|
expect(stored.annotations[0]!.body).toBe("Define atomically.");
|
|
121
|
+
expect(stored.annotations[0]!.anchor.quote).toBe("daemon");
|
|
126
122
|
});
|
|
127
123
|
|
|
128
|
-
test("
|
|
124
|
+
test("a drag marks the exact span the comment anchors to", async () => {
|
|
129
125
|
// Arrange
|
|
130
126
|
const setup = await renderApp();
|
|
131
127
|
|
|
132
|
-
// Act
|
|
133
|
-
await
|
|
134
|
-
await press(setup, "j");
|
|
135
|
-
await press(setup, "v");
|
|
136
|
-
await press(setup, "l"); // "The daemon"
|
|
137
|
-
await press(setup, "c");
|
|
128
|
+
// Act: from the start of "The" to the end of "daemon"
|
|
129
|
+
await dragText(setup, "The daemon", "daemon persists", "daemon".length);
|
|
138
130
|
await type(setup, "Which daemon?");
|
|
139
|
-
await
|
|
131
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
140
132
|
|
|
141
133
|
// Assert
|
|
142
134
|
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
expect(stored.annotations[0]!.anchor.quote).toBe("The daemon");
|
|
135
|
+
expect(server.core.sessionGet(session.id).annotations[0]!.anchor.quote).toBe("The daemon");
|
|
146
136
|
});
|
|
147
137
|
|
|
148
|
-
test("x cuts
|
|
138
|
+
test("option+x cuts the caret's block into the working copy; option+x restores it", async () => {
|
|
149
139
|
// Arrange
|
|
150
140
|
const setup = await renderApp();
|
|
151
141
|
|
|
152
|
-
|
|
153
|
-
for (let i = 0; i < 4; i++) await press(setup, "j");
|
|
142
|
+
await clickText(setup, "move the store");
|
|
154
143
|
|
|
155
144
|
// Act
|
|
156
|
-
await
|
|
145
|
+
await pressKey(setup, "x", { meta: true });
|
|
157
146
|
|
|
158
147
|
// Assert - the rail shows the cut as a removal card titled CUT · me
|
|
159
148
|
await waitForText(setup, "CUT · me");
|
|
160
149
|
expect(server.core.sessionGet(session.id).workingCopy).not.toContain("move the store");
|
|
161
150
|
|
|
162
151
|
// Act
|
|
163
|
-
await
|
|
152
|
+
await pressKey(setup, "x", { meta: true });
|
|
164
153
|
|
|
165
154
|
// Assert
|
|
166
155
|
await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy === undefined);
|
|
167
156
|
});
|
|
168
157
|
|
|
169
|
-
test("e runs $EDITOR on the working copy and tracks the diff", async () => {
|
|
158
|
+
test("ctrl+e runs $EDITOR on the working copy and tracks the diff", async () => {
|
|
170
159
|
const script = join(home, "fake-editor.sh");
|
|
171
160
|
|
|
172
161
|
await Bun.write(script, `#!/bin/sh\nsed -i '' 's/atomically/very atomically/' "$1"\n`);
|
|
@@ -177,7 +166,7 @@ describe("keyboard grammar", () => {
|
|
|
177
166
|
const setup = await renderApp();
|
|
178
167
|
|
|
179
168
|
// Act
|
|
180
|
-
await
|
|
169
|
+
await pressKey(setup, "e", { ctrl: true });
|
|
181
170
|
|
|
182
171
|
// Assert
|
|
183
172
|
await waitForText(setup, "[edited]");
|
|
@@ -189,19 +178,17 @@ describe("keyboard grammar", () => {
|
|
|
189
178
|
});
|
|
190
179
|
|
|
191
180
|
describe("submit", () => {
|
|
192
|
-
test("
|
|
181
|
+
test("cmd+⏎ opens the rail confirm card; verdict + summary resolve the session", async () => {
|
|
193
182
|
// Arrange
|
|
194
183
|
const setup = await renderApp();
|
|
195
184
|
|
|
196
|
-
await
|
|
197
|
-
await press(setup, "j");
|
|
198
|
-
await press(setup, "c");
|
|
185
|
+
await clickText(setup, "daemon");
|
|
199
186
|
await type(setup, "Needs a phase list.");
|
|
200
|
-
await
|
|
187
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
201
188
|
await waitForText(setup, "Needs a phase list");
|
|
202
189
|
|
|
203
|
-
// Act
|
|
204
|
-
await
|
|
190
|
+
// Act: with no composer open the same chord opens submit
|
|
191
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
205
192
|
|
|
206
193
|
// Assert
|
|
207
194
|
await waitForText(setup, "[Changes]");
|
|
@@ -226,7 +213,7 @@ describe("submit", () => {
|
|
|
226
213
|
const setup = await renderApp();
|
|
227
214
|
|
|
228
215
|
// Act
|
|
229
|
-
await
|
|
216
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
230
217
|
|
|
231
218
|
// Assert
|
|
232
219
|
await waitForText(setup, "[Approve]"); // no pending items → approve default
|
|
@@ -287,3 +274,40 @@ describe("inbox", () => {
|
|
|
287
274
|
await waitForText(setup, "Submit review");
|
|
288
275
|
});
|
|
289
276
|
});
|
|
277
|
+
|
|
278
|
+
describe("the thread view and the menu", () => {
|
|
279
|
+
test("the keybinds dialog lists the thread view grammar, keeps keys away from the text, and closes on escape", async () => {
|
|
280
|
+
// Arrange
|
|
281
|
+
const setup = await renderApp();
|
|
282
|
+
|
|
283
|
+
await waitForText(setup, "The daemon persists");
|
|
284
|
+
const lines = setup.captureCharFrame().split("\n");
|
|
285
|
+
const menuRow = lines.findIndex((line) => line.includes("menu"));
|
|
286
|
+
const menuColumn = lines[menuRow]!.indexOf("menu");
|
|
287
|
+
|
|
288
|
+
// Act - open the menu, then the keybinds dialog
|
|
289
|
+
await setup.mockMouse.click(menuColumn + 1, menuRow);
|
|
290
|
+
await waitForText(setup, "Keybinds");
|
|
291
|
+
const dropUp = setup.captureCharFrame().split("\n");
|
|
292
|
+
const keybindsRow = dropUp.findIndex((line) => line.includes("Keybinds"));
|
|
293
|
+
|
|
294
|
+
await setup.mockMouse.click(dropUp[keybindsRow]!.indexOf("Keybinds") + 1, keybindsRow);
|
|
295
|
+
await waitForText(setup, "mark text, across blocks");
|
|
296
|
+
|
|
297
|
+
// Assert - the thread grammar, not the plan sheet's
|
|
298
|
+
const dialog = setup.captureCharFrame();
|
|
299
|
+
|
|
300
|
+
expect(dialog).toContain("⌘⌥m");
|
|
301
|
+
expect(dialog).toContain("dismiss an empty draft");
|
|
302
|
+
expect(dialog).not.toContain("grow/shrink");
|
|
303
|
+
|
|
304
|
+
// Act - a printable behind the dialog must not open a comment; escape closes it
|
|
305
|
+
await press(setup, "x");
|
|
306
|
+
expect(setup.captureCharFrame()).not.toContain("● x");
|
|
307
|
+
await press(setup, "escape");
|
|
308
|
+
|
|
309
|
+
// Assert
|
|
310
|
+
await waitForState(setup, () => !setup.captureCharFrame().includes("dismiss an empty draft"));
|
|
311
|
+
expect(setup.captureCharFrame()).not.toContain("● x");
|
|
312
|
+
});
|
|
313
|
+
});
|