@cueloop/client 0.1.0-alpha.15 → 0.1.0-alpha.16
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.tsx +67 -12
- package/src/App.walk.test.tsx +208 -0
- package/src/components/ConfirmCard.stories.tsx +15 -0
- package/src/components/ConfirmCard.tsx +8 -1
- package/src/components/WalkWizard.stories.tsx +89 -0
- package/src/components/WalkWizard.tsx +146 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +130 -0
- package/src/config.ts +1 -0
- package/src/key-bindings.test.ts +18 -1
- package/src/key-bindings.ts +22 -1
- package/src/keymap.test.ts +37 -0
- package/src/keymap.ts +27 -2
- package/src/session-controller.ts +76 -2
- package/src/theme.ts +19 -0
- package/src/walk.test.ts +110 -0
- package/src/walk.ts +65 -0
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.16",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.ts"
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
11
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
12
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
13
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
10
|
+
"@cueloop/daemon": "0.1.0-alpha.16",
|
|
11
|
+
"@cueloop/extension-api": "0.1.0-alpha.16",
|
|
12
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.16",
|
|
13
|
+
"@cueloop/schema": "0.1.0-alpha.16",
|
|
14
14
|
"@opentui/core": "^0.5.1",
|
|
15
15
|
"@opentui/keymap": "0.5.1",
|
|
16
16
|
"@opentui/react": "^0.5.1",
|
package/src/App.tsx
CHANGED
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
17
17
|
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
|
|
18
18
|
import type { Clock } from "@opentui/core";
|
|
19
|
-
import { type ReviewSession, type VerdictKind } from "@cueloop/schema";
|
|
19
|
+
import { isAgentNote, type ReviewSession, type VerdictKind } from "@cueloop/schema";
|
|
20
20
|
import { displayText, marksByDisplay, spanKey, startSpan, type Mark, type SpanState } from "./view";
|
|
21
|
-
import {
|
|
21
|
+
import { noteForFile, viewedCount } from "./walk";
|
|
22
|
+
import { DARK, dimmedTheme } from "./theme";
|
|
22
23
|
import { DEFAULT_KEYS, loadConfig } from "./config";
|
|
23
24
|
import { returnPaneFor } from "./herdr";
|
|
24
25
|
import { createReviewController } from "./session-controller";
|
|
@@ -34,6 +35,7 @@ import { VERDICTS } from "./components/ConfirmCard";
|
|
|
34
35
|
import { CompletionOverlay } from "./components/CompletionOverlay";
|
|
35
36
|
import { InboxList } from "./components/InboxList";
|
|
36
37
|
import { ComposeBar } from "./components/ComposeBar";
|
|
38
|
+
import { WalkWizard } from "./components/WalkWizard";
|
|
37
39
|
|
|
38
40
|
export interface AppProps {
|
|
39
41
|
home?: string;
|
|
@@ -55,8 +57,13 @@ type Mode =
|
|
|
55
57
|
| { m: "railEdit"; id: string; text: string }
|
|
56
58
|
| { m: "submit"; verdict: VerdictKind; summary: string };
|
|
57
59
|
|
|
60
|
+
/** Reviewer-authored annotations only: agent notes never count as feedback. */
|
|
61
|
+
function reviewerAnnotations(session: ReviewSession) {
|
|
62
|
+
return session.annotations.filter((annotation) => !isAgentNote(annotation));
|
|
63
|
+
}
|
|
64
|
+
|
|
58
65
|
function defaultVerdict(session: ReviewSession): VerdictKind {
|
|
59
|
-
return session.
|
|
66
|
+
return reviewerAnnotations(session).length || session.workingCopy !== undefined ? "request_changes" : "approve";
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
export function App({ home, sessionId, readOnly = false, onExit, clock }: AppProps): React.ReactNode {
|
|
@@ -69,7 +76,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
69
76
|
controller.connect();
|
|
70
77
|
return () => controller.close();
|
|
71
78
|
}, [controller]);
|
|
72
|
-
const { session, inbox, status, error, completion, editOrphanCount } = useSyncExternalStore(
|
|
79
|
+
const { session, inbox, status, error, completion, editOrphanCount, walk } = useSyncExternalStore(
|
|
73
80
|
controller.subscribe,
|
|
74
81
|
controller.getSnapshot,
|
|
75
82
|
controller.getSnapshot,
|
|
@@ -127,6 +134,11 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
127
134
|
const resolved = session?.status === "resolved";
|
|
128
135
|
const isDiff = session?.artifact.type === "diff";
|
|
129
136
|
|
|
137
|
+
// ── the guided walk's view model ────────────
|
|
138
|
+
const walkFileList = controller.files();
|
|
139
|
+
const walking = isDiff && walk !== null;
|
|
140
|
+
const viewedPaths = useMemo(() => new Set(session?.viewedPaths ?? []), [session]);
|
|
141
|
+
|
|
130
142
|
// driving needs committed layout, so it runs after render; any transition
|
|
131
143
|
// out of span mode clears the renderer selection (compose paints its own
|
|
132
144
|
// mark, and a mouse drag never changes the mode, so it survives)
|
|
@@ -267,6 +279,14 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
267
279
|
const next = idx === -1 ? 0 : (idx + (intent.t === "nextAnn" ? 1 : -1) + anns.length) % anns.length;
|
|
268
280
|
return void selectCardFromDocument(anns[next]!.id);
|
|
269
281
|
}
|
|
282
|
+
case "walkStart":
|
|
283
|
+
return controller.walkStart();
|
|
284
|
+
case "walkForward":
|
|
285
|
+
return controller.walkForward();
|
|
286
|
+
case "walkBack":
|
|
287
|
+
return controller.walkBack();
|
|
288
|
+
case "walkLeave":
|
|
289
|
+
return controller.walkLeave();
|
|
270
290
|
case "removeAnnotation":
|
|
271
291
|
if (focusedAnn) {
|
|
272
292
|
controller.removeAnnotation(focusedAnn);
|
|
@@ -319,7 +339,9 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
319
339
|
? "completion-prompt"
|
|
320
340
|
: completion.phase === "counting"
|
|
321
341
|
? "completion-counting"
|
|
322
|
-
:
|
|
342
|
+
: walking
|
|
343
|
+
? "walk"
|
|
344
|
+
: "none";
|
|
323
345
|
|
|
324
346
|
useKeyboard((key) => {
|
|
325
347
|
const state: KeyState = {
|
|
@@ -332,6 +354,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
332
354
|
hasInboxItems: !!inbox?.length,
|
|
333
355
|
annotationCount: session?.annotations.length ?? 0,
|
|
334
356
|
hasFocusedAnnotation: focusedAnn !== undefined,
|
|
357
|
+
walkAtEnd: walk !== null && walk.index >= walkFileList.length,
|
|
335
358
|
cursorAnnotatable: isDiff
|
|
336
359
|
? rows[cursor] !== undefined && rows[cursor]!.t !== "file" && rows[cursor]!.t !== "hunk"
|
|
337
360
|
: !!display[cursor]?.work,
|
|
@@ -365,7 +388,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
365
388
|
}
|
|
366
389
|
|
|
367
390
|
const s = session!;
|
|
368
|
-
const pendingCount = s.
|
|
391
|
+
const pendingCount = reviewerAnnotations(s).length + (s.workingCopy !== undefined ? 1 : 0);
|
|
369
392
|
|
|
370
393
|
if ((completion.phase === "prompt" || completion.phase === "counting") && s.verdict) {
|
|
371
394
|
return (
|
|
@@ -432,8 +455,13 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
432
455
|
? {
|
|
433
456
|
verdict: mode.verdict,
|
|
434
457
|
summary: mode.summary,
|
|
435
|
-
annotationCount: s.
|
|
436
|
-
blockingCount: s.
|
|
458
|
+
annotationCount: reviewerAnnotations(s).length,
|
|
459
|
+
blockingCount: reviewerAnnotations(s).filter(annotationBlocking).length,
|
|
460
|
+
// walk coverage keeps partial passes honest at the verdict
|
|
461
|
+
viewedSummary:
|
|
462
|
+
isDiff && s.viewedPaths !== undefined
|
|
463
|
+
? `${viewedCount(walkFileList, viewedPaths)}/${walkFileList.length} files viewed`
|
|
464
|
+
: undefined,
|
|
437
465
|
onInput: (summary: string) => {
|
|
438
466
|
liveInput.current = summary;
|
|
439
467
|
setMode({ ...mode, summary });
|
|
@@ -475,9 +503,11 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
475
503
|
? "span"
|
|
476
504
|
: mode.m === "compose" || mode.m === "railEdit"
|
|
477
505
|
? "compose"
|
|
478
|
-
:
|
|
479
|
-
? "
|
|
480
|
-
:
|
|
506
|
+
: walking
|
|
507
|
+
? "walk"
|
|
508
|
+
: focusedAnn !== undefined
|
|
509
|
+
? "card"
|
|
510
|
+
: "normal";
|
|
481
511
|
const railWidth = terminalWidth >= 100 ? 34 : 28;
|
|
482
512
|
|
|
483
513
|
return (
|
|
@@ -486,7 +516,14 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
486
516
|
<Breadcrumb items={headerItems} />
|
|
487
517
|
<box style={{ flexGrow: 1, flexDirection: "row" }}>
|
|
488
518
|
{isDiff ? (
|
|
489
|
-
|
|
519
|
+
// the sheet dims to reading-quiet colors while the wizard has focus
|
|
520
|
+
<DiffSheet
|
|
521
|
+
rows={rows}
|
|
522
|
+
cursor={cursor}
|
|
523
|
+
annotations={s.annotations}
|
|
524
|
+
focusedAnnotationId={focusedAnn}
|
|
525
|
+
theme={walking ? dimmedTheme(theme) : undefined}
|
|
526
|
+
/>
|
|
490
527
|
) : (
|
|
491
528
|
<PlanSheet
|
|
492
529
|
ref={planSheetRef}
|
|
@@ -530,6 +567,24 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
530
567
|
) : (
|
|
531
568
|
<StatusBar>{keyBindings.statusHint(hintMode)}</StatusBar>
|
|
532
569
|
)}
|
|
570
|
+
{walking && walk !== null ? (
|
|
571
|
+
<WalkWizard
|
|
572
|
+
files={walkFileList}
|
|
573
|
+
index={walk.index}
|
|
574
|
+
viewedPaths={viewedPaths}
|
|
575
|
+
note={
|
|
576
|
+
walkFileList[walk.index] !== undefined
|
|
577
|
+
? noteForFile(s.annotations, walkFileList[walk.index]!.path)
|
|
578
|
+
: undefined
|
|
579
|
+
}
|
|
580
|
+
terminalWidth={terminalWidth}
|
|
581
|
+
onSubmitRequest={() => {
|
|
582
|
+
dispatch({ t: "walkLeave" });
|
|
583
|
+
dispatch({ t: "openSubmit" });
|
|
584
|
+
}}
|
|
585
|
+
onBack={() => dispatch({ t: "walkBack" })}
|
|
586
|
+
/>
|
|
587
|
+
) : null}
|
|
533
588
|
</box>
|
|
534
589
|
</ThemeProvider>
|
|
535
590
|
);
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guided walk in the virtual terminal (#83): 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
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import React from "react";
|
|
15
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
16
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
17
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
18
|
+
import { App } from "./App";
|
|
19
|
+
import { DARK } from "./theme";
|
|
20
|
+
import { press, waitForState, waitForText, waitForTextGone } from "./test-support";
|
|
21
|
+
|
|
22
|
+
const PATCH = `diff --git a/src/a.ts b/src/a.ts
|
|
23
|
+
index 111..222 100644
|
|
24
|
+
--- a/src/a.ts
|
|
25
|
+
+++ b/src/a.ts
|
|
26
|
+
@@ -1,2 +1,2 @@
|
|
27
|
+
export const a = 1;
|
|
28
|
+
-export const b = 2;
|
|
29
|
+
+export const b = 3;
|
|
30
|
+
diff --git a/src/b.ts b/src/b.ts
|
|
31
|
+
index 333..444 100644
|
|
32
|
+
--- a/src/b.ts
|
|
33
|
+
+++ b/src/b.ts
|
|
34
|
+
@@ -1,2 +1,2 @@
|
|
35
|
+
// header
|
|
36
|
+
-old line
|
|
37
|
+
+new line
|
|
38
|
+
diff --git a/src/c.ts b/src/c.ts
|
|
39
|
+
index 555..666 100644
|
|
40
|
+
--- a/src/c.ts
|
|
41
|
+
+++ b/src/c.ts
|
|
42
|
+
@@ -1,1 +1,2 @@
|
|
43
|
+
keep
|
|
44
|
+
+added tail
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
let home: string;
|
|
48
|
+
let server: DaemonServer;
|
|
49
|
+
let session: ReviewSession;
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-walk-"));
|
|
53
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
54
|
+
server.start();
|
|
55
|
+
session = server.core.sessionCreate({
|
|
56
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
57
|
+
artifact: { type: "diff", content: PATCH, meta: { title: "working tree" } },
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
server.stop();
|
|
62
|
+
rmSync(home, { recursive: true, force: true });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
async function renderApp() {
|
|
66
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 34 });
|
|
67
|
+
// Wait for session content, not just the chrome: pressing w before the diff
|
|
68
|
+
// loads drops the walk intent (no session, no diff view) and hangs the test.
|
|
69
|
+
await waitForText(setup, "src/a.ts");
|
|
70
|
+
return setup;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type Setup = Awaited<ReturnType<typeof renderApp>>;
|
|
74
|
+
|
|
75
|
+
/** Foreground colors (hex) of every styled span containing the needle. */
|
|
76
|
+
function foregroundsOf(setup: Setup, needle: string): string[] {
|
|
77
|
+
const foregrounds: string[] = [];
|
|
78
|
+
for (const line of setup.captureSpans().lines) {
|
|
79
|
+
for (const span of line.spans) {
|
|
80
|
+
if (!span.text.includes(needle)) continue;
|
|
81
|
+
const [red, green, blue] = span.fg.toInts();
|
|
82
|
+
foregrounds.push("#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return foregrounds;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe("the guided walk", () => {
|
|
89
|
+
test("w walks all three files: progress title, persisted marks, end card, submit hand-over", async () => {
|
|
90
|
+
const setup = await renderApp();
|
|
91
|
+
await press(setup, "w");
|
|
92
|
+
await waitForText(setup, "file 1 of 3 · 0 viewed");
|
|
93
|
+
expect(setup.captureCharFrame()).toContain("src/a.ts");
|
|
94
|
+
expect(setup.captureCharFrame()).toContain("+1 -1");
|
|
95
|
+
|
|
96
|
+
await press(setup, "]");
|
|
97
|
+
await waitForText(setup, "file 2 of 3 · 1 viewed");
|
|
98
|
+
// the viewed mark rides the session record, not the client
|
|
99
|
+
await waitForState(setup, () => (server.core.sessionGet(session.id).viewedPaths ?? []).includes("src/a.ts"));
|
|
100
|
+
|
|
101
|
+
// [ steps back; the revisited card shows its viewed marker
|
|
102
|
+
await press(setup, "[");
|
|
103
|
+
await waitForText(setup, "file 1 of 3 · 1 viewed");
|
|
104
|
+
expect(setup.captureCharFrame()).toContain("· viewed");
|
|
105
|
+
await press(setup, "]");
|
|
106
|
+
await waitForText(setup, "file 2 of 3 · 1 viewed");
|
|
107
|
+
|
|
108
|
+
await press(setup, "]");
|
|
109
|
+
await waitForText(setup, "file 3 of 3 · 2 viewed");
|
|
110
|
+
await press(setup, "]");
|
|
111
|
+
await waitForText(setup, "walk complete");
|
|
112
|
+
expect(setup.captureCharFrame()).toContain("every file viewed (3/3)");
|
|
113
|
+
expect(server.core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
|
|
114
|
+
|
|
115
|
+
// the end card's return leaves the walk and opens the rail confirm
|
|
116
|
+
await press(setup, "enter");
|
|
117
|
+
await waitForText(setup, "submit review");
|
|
118
|
+
expect(setup.captureCharFrame()).toContain("3/3 files viewed");
|
|
119
|
+
await press(setup, "enter");
|
|
120
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).status === "resolved");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("esc keeps progress and w resumes at the first unviewed file", async () => {
|
|
124
|
+
const setup = await renderApp();
|
|
125
|
+
await press(setup, "w");
|
|
126
|
+
await waitForText(setup, "file 1 of 3 · 0 viewed");
|
|
127
|
+
await press(setup, "]");
|
|
128
|
+
await waitForText(setup, "file 2 of 3 · 1 viewed");
|
|
129
|
+
|
|
130
|
+
await press(setup, "escape");
|
|
131
|
+
await waitForTextGone(setup, "file 2 of 3");
|
|
132
|
+
// the sheet is back at full strength: no wizard card on screen
|
|
133
|
+
expect(setup.captureCharFrame()).not.toContain("· 1 viewed");
|
|
134
|
+
|
|
135
|
+
await press(setup, "w");
|
|
136
|
+
await waitForText(setup, "file 2 of 3 · 1 viewed");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("a half-walked review resumes across App instances via the daemon", async () => {
|
|
140
|
+
const first = await renderApp();
|
|
141
|
+
await press(first, "w");
|
|
142
|
+
await waitForText(first, "file 1 of 3 · 0 viewed");
|
|
143
|
+
await press(first, "]");
|
|
144
|
+
await press(first, "]");
|
|
145
|
+
await waitForText(first, "file 3 of 3 · 2 viewed");
|
|
146
|
+
await waitForState(first, () => (server.core.sessionGet(session.id).viewedPaths ?? []).length === 2);
|
|
147
|
+
first.renderer.destroy();
|
|
148
|
+
|
|
149
|
+
// a fresh client reads the viewed set back from the session record
|
|
150
|
+
const second = await renderApp();
|
|
151
|
+
await press(second, "w");
|
|
152
|
+
await waitForText(second, "file 3 of 3 · 2 viewed");
|
|
153
|
+
expect(second.captureCharFrame()).toContain("src/c.ts");
|
|
154
|
+
// two full App boots with daemon round-trips need more than the default budget
|
|
155
|
+
}, 15_000);
|
|
156
|
+
|
|
157
|
+
test("the agent-note block renders only for files carrying a note", async () => {
|
|
158
|
+
server.core.sessionAnnotate(session.id, {
|
|
159
|
+
id: "note-b",
|
|
160
|
+
kind: "note",
|
|
161
|
+
anchor: { quote: "src/b.ts", prefix: "", suffix: "" },
|
|
162
|
+
body: "Swaps the stale line for the new constant.",
|
|
163
|
+
});
|
|
164
|
+
const setup = await renderApp();
|
|
165
|
+
await press(setup, "w");
|
|
166
|
+
await waitForText(setup, "file 1 of 3");
|
|
167
|
+
expect(setup.captureCharFrame()).not.toContain("agent note");
|
|
168
|
+
|
|
169
|
+
await press(setup, "]");
|
|
170
|
+
await waitForText(setup, "agent note");
|
|
171
|
+
expect(setup.captureCharFrame()).toContain("Swaps the stale line for the new constant.");
|
|
172
|
+
|
|
173
|
+
// a note is agent context, not reviewer feedback: it neither counts nor
|
|
174
|
+
// flips the default verdict, and never comes back in the feedback doc
|
|
175
|
+
await press(setup, "escape");
|
|
176
|
+
await waitForTextGone(setup, "agent note");
|
|
177
|
+
await press(setup, "enter");
|
|
178
|
+
await waitForText(setup, "0 annotations");
|
|
179
|
+
await press(setup, "enter");
|
|
180
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).status === "resolved");
|
|
181
|
+
const resolved = server.core.sessionGet(session.id);
|
|
182
|
+
expect(resolved.verdict!.kind).toBe("approve");
|
|
183
|
+
expect(resolved.verdict!.feedback).not.toContain("Swaps the stale line");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("the sheet dims behind the wizard; the preview keeps the diff colors", async () => {
|
|
187
|
+
const setup = await renderApp();
|
|
188
|
+
// before the walk the sheet's added line wears the insertion color
|
|
189
|
+
await waitForText(setup, "+new line");
|
|
190
|
+
expect(foregroundsOf(setup, "+new line")).toContain(DARK.insFg);
|
|
191
|
+
|
|
192
|
+
await press(setup, "w");
|
|
193
|
+
await waitForText(setup, "file 1 of 3 · 0 viewed");
|
|
194
|
+
// dimmed: the sheet line drops to the dim token...
|
|
195
|
+
expect(foregroundsOf(setup, "+new line")).toEqual([DARK.textDim]);
|
|
196
|
+
// ...while the wizard preview keeps the insertion color
|
|
197
|
+
expect(foregroundsOf(setup, "+export const b = 3;")).toContain(DARK.insFg);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("walking a resolved review answers read-only", async () => {
|
|
201
|
+
server.core.sessionResolve(session.id, "approve", "");
|
|
202
|
+
const setup = await renderApp();
|
|
203
|
+
await waitForText(setup, "resolved");
|
|
204
|
+
await press(setup, "w");
|
|
205
|
+
await waitForText(setup, "review submitted - read-only");
|
|
206
|
+
expect(setup.captureCharFrame()).not.toContain("file 1 of 3");
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -20,3 +20,18 @@ export const RequestChanges: Story = {
|
|
|
20
20
|
expectedColors: [DARK.red],
|
|
21
21
|
size: { width: 40, height: 14 },
|
|
22
22
|
};
|
|
23
|
+
|
|
24
|
+
export const WithViewedSummary: Story = {
|
|
25
|
+
render: () => (
|
|
26
|
+
<ConfirmCard
|
|
27
|
+
verdict="approve"
|
|
28
|
+
summary=""
|
|
29
|
+
annotationCount={1}
|
|
30
|
+
blockingCount={0}
|
|
31
|
+
viewedSummary="2/3 files viewed"
|
|
32
|
+
{...callbacks}
|
|
33
|
+
/>
|
|
34
|
+
),
|
|
35
|
+
expectedColors: [DARK.green, DARK.textDim],
|
|
36
|
+
size: { width: 40, height: 14 },
|
|
37
|
+
};
|
|
@@ -28,6 +28,11 @@ export interface ConfirmCardProps {
|
|
|
28
28
|
summary: string;
|
|
29
29
|
annotationCount: number;
|
|
30
30
|
blockingCount: number;
|
|
31
|
+
/**
|
|
32
|
+
* The guided walk's honest coverage line for diff sessions, e.g.
|
|
33
|
+
* "2/3 files viewed". Undefined = no walk data, the row does not render.
|
|
34
|
+
*/
|
|
35
|
+
viewedSummary?: string;
|
|
31
36
|
onInput: (summary: string) => void;
|
|
32
37
|
onSelectVerdict: (verdict: VerdictKind) => void;
|
|
33
38
|
onSubmit: () => void;
|
|
@@ -76,6 +81,7 @@ export function ConfirmCard({
|
|
|
76
81
|
summary,
|
|
77
82
|
annotationCount,
|
|
78
83
|
blockingCount,
|
|
84
|
+
viewedSummary,
|
|
79
85
|
onInput,
|
|
80
86
|
onSelectVerdict,
|
|
81
87
|
onSubmit,
|
|
@@ -86,12 +92,13 @@ export function ConfirmCard({
|
|
|
86
92
|
return (
|
|
87
93
|
<Card
|
|
88
94
|
title=" submit review "
|
|
89
|
-
contentRows={CONFIRM_CONTENT_ROWS}
|
|
95
|
+
contentRows={CONFIRM_CONTENT_ROWS + (viewedSummary !== undefined ? 1 : 0)}
|
|
90
96
|
borderColor={tokens.accent}
|
|
91
97
|
marginRight={1}
|
|
92
98
|
theme={theme}
|
|
93
99
|
>
|
|
94
100
|
<text fg={tokens.textDim}>{`${annotationCount} annotations · ${blockingCount} blocking`}</text>
|
|
101
|
+
{viewedSummary !== undefined ? <text fg={tokens.textDim}>{viewedSummary}</text> : null}
|
|
95
102
|
<box style={{ height: 1 }} />
|
|
96
103
|
<VerdictSelector verdict={verdict} onSelectVerdict={onSelectVerdict} theme={theme} />
|
|
97
104
|
<box style={{ height: 1 }} />
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { WalkFile } from "../walk";
|
|
4
|
+
import type { Story, StoryMeta } from "./story";
|
|
5
|
+
import { WalkWizard } from "./WalkWizard";
|
|
6
|
+
|
|
7
|
+
export const meta: StoryMeta = { title: "WalkWizard" };
|
|
8
|
+
|
|
9
|
+
const FILES: WalkFile[] = [
|
|
10
|
+
{
|
|
11
|
+
path: "packages/daemon/src/store.ts",
|
|
12
|
+
added: 12,
|
|
13
|
+
removed: 3,
|
|
14
|
+
preview: [
|
|
15
|
+
{ sign: "+", text: "const viewed = new Set<string>();" },
|
|
16
|
+
{ sign: "+", text: "record.viewedPaths = [...viewed];" },
|
|
17
|
+
{ sign: "-", text: "// viewed tracking lives in the client only" },
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
path: "packages/client/src/walk.ts",
|
|
22
|
+
added: 48,
|
|
23
|
+
removed: 0,
|
|
24
|
+
preview: [{ sign: "+", text: "export function firstUnviewedIndex(files, viewed) {" }],
|
|
25
|
+
},
|
|
26
|
+
{ path: "packages/schema/src/types.ts", added: 3, removed: 0, preview: [{ sign: "+", text: "viewedPaths?: string[];" }] },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const noop = (): void => {};
|
|
30
|
+
|
|
31
|
+
export const FirstFile: Story = {
|
|
32
|
+
render: () => (
|
|
33
|
+
<WalkWizard
|
|
34
|
+
files={FILES}
|
|
35
|
+
index={0}
|
|
36
|
+
viewedPaths={new Set()}
|
|
37
|
+
terminalWidth={80}
|
|
38
|
+
onSubmitRequest={noop}
|
|
39
|
+
onBack={noop}
|
|
40
|
+
/>
|
|
41
|
+
),
|
|
42
|
+
// the main card wears the accent border; stats carry the +/- colors
|
|
43
|
+
expectedColors: [DARK.accent, DARK.green, DARK.red, DARK.insFg, DARK.delFg],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const WithAgentNote: Story = {
|
|
47
|
+
render: () => (
|
|
48
|
+
<WalkWizard
|
|
49
|
+
files={FILES}
|
|
50
|
+
index={1}
|
|
51
|
+
viewedPaths={new Set(["packages/daemon/src/store.ts"])}
|
|
52
|
+
note="New module: pure helpers that pick the next unviewed file. No IO, unit tested."
|
|
53
|
+
terminalWidth={80}
|
|
54
|
+
onSubmitRequest={noop}
|
|
55
|
+
onBack={noop}
|
|
56
|
+
/>
|
|
57
|
+
),
|
|
58
|
+
// the agent-note block wears the plain gray border token, not the accent
|
|
59
|
+
expectedColors: [DARK.accent, DARK.border, DARK.textMuted],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const EndCard: Story = {
|
|
63
|
+
render: () => (
|
|
64
|
+
<WalkWizard
|
|
65
|
+
files={FILES}
|
|
66
|
+
index={3}
|
|
67
|
+
viewedPaths={new Set(FILES.map((file) => file.path))}
|
|
68
|
+
terminalWidth={80}
|
|
69
|
+
onSubmitRequest={noop}
|
|
70
|
+
onBack={noop}
|
|
71
|
+
/>
|
|
72
|
+
),
|
|
73
|
+
// the end card wears the green border and the solid submit button
|
|
74
|
+
expectedColors: [DARK.green, DARK.accent, DARK.accentInk],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const EndCardPartialPass: Story = {
|
|
78
|
+
render: () => (
|
|
79
|
+
<WalkWizard
|
|
80
|
+
files={FILES}
|
|
81
|
+
index={3}
|
|
82
|
+
viewedPaths={new Set(["packages/daemon/src/store.ts"])}
|
|
83
|
+
terminalWidth={80}
|
|
84
|
+
onSubmitRequest={noop}
|
|
85
|
+
onBack={noop}
|
|
86
|
+
/>
|
|
87
|
+
),
|
|
88
|
+
expectedColors: [DARK.green],
|
|
89
|
+
};
|