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