@cueloop/client 0.1.0-alpha.20 → 0.1.0-alpha.22
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 +29 -5
- package/src/App.diff.test.tsx +20 -0
- package/src/App.export.test.tsx +12 -8
- package/src/App.plan-review.test.tsx +182 -7
- package/src/App.readonly.test.tsx +19 -5
- package/src/App.rendering.test.tsx +21 -2
- package/src/App.review-panel.test.tsx +120 -0
- package/src/App.submit-confirm.test.tsx +57 -6
- package/src/App.test.tsx +62 -5
- package/src/App.tsx +147 -185
- package/src/App.walk.test.tsx +63 -8
- 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.tsx +66 -13
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +116 -0
- package/src/components/ReviewRail.tsx +30 -5
- package/src/components/__snapshots__/stories.test.tsx.snap +140 -0
- package/src/components/primitives/Card.tsx +2 -1
- package/src/components/primitives/Dialog.tsx +2 -1
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/stories-app.tsx +2 -1
- package/src/components/stories.test.tsx +5 -0
- package/src/config.test.ts +113 -1
- package/src/config.ts +58 -22
- package/src/editor.test.ts +24 -0
- package/src/herdr.test.ts +4 -0
- package/src/index.ts +2 -0
- package/src/integrations.test.ts +12 -0
- package/src/intent-dispatch.test.ts +213 -0
- package/src/intent-dispatch.ts +270 -0
- package/src/key-bindings.test.ts +16 -5
- package/src/keymap.test.ts +141 -9
- package/src/keymap.ts +54 -7
- package/src/review-panel.test.ts +111 -0
- package/src/review-panel.ts +76 -0
- package/src/serve.test.ts +7 -0
- package/src/session-controller.ts +39 -4
- package/src/share.ts +47 -0
- package/src/view-diff.test.ts +11 -0
- package/src/view-diff.ts +6 -6
- package/src/view-plan.test.ts +81 -0
- package/src/view-plan.ts +3 -0
- package/src/walk.test.ts +6 -0
package/src/App.tsx
CHANGED
|
@@ -11,28 +11,43 @@
|
|
|
11
11
|
|
|
12
12
|
import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
13
13
|
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
|
|
14
|
-
import type { Clock } from "@opentui/core";
|
|
15
|
-
import {
|
|
16
|
-
import { displayText, marksByDisplay,
|
|
14
|
+
import type { Clock, MouseEvent } from "@opentui/core";
|
|
15
|
+
import { type VerdictKind } from "@cueloop/schema";
|
|
16
|
+
import { displayText, marksByDisplay, type Mark } from "./view-plan";
|
|
17
17
|
import { noteForFile, viewedCount } from "./walk";
|
|
18
18
|
import { DARK, dimmedTheme } from "./theme";
|
|
19
19
|
import { DEFAULT_KEYS, loadConfig } from "./config";
|
|
20
20
|
import { returnPaneFor } from "@cueloop/schema";
|
|
21
21
|
import { createReviewController } from "./session-controller";
|
|
22
|
-
import
|
|
22
|
+
import type { SessionClient } from "@cueloop/daemon/client";
|
|
23
|
+
import { createIntentDispatch, reviewerAnnotations, type Mode } from "./intent-dispatch";
|
|
24
|
+
import { reduceKey, type KeyState } from "./keymap";
|
|
23
25
|
import { KeyBindings, type HintMode } from "./key-bindings";
|
|
24
26
|
import { ThemeProvider } from "./components/theme-context";
|
|
25
27
|
import { StatusBar } from "./components/primitives/StatusBar";
|
|
26
28
|
import { Breadcrumb, type BreadcrumbItem } from "./components/Breadcrumb";
|
|
27
29
|
import { PlanSheet, type PlanSheetHandle } from "./components/PlanSheet";
|
|
28
30
|
import { DiffSheet } from "./components/DiffSheet";
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
+
import { annotationBlocking, type ReviewRailHandle } from "./components/ReviewRail";
|
|
32
|
+
import { ReviewPanel } from "./components/ReviewPanel";
|
|
33
|
+
import {
|
|
34
|
+
REVIEW_DEFAULT_WIDTH,
|
|
35
|
+
resolveReviewWidth,
|
|
36
|
+
toggleReviewPanelMode,
|
|
37
|
+
widthFromMouseColumn,
|
|
38
|
+
type ReviewPanelMode,
|
|
39
|
+
} from "./review-panel";
|
|
31
40
|
import { CompletionOverlay } from "./components/CompletionOverlay";
|
|
32
41
|
import { InboxList } from "./components/InboxList";
|
|
33
42
|
import { ComposeBar } from "./components/ComposeBar";
|
|
34
43
|
import { WalkWizard } from "./components/WalkWizard";
|
|
35
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The breadcrumb header and the status bar each occupy one terminal row; the
|
|
47
|
+
* review layout (plan column, divider, rail) gets the rows that remain.
|
|
48
|
+
*/
|
|
49
|
+
const CHROME_ROWS = 2;
|
|
50
|
+
|
|
36
51
|
export interface AppProps {
|
|
37
52
|
home?: string;
|
|
38
53
|
sessionId?: string;
|
|
@@ -44,27 +59,26 @@ export interface AppProps {
|
|
|
44
59
|
onExit?: (code: number) => void;
|
|
45
60
|
/** Timer source for the auto-close countdown; tests inject a ManualClock. */
|
|
46
61
|
clock?: Clock;
|
|
62
|
+
/** Session source; the sharing gateway injects a blob-backed client. */
|
|
63
|
+
openClient?: () => Promise<SessionClient>;
|
|
64
|
+
/**
|
|
65
|
+
* Who is at the keyboard. `owner` is the local planner (default). `observer`
|
|
66
|
+
* is a passive `cueloop serve` watcher (read-only). `collaborator` is a share
|
|
67
|
+
* viewer: annotates, but cannot edit the plan or submit an agent verdict.
|
|
68
|
+
*/
|
|
69
|
+
role?: "owner" | "observer" | "collaborator";
|
|
47
70
|
}
|
|
48
71
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
function reviewerAnnotations(session: ReviewSession) {
|
|
58
|
-
return session.annotations.filter((annotation) => !isAgentNote(annotation));
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function defaultVerdict(session: ReviewSession): VerdictKind {
|
|
62
|
-
return reviewerAnnotations(session).length || session.workingCopy !== undefined ? "request_changes" : "approve";
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function App({ home, sessionId, readOnly = false, onExit, clock }: AppProps): React.ReactNode {
|
|
72
|
+
export function App({ home, sessionId, readOnly = false, onExit, clock, openClient, role = "owner" }: AppProps): React.ReactNode {
|
|
73
|
+
// Observer stays fully read-only; a collaborator writes annotations but not
|
|
74
|
+
// the plan or a verdict. `observer` is what the controller and every write
|
|
75
|
+
// gate key off; the two capability flags carve out the collaborator's middle.
|
|
76
|
+
const observer = readOnly || role === "observer";
|
|
77
|
+
const canEditPlan = !observer && role !== "collaborator";
|
|
78
|
+
const canSubmitVerdict = !observer && role !== "collaborator";
|
|
79
|
+
const canShare = !observer && role === "owner";
|
|
66
80
|
const controller = useMemo(
|
|
67
|
-
() => createReviewController({ home, sessionId, readOnly, onExit, clock }),
|
|
81
|
+
() => createReviewController({ home, sessionId, readOnly: observer, onExit, clock, openClient }),
|
|
68
82
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
69
83
|
[home, sessionId],
|
|
70
84
|
);
|
|
@@ -78,7 +92,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
78
92
|
controller.getSnapshot,
|
|
79
93
|
);
|
|
80
94
|
const renderer = useRenderer();
|
|
81
|
-
const { width: terminalWidth } = useTerminalDimensions();
|
|
95
|
+
const { width: terminalWidth, height: terminalHeight } = useTerminalDimensions();
|
|
82
96
|
|
|
83
97
|
// ── view state ──────────────────────────────
|
|
84
98
|
const [cursor, setCursor] = useState(0);
|
|
@@ -86,6 +100,13 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
86
100
|
const [mode, setMode] = useState<Mode>({ type: "normal" });
|
|
87
101
|
const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
|
|
88
102
|
const [railTab, setRailTab] = useState<"review" | "agent">("review");
|
|
103
|
+
// review panel layout: mode + expanded width are client view state, loaded
|
|
104
|
+
// from and persisted to the user config so they survive a restart. The ref
|
|
105
|
+
// mirrors the width so the drag-end persist reads the latest value.
|
|
106
|
+
const [reviewMode, setReviewMode] = useState<ReviewPanelMode>("expanded");
|
|
107
|
+
const [reviewWidth, setReviewWidth] = useState(REVIEW_DEFAULT_WIDTH);
|
|
108
|
+
const [dividerDragging, setDividerDragging] = useState(false);
|
|
109
|
+
const reviewWidthRef = useRef(REVIEW_DEFAULT_WIDTH);
|
|
89
110
|
// ~2s focus pulse on the document highlight when a rail card is activated
|
|
90
111
|
const [pulsedAnnotationId, setPulsedAnnotationId] = useState<string | null>(null);
|
|
91
112
|
const pulseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
@@ -103,6 +124,9 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
103
124
|
keysRef.current = config.keys;
|
|
104
125
|
keyBindings.setKeys(config.keys);
|
|
105
126
|
setTheme(config.theme);
|
|
127
|
+
setReviewMode(config.ui.reviewState);
|
|
128
|
+
setReviewWidth(config.ui.reviewWidth);
|
|
129
|
+
reviewWidthRef.current = config.ui.reviewWidth;
|
|
106
130
|
controller.applyConfig(config);
|
|
107
131
|
}, [session?.workspace.repoRoot, controller, keyBindings]);
|
|
108
132
|
useEffect(
|
|
@@ -172,7 +196,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
172
196
|
};
|
|
173
197
|
|
|
174
198
|
const openCardEdit = (annotationId: string): void => {
|
|
175
|
-
if (
|
|
199
|
+
if (observer) return controller.setStatus("observer - read-only");
|
|
176
200
|
if (resolved) return controller.setStatus("review submitted - read-only");
|
|
177
201
|
const annotation = session?.annotations.find((candidate) => candidate.id === annotationId);
|
|
178
202
|
if (!annotation) return;
|
|
@@ -191,143 +215,36 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
191
215
|
};
|
|
192
216
|
|
|
193
217
|
// ── keyboard grammar: build state, reduce, dispatch ──
|
|
194
|
-
const dispatch = (
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
225
|
-
case "spanKey":
|
|
226
|
-
if (mode.type === "span") {
|
|
227
|
-
const span = spanKey(mode.span, intent.name, displayText(display[mode.span.displayIndex]!));
|
|
228
|
-
setMode({ type: "span", span });
|
|
229
|
-
}
|
|
230
|
-
return;
|
|
231
|
-
case "openCompose": {
|
|
232
|
-
liveInput.current = "";
|
|
233
|
-
if (intent.from === "span" && mode.type === "span") {
|
|
234
|
-
setMode({
|
|
235
|
-
type: "compose",
|
|
236
|
-
kind: intent.kind,
|
|
237
|
-
displayIndex: mode.span.displayIndex,
|
|
238
|
-
start: mode.span.start,
|
|
239
|
-
end: mode.span.end,
|
|
240
|
-
text: "",
|
|
241
|
-
});
|
|
242
|
-
} else if (isDiff) {
|
|
243
|
-
const row = rows[cursor];
|
|
244
|
-
if (row) setMode({ type: "compose", kind: intent.kind, displayIndex: cursor, start: 0, end: row.text.length, text: "" });
|
|
245
|
-
} else {
|
|
246
|
-
// a mouse drag leaves a native selection; it wins over the cursor block
|
|
247
|
-
const native = planSheetRef.current?.readSelection() ?? null;
|
|
248
|
-
if (native) {
|
|
249
|
-
setMode({ type: "compose", kind: intent.kind, ...native, text: "" });
|
|
250
|
-
} else {
|
|
251
|
-
const block = display[cursor];
|
|
252
|
-
if (block) setMode({ type: "compose", kind: intent.kind, displayIndex: cursor, start: 0, end: displayText(block).length, text: "" });
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
case "openSubmit":
|
|
258
|
-
if (!session) return;
|
|
259
|
-
liveInput.current = "";
|
|
260
|
-
// the confirm card lives in the review tab; opening submit reveals it
|
|
261
|
-
setRailTab("review");
|
|
262
|
-
return void setMode({ type: "submit", verdict: defaultVerdict(session), summary: "" });
|
|
263
|
-
case "cut":
|
|
264
|
-
return controller.cut(cursor);
|
|
265
|
-
case "edit":
|
|
266
|
-
return runEditorHandOff();
|
|
267
|
-
case "editCard":
|
|
268
|
-
if (focusedAnnotationId) openCardEdit(focusedAnnotationId);
|
|
269
|
-
return;
|
|
270
|
-
case "nextAnnotation":
|
|
271
|
-
case "prevAnnotation": {
|
|
272
|
-
const annotations = session?.annotations ?? [];
|
|
273
|
-
if (!annotations.length) return;
|
|
274
|
-
const focusedIndex = annotations.findIndex((annotation) => annotation.id === focusedAnnotationId);
|
|
275
|
-
const nextIndex =
|
|
276
|
-
focusedIndex === -1
|
|
277
|
-
? 0
|
|
278
|
-
: (focusedIndex + (intent.type === "nextAnnotation" ? 1 : -1) + annotations.length) % annotations.length;
|
|
279
|
-
return void selectCardFromDocument(annotations[nextIndex]!.id);
|
|
280
|
-
}
|
|
281
|
-
case "walkStart":
|
|
282
|
-
return controller.walkStart();
|
|
283
|
-
case "walkForward":
|
|
284
|
-
return controller.walkForward();
|
|
285
|
-
case "walkBack":
|
|
286
|
-
return controller.walkBack();
|
|
287
|
-
case "walkLeave":
|
|
288
|
-
return controller.walkLeave();
|
|
289
|
-
case "removeAnnotation":
|
|
290
|
-
if (focusedAnnotationId) {
|
|
291
|
-
controller.removeAnnotation(focusedAnnotationId);
|
|
292
|
-
setFocusedAnnotationId(undefined);
|
|
293
|
-
}
|
|
294
|
-
return;
|
|
295
|
-
case "deselect":
|
|
296
|
-
planSheetRef.current?.clearSelection();
|
|
297
|
-
setFocusedAnnotationId(undefined);
|
|
298
|
-
setPulsedAnnotationId(null);
|
|
299
|
-
return;
|
|
300
|
-
case "closeOverlay":
|
|
301
|
-
return void setMode({ type: "normal" });
|
|
302
|
-
case "saveCompose": {
|
|
303
|
-
const body = liveInput.current.trim();
|
|
304
|
-
if (mode.type === "railEdit") {
|
|
305
|
-
if (session && body) controller.updateAnnotation(mode.id, body);
|
|
306
|
-
return void setMode({ type: "normal" });
|
|
307
|
-
}
|
|
308
|
-
if (mode.type !== "compose") return;
|
|
309
|
-
if (session && body) {
|
|
310
|
-
const annotationId = controller.annotate(mode.kind, mode.displayIndex, mode.start, mode.end, body);
|
|
311
|
-
if (annotationId) setFocusedAnnotationId(annotationId);
|
|
312
|
-
}
|
|
313
|
-
return void setMode({ type: "normal" });
|
|
314
|
-
}
|
|
315
|
-
case "submitVerdict":
|
|
316
|
-
if (mode.type === "submit") controller.submit(mode.verdict, liveInput.current);
|
|
317
|
-
return void setMode({ type: "normal" });
|
|
318
|
-
case "cycleVerdict": {
|
|
319
|
-
if (mode.type !== "submit") return;
|
|
320
|
-
const verdictIndex = (VERDICTS.indexOf(mode.verdict) + intent.dir + VERDICTS.length) % VERDICTS.length;
|
|
321
|
-
return void setMode({ ...mode, verdict: VERDICTS[verdictIndex]! });
|
|
322
|
-
}
|
|
323
|
-
case "finishReview":
|
|
324
|
-
return controller.finishReview();
|
|
325
|
-
case "optInAutoClose":
|
|
326
|
-
return controller.optInAutoClose();
|
|
327
|
-
case "dismissCompletion":
|
|
328
|
-
return controller.dismissCompletion();
|
|
329
|
-
}
|
|
330
|
-
};
|
|
218
|
+
const dispatch = createIntentDispatch({
|
|
219
|
+
controller,
|
|
220
|
+
onExit,
|
|
221
|
+
isDiff,
|
|
222
|
+
display,
|
|
223
|
+
rows,
|
|
224
|
+
cursor,
|
|
225
|
+
inbox,
|
|
226
|
+
inboxCursor,
|
|
227
|
+
mode,
|
|
228
|
+
session,
|
|
229
|
+
reviewMode,
|
|
230
|
+
reviewWidth,
|
|
231
|
+
terminalWidth,
|
|
232
|
+
focusedAnnotationId,
|
|
233
|
+
liveInput,
|
|
234
|
+
reviewWidthRef,
|
|
235
|
+
planSheetRef,
|
|
236
|
+
setCursor,
|
|
237
|
+
setInboxCursor,
|
|
238
|
+
setMode,
|
|
239
|
+
setReviewMode,
|
|
240
|
+
setReviewWidth,
|
|
241
|
+
setRailTab,
|
|
242
|
+
setFocusedAnnotationId,
|
|
243
|
+
setPulsedAnnotationId,
|
|
244
|
+
selectCardFromDocument,
|
|
245
|
+
runEditorHandOff,
|
|
246
|
+
openCardEdit,
|
|
247
|
+
});
|
|
331
248
|
|
|
332
249
|
const overlay: KeyState["overlay"] =
|
|
333
250
|
mode.type === "compose" || mode.type === "railEdit"
|
|
@@ -345,7 +262,10 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
345
262
|
useKeyboard((key) => {
|
|
346
263
|
const state: KeyState = {
|
|
347
264
|
keys: keysRef.current,
|
|
348
|
-
readOnly,
|
|
265
|
+
readOnly: observer,
|
|
266
|
+
canEditPlan,
|
|
267
|
+
canSubmitVerdict,
|
|
268
|
+
canShare,
|
|
349
269
|
overlay,
|
|
350
270
|
view: !session ? "inbox" : isDiff ? "diff" : "plan",
|
|
351
271
|
spanMode: mode.type === "span",
|
|
@@ -360,7 +280,8 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
360
280
|
};
|
|
361
281
|
keyBindings.setContext({ overlay: state.overlay, spanMode: state.spanMode });
|
|
362
282
|
const action = keyBindings.resolveAction({ name: key.name, shift: !!key.shift });
|
|
363
|
-
for (const intent of reduceKey(state, { name: key.name, shift: !!key.shift }, action))
|
|
283
|
+
for (const intent of reduceKey(state, { name: key.name, shift: !!key.shift, meta: !!key.meta }, action))
|
|
284
|
+
dispatch(intent);
|
|
364
285
|
});
|
|
365
286
|
|
|
366
287
|
// ── render ──────────────────────────────────
|
|
@@ -437,18 +358,38 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
437
358
|
};
|
|
438
359
|
|
|
439
360
|
const onEditRequest = (): void => {
|
|
440
|
-
if (
|
|
361
|
+
if (observer) return controller.setStatus("observer - read-only");
|
|
362
|
+
if (!canEditPlan) return controller.setStatus("shared plan - edit it in your own copy");
|
|
441
363
|
if (resolved) return controller.setStatus("review submitted - read-only");
|
|
442
364
|
runEditorHandOff();
|
|
443
365
|
};
|
|
444
366
|
|
|
367
|
+
// clicking the rail Share button: publish the plan, copy the ssh line
|
|
368
|
+
const onShareRequest = (): void => {
|
|
369
|
+
if (!canShare) return controller.setStatus("only the plan owner can share");
|
|
370
|
+
controller.share();
|
|
371
|
+
};
|
|
372
|
+
|
|
445
373
|
// clicking the rail Submit button: same read-only answer as the submit key
|
|
446
374
|
const onSubmitRequest = (): void => {
|
|
447
|
-
if (
|
|
375
|
+
if (observer) return controller.setStatus("observer - read-only");
|
|
376
|
+
if (!canSubmitVerdict) return controller.setStatus("shared view - your notes save as you go");
|
|
448
377
|
if (resolved) return;
|
|
449
378
|
dispatch({ type: "openSubmit" });
|
|
450
379
|
};
|
|
451
380
|
|
|
381
|
+
// the clickable chevron toggles expanded <-> compact; the keybinding (b)
|
|
382
|
+
// cycles all three including hidden
|
|
383
|
+
const onToggleReviewPanel = (): void => {
|
|
384
|
+
const next = toggleReviewPanelMode(reviewMode);
|
|
385
|
+
setReviewMode(next);
|
|
386
|
+
controller.saveReviewPanel({ mode: next });
|
|
387
|
+
};
|
|
388
|
+
// grabbing the divider only arms a drag when there is a width to drag
|
|
389
|
+
const onDividerGrab = (): void => {
|
|
390
|
+
if (reviewMode === "expanded") setDividerDragging(true);
|
|
391
|
+
};
|
|
392
|
+
|
|
452
393
|
const submitConfirmState =
|
|
453
394
|
mode.type === "submit"
|
|
454
395
|
? {
|
|
@@ -489,12 +430,13 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
489
430
|
{ label: "cueloop", tone: "accent" },
|
|
490
431
|
{ label: `${activeSession.artifact.meta.title ?? activeSession.artifact.meta.planPath ?? activeSession.id} · rev ${activeSession.revisions.length}`, tone: "dim" },
|
|
491
432
|
...(resolved ? [{ label: `resolved: ${activeSession.verdict!.kind.replace("_", " ")}`, tone: "green" as const }] : []),
|
|
492
|
-
...(
|
|
433
|
+
...(observer ? [{ label: "observer", tone: "dim" as const }] : []),
|
|
434
|
+
...(role === "collaborator" ? [{ label: "shared · your notes save as you go", tone: "dim" as const }] : []),
|
|
493
435
|
...(status ? [{ label: status, tone: "accent" as const }] : []),
|
|
494
436
|
];
|
|
495
437
|
|
|
496
438
|
keyBindings.setContext({ overlay, spanMode: mode.type === "span" });
|
|
497
|
-
const hintMode: HintMode =
|
|
439
|
+
const hintMode: HintMode = observer
|
|
498
440
|
? "read-only"
|
|
499
441
|
: mode.type === "submit"
|
|
500
442
|
? "submit"
|
|
@@ -507,11 +449,23 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
507
449
|
: focusedAnnotationId !== undefined
|
|
508
450
|
? "card"
|
|
509
451
|
: "normal";
|
|
510
|
-
const railWidth = terminalWidth >= 100 ? 34 : 28;
|
|
511
452
|
|
|
512
453
|
return (
|
|
513
454
|
<ThemeProvider theme={theme}>
|
|
514
|
-
<box
|
|
455
|
+
<box
|
|
456
|
+
style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: theme.bg }}
|
|
457
|
+
onMouseDrag={(event: MouseEvent) => {
|
|
458
|
+
if (!dividerDragging || reviewMode !== "expanded") return;
|
|
459
|
+
const next = widthFromMouseColumn(event.x, terminalWidth);
|
|
460
|
+
reviewWidthRef.current = next;
|
|
461
|
+
setReviewWidth(next);
|
|
462
|
+
}}
|
|
463
|
+
onMouseUp={() => {
|
|
464
|
+
if (!dividerDragging) return;
|
|
465
|
+
setDividerDragging(false);
|
|
466
|
+
controller.saveReviewPanel({ width: reviewWidthRef.current });
|
|
467
|
+
}}
|
|
468
|
+
>
|
|
515
469
|
<Breadcrumb items={headerItems} />
|
|
516
470
|
<box style={{ flexGrow: 1, flexDirection: "row" }}>
|
|
517
471
|
{isDiff ? (
|
|
@@ -537,20 +491,28 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
537
491
|
onEditRequest={onEditRequest}
|
|
538
492
|
/>
|
|
539
493
|
)}
|
|
540
|
-
<
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
494
|
+
<ReviewPanel
|
|
495
|
+
mode={reviewMode}
|
|
496
|
+
width={resolveReviewWidth(reviewWidth, terminalWidth)}
|
|
497
|
+
height={terminalHeight - CHROME_ROWS}
|
|
498
|
+
dragging={dividerDragging}
|
|
499
|
+
onDividerGrab={onDividerGrab}
|
|
500
|
+
onToggle={onToggleReviewPanel}
|
|
501
|
+
railRef={railRef}
|
|
502
|
+
rail={{
|
|
503
|
+
session: activeSession,
|
|
504
|
+
selectedId: focusedAnnotationId,
|
|
505
|
+
resolvedIds: isDiff ? null : resolvedIds,
|
|
506
|
+
railTab,
|
|
507
|
+
pendingCount,
|
|
508
|
+
cardEdit: cardEditState,
|
|
509
|
+
submitConfirm: submitConfirmState,
|
|
510
|
+
onTabChange: setRailTab,
|
|
511
|
+
onSelectCard: selectCardFromRail,
|
|
512
|
+
onActivateCard: openCardEdit,
|
|
513
|
+
onSubmitRequest,
|
|
514
|
+
onShareRequest: canShare ? onShareRequest : undefined,
|
|
515
|
+
}}
|
|
554
516
|
/>
|
|
555
517
|
</box>
|
|
556
518
|
{mode.type === "compose" && isDiff ? (
|
package/src/App.walk.test.tsx
CHANGED
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The guided walk in the virtual terminal: the wizard steps every
|
|
3
|
-
* changed file with a plain progress title, ] marks viewed and persists with
|
|
4
|
-
* the session, esc keeps progress, w resumes at the first unviewed file
|
|
5
|
-
* (across App instances - the daemon round-trip), the agent-note block
|
|
6
|
-
* renders only for files carrying a note, and the end card hands over to the
|
|
7
|
-
* submit confirm with the honest viewed count.
|
|
8
|
-
*/
|
|
1
|
+
/** The guided walk in the virtual terminal: the wizard steps every changed file with a plain progress title, ] marks viewed and persists with the session, esc keeps progress, w resumes at the first unviewed file (across App instances via the daemon round-trip), the agent-note block renders only for files carrying a note, and the end card hands over to the submit confirm with the honest viewed count. */
|
|
9
2
|
|
|
10
3
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
11
4
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
@@ -87,56 +80,93 @@ function foregroundsOf(setup: Setup, needle: string): string[] {
|
|
|
87
80
|
|
|
88
81
|
describe("the guided walk", () => {
|
|
89
82
|
test("w walks all three files: progress title, persisted marks, end card, submit hand-over", async () => {
|
|
83
|
+
// Arrange
|
|
90
84
|
const setup = await renderApp();
|
|
85
|
+
|
|
86
|
+
// Act
|
|
91
87
|
await press(setup, "w");
|
|
88
|
+
|
|
89
|
+
// Assert
|
|
92
90
|
await waitForText(setup, "file 1 of 3 · 0 viewed");
|
|
93
91
|
expect(setup.captureCharFrame()).toContain("src/a.ts");
|
|
94
92
|
expect(setup.captureCharFrame()).toContain("+1 -1");
|
|
95
93
|
|
|
94
|
+
// Act
|
|
96
95
|
await press(setup, "]");
|
|
96
|
+
|
|
97
|
+
// Assert
|
|
97
98
|
await waitForText(setup, "file 2 of 3 · 1 viewed");
|
|
98
99
|
// the viewed mark rides the session record, not the client
|
|
99
100
|
await waitForState(setup, () => (server.core.sessionGet(session.id).viewedPaths ?? []).includes("src/a.ts"));
|
|
100
101
|
|
|
102
|
+
// Act
|
|
101
103
|
// [ steps back; the revisited card shows its viewed marker
|
|
102
104
|
await press(setup, "[");
|
|
105
|
+
|
|
106
|
+
// Assert
|
|
103
107
|
await waitForText(setup, "file 1 of 3 · 1 viewed");
|
|
104
108
|
expect(setup.captureCharFrame()).toContain("· viewed");
|
|
109
|
+
|
|
110
|
+
// Act
|
|
105
111
|
await press(setup, "]");
|
|
112
|
+
|
|
113
|
+
// Assert
|
|
106
114
|
await waitForText(setup, "file 2 of 3 · 1 viewed");
|
|
107
115
|
|
|
116
|
+
// Act
|
|
108
117
|
await press(setup, "]");
|
|
118
|
+
|
|
119
|
+
// Assert
|
|
109
120
|
await waitForText(setup, "file 3 of 3 · 2 viewed");
|
|
121
|
+
|
|
122
|
+
// Act
|
|
110
123
|
await press(setup, "]");
|
|
124
|
+
|
|
125
|
+
// Assert
|
|
111
126
|
await waitForText(setup, "walk complete");
|
|
112
127
|
expect(setup.captureCharFrame()).toContain("every file viewed (3/3)");
|
|
113
128
|
expect(server.core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
|
|
114
129
|
|
|
130
|
+
// Act
|
|
115
131
|
// the end card's return leaves the walk and opens the rail confirm
|
|
116
132
|
await press(setup, "enter");
|
|
133
|
+
|
|
134
|
+
// Assert
|
|
117
135
|
await waitForText(setup, "submit review");
|
|
118
136
|
expect(setup.captureCharFrame()).toContain("3/3 files viewed");
|
|
137
|
+
|
|
138
|
+
// Act
|
|
119
139
|
await press(setup, "enter");
|
|
140
|
+
|
|
141
|
+
// Assert
|
|
120
142
|
await waitForState(setup, () => server.core.sessionGet(session.id).status === "resolved");
|
|
121
143
|
});
|
|
122
144
|
|
|
123
145
|
test("esc keeps progress and w resumes at the first unviewed file", async () => {
|
|
146
|
+
// Arrange
|
|
124
147
|
const setup = await renderApp();
|
|
125
148
|
await press(setup, "w");
|
|
126
149
|
await waitForText(setup, "file 1 of 3 · 0 viewed");
|
|
127
150
|
await press(setup, "]");
|
|
128
151
|
await waitForText(setup, "file 2 of 3 · 1 viewed");
|
|
129
152
|
|
|
153
|
+
// Act
|
|
130
154
|
await press(setup, "escape");
|
|
155
|
+
|
|
156
|
+
// Assert
|
|
131
157
|
await waitForTextGone(setup, "file 2 of 3");
|
|
132
158
|
// the sheet is back at full strength: no wizard card on screen
|
|
133
159
|
expect(setup.captureCharFrame()).not.toContain("· 1 viewed");
|
|
134
160
|
|
|
161
|
+
// Act
|
|
135
162
|
await press(setup, "w");
|
|
163
|
+
|
|
164
|
+
// Assert
|
|
136
165
|
await waitForText(setup, "file 2 of 3 · 1 viewed");
|
|
137
166
|
});
|
|
138
167
|
|
|
139
168
|
test("a half-walked review resumes across App instances via the daemon", async () => {
|
|
169
|
+
// Arrange
|
|
140
170
|
const first = await renderApp();
|
|
141
171
|
await press(first, "w");
|
|
142
172
|
await waitForText(first, "file 1 of 3 · 0 viewed");
|
|
@@ -146,15 +176,19 @@ describe("the guided walk", () => {
|
|
|
146
176
|
await waitForState(first, () => (server.core.sessionGet(session.id).viewedPaths ?? []).length === 2);
|
|
147
177
|
first.renderer.destroy();
|
|
148
178
|
|
|
179
|
+
// Act
|
|
149
180
|
// a fresh client reads the viewed set back from the session record
|
|
150
181
|
const second = await renderApp();
|
|
151
182
|
await press(second, "w");
|
|
183
|
+
|
|
184
|
+
// Assert
|
|
152
185
|
await waitForText(second, "file 3 of 3 · 2 viewed");
|
|
153
186
|
expect(second.captureCharFrame()).toContain("src/c.ts");
|
|
154
187
|
// two full App boots with daemon round-trips need more than the default budget
|
|
155
188
|
}, 15_000);
|
|
156
189
|
|
|
157
190
|
test("the agent-note block renders only for files carrying a note", async () => {
|
|
191
|
+
// Arrange
|
|
158
192
|
server.core.sessionAnnotate(session.id, {
|
|
159
193
|
id: "note-b",
|
|
160
194
|
kind: "note",
|
|
@@ -162,14 +196,22 @@ describe("the guided walk", () => {
|
|
|
162
196
|
body: "Swaps the stale line for the new constant.",
|
|
163
197
|
});
|
|
164
198
|
const setup = await renderApp();
|
|
199
|
+
|
|
200
|
+
// Act
|
|
165
201
|
await press(setup, "w");
|
|
202
|
+
|
|
203
|
+
// Assert
|
|
166
204
|
await waitForText(setup, "file 1 of 3");
|
|
167
205
|
expect(setup.captureCharFrame()).not.toContain("agent note");
|
|
168
206
|
|
|
207
|
+
// Act
|
|
169
208
|
await press(setup, "]");
|
|
209
|
+
|
|
210
|
+
// Assert
|
|
170
211
|
await waitForText(setup, "agent note");
|
|
171
212
|
expect(setup.captureCharFrame()).toContain("Swaps the stale line for the new constant.");
|
|
172
213
|
|
|
214
|
+
// Act
|
|
173
215
|
// a note is agent context, not reviewer feedback: it neither counts nor
|
|
174
216
|
// flips the default verdict, and never comes back in the feedback doc
|
|
175
217
|
await press(setup, "escape");
|
|
@@ -177,6 +219,8 @@ describe("the guided walk", () => {
|
|
|
177
219
|
await press(setup, "enter");
|
|
178
220
|
await waitForText(setup, "0 annotations");
|
|
179
221
|
await press(setup, "enter");
|
|
222
|
+
|
|
223
|
+
// Assert
|
|
180
224
|
await waitForState(setup, () => server.core.sessionGet(session.id).status === "resolved");
|
|
181
225
|
const resolved = server.core.sessionGet(session.id);
|
|
182
226
|
expect(resolved.verdict!.kind).toBe("approve");
|
|
@@ -184,12 +228,18 @@ describe("the guided walk", () => {
|
|
|
184
228
|
});
|
|
185
229
|
|
|
186
230
|
test("the sheet dims behind the wizard; the preview keeps the diff colors", async () => {
|
|
231
|
+
// Arrange
|
|
187
232
|
const setup = await renderApp();
|
|
233
|
+
|
|
234
|
+
// Assert
|
|
188
235
|
// before the walk the sheet's added line wears the insertion color
|
|
189
236
|
await waitForText(setup, "+new line");
|
|
190
237
|
expect(foregroundsOf(setup, "+new line")).toContain(DARK.insFg);
|
|
191
238
|
|
|
239
|
+
// Act
|
|
192
240
|
await press(setup, "w");
|
|
241
|
+
|
|
242
|
+
// Assert
|
|
193
243
|
await waitForText(setup, "file 1 of 3 · 0 viewed");
|
|
194
244
|
// dimmed: the sheet line drops to the dim token...
|
|
195
245
|
expect(foregroundsOf(setup, "+new line")).toEqual([DARK.textDim]);
|
|
@@ -198,10 +248,15 @@ describe("the guided walk", () => {
|
|
|
198
248
|
});
|
|
199
249
|
|
|
200
250
|
test("walking a resolved review answers read-only", async () => {
|
|
251
|
+
// Arrange
|
|
201
252
|
server.core.sessionResolve(session.id, "approve", "");
|
|
202
253
|
const setup = await renderApp();
|
|
203
254
|
await waitForText(setup, "resolved");
|
|
255
|
+
|
|
256
|
+
// Act
|
|
204
257
|
await press(setup, "w");
|
|
258
|
+
|
|
259
|
+
// Assert
|
|
205
260
|
await waitForText(setup, "review submitted - read-only");
|
|
206
261
|
expect(setup.captureCharFrame()).not.toContain("file 1 of 3");
|
|
207
262
|
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { clipboardCommands } from "./clipboard";
|
|
3
|
+
|
|
4
|
+
describe(clipboardCommands, () => {
|
|
5
|
+
test("uses pbcopy on macOS", () => {
|
|
6
|
+
// Assert
|
|
7
|
+
expect(clipboardCommands("darwin")).toEqual([["pbcopy"]]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("uses clip.exe on Windows", () => {
|
|
11
|
+
// Assert
|
|
12
|
+
expect(clipboardCommands("win32")).toEqual([["clip.exe"]]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("prefers wayland, then X11 tools on Linux", () => {
|
|
16
|
+
// Act
|
|
17
|
+
const commands = clipboardCommands("linux");
|
|
18
|
+
|
|
19
|
+
// Assert
|
|
20
|
+
expect(commands[0]).toEqual(["wl-copy"]);
|
|
21
|
+
expect(commands.map((command) => command[0])).toEqual(["wl-copy", "xclip", "xsel"]);
|
|
22
|
+
});
|
|
23
|
+
});
|