@cueloop/client 0.1.0-alpha.14 → 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.autoclose.test.tsx +12 -10
- package/src/App.diff.test.tsx +1 -1
- package/src/App.submit-confirm.test.tsx +1 -1
- package/src/App.test.tsx +2 -2
- package/src/App.tsx +67 -12
- package/src/App.walk.test.tsx +208 -0
- package/src/components/CompletionOverlay.tsx +7 -8
- 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 +135 -5
- 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 +38 -1
- package/src/keymap.ts +28 -3
- package/src/session-controller.ts +92 -14
- package/src/theme.ts +19 -0
- package/src/walk.test.ts +110 -0
- package/src/walk.ts +65 -0
|
@@ -24,14 +24,15 @@ import { Registry, type Exporter } from "@cueloop/extension-api";
|
|
|
24
24
|
import { createObsidianExtension, shouldExport, type ObsidianConfig } from "@cueloop/integration-obsidian";
|
|
25
25
|
import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view";
|
|
26
26
|
import { diffRowAnchor, diffRows, type DiffRow } from "./view-diff";
|
|
27
|
+
import { firstUnviewedIndex, walkFiles, type WalkFile } from "./walk";
|
|
27
28
|
import { editInEditor } from "./editor";
|
|
28
29
|
import { focusHerdrPane, returnPaneFor } from "./herdr";
|
|
29
30
|
import { persistAutoClose, type AutoClose, type CueloopConfig } from "./config";
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* Post-submit lifecycle (a review pane should hand you back to the agent,
|
|
33
|
-
* not linger): idle →
|
|
34
|
-
*
|
|
34
|
+
* not linger): idle → counting (a visible countdown, the default) → exit.
|
|
35
|
+
* esc dismisses to the resolved view; a remembers the countdown as default.
|
|
35
36
|
*/
|
|
36
37
|
export type Completion =
|
|
37
38
|
| { phase: "idle" }
|
|
@@ -39,6 +40,9 @@ export type Completion =
|
|
|
39
40
|
| { phase: "counting"; remaining: number }
|
|
40
41
|
| { phase: "dismissed" };
|
|
41
42
|
|
|
43
|
+
/** Seconds the completion overlay counts down before it hands back. */
|
|
44
|
+
export const DEFAULT_AUTO_CLOSE = 5;
|
|
45
|
+
|
|
42
46
|
export interface ControllerSnapshot {
|
|
43
47
|
session: ReviewSession | null;
|
|
44
48
|
inbox: ReviewSession[] | null;
|
|
@@ -50,6 +54,12 @@ export interface ControllerSnapshot {
|
|
|
50
54
|
* hand-off - the reconciliation banner count. 0 = no banner.
|
|
51
55
|
*/
|
|
52
56
|
editOrphanCount: number;
|
|
57
|
+
/**
|
|
58
|
+
* The guided walk's cursor (diff sessions): which wizard step is on
|
|
59
|
+
* screen. index === file count is the end card. null = not walking; the
|
|
60
|
+
* viewed set itself rides the session record, so leaving loses nothing.
|
|
61
|
+
*/
|
|
62
|
+
walk: { index: number } | null;
|
|
53
63
|
}
|
|
54
64
|
|
|
55
65
|
export interface ReviewControllerOptions {
|
|
@@ -76,6 +86,8 @@ export interface ReviewController {
|
|
|
76
86
|
/** Derived projections, cached per session identity. */
|
|
77
87
|
display(): DisplayBlock[];
|
|
78
88
|
rows(): DiffRow[];
|
|
89
|
+
/** The walk's step list, derived from the diff rows. */
|
|
90
|
+
files(): WalkFile[];
|
|
79
91
|
working(): string;
|
|
80
92
|
/** Open a session from the inbox. */
|
|
81
93
|
open(id: string): void;
|
|
@@ -92,6 +104,13 @@ export interface ReviewController {
|
|
|
92
104
|
updateAnnotation(id: string, body: string): void;
|
|
93
105
|
removeAnnotation(id: string): void;
|
|
94
106
|
setWorkingCopy(content: string | undefined): void;
|
|
107
|
+
/** Enter the guided walk at the first unviewed file (diff sessions). */
|
|
108
|
+
walkStart(): void;
|
|
109
|
+
/** Mark the current file viewed (persists with the session) and advance. */
|
|
110
|
+
walkForward(): void;
|
|
111
|
+
walkBack(): void;
|
|
112
|
+
/** Leave the walk; the viewed set stays on the session record. */
|
|
113
|
+
walkLeave(): void;
|
|
95
114
|
/** Resolve the review, run the export, start the completion hand-back. */
|
|
96
115
|
submit(verdict: VerdictKind, summary: string): void;
|
|
97
116
|
/** Close the review and, inside herdr, bounce focus back to the agent. */
|
|
@@ -116,6 +135,7 @@ class Controller implements ReviewController {
|
|
|
116
135
|
error: null,
|
|
117
136
|
completion: { phase: "idle" },
|
|
118
137
|
editOrphanCount: 0,
|
|
138
|
+
walk: null,
|
|
119
139
|
};
|
|
120
140
|
private listeners = new Set<() => void>();
|
|
121
141
|
private autoClose: AutoClose = "off";
|
|
@@ -125,7 +145,11 @@ class Controller implements ReviewController {
|
|
|
125
145
|
private countdown: TimerHandle | undefined;
|
|
126
146
|
/** Projections keyed by session identity so renders reuse one computation. */
|
|
127
147
|
private derivedFor: ReviewSession | null = null;
|
|
128
|
-
private derived: { display: DisplayBlock[]; rows: DiffRow[]
|
|
148
|
+
private derived: { display: DisplayBlock[]; rows: DiffRow[]; files: WalkFile[] } = {
|
|
149
|
+
display: [],
|
|
150
|
+
rows: [],
|
|
151
|
+
files: [],
|
|
152
|
+
};
|
|
129
153
|
|
|
130
154
|
constructor(private readonly opts: ReviewControllerOptions) {
|
|
131
155
|
this.readOnly = opts.readOnly ?? false;
|
|
@@ -198,9 +222,11 @@ class Controller implements ReviewController {
|
|
|
198
222
|
const s = this.snap.session;
|
|
199
223
|
if (this.derivedFor === s) return;
|
|
200
224
|
this.derivedFor = s;
|
|
225
|
+
const rows = s && s.artifact.type === "diff" ? diffRows(s.artifact.content) : [];
|
|
201
226
|
this.derived = {
|
|
202
227
|
display: s ? buildDisplay(s.artifact.content, s.workingCopy) : [],
|
|
203
|
-
rows
|
|
228
|
+
rows,
|
|
229
|
+
files: walkFiles(rows),
|
|
204
230
|
};
|
|
205
231
|
}
|
|
206
232
|
|
|
@@ -214,6 +240,11 @@ class Controller implements ReviewController {
|
|
|
214
240
|
return this.derived.rows;
|
|
215
241
|
}
|
|
216
242
|
|
|
243
|
+
files(): WalkFile[] {
|
|
244
|
+
this.ensureDerived();
|
|
245
|
+
return this.derived.files;
|
|
246
|
+
}
|
|
247
|
+
|
|
217
248
|
working(): string {
|
|
218
249
|
const s = this.snap.session;
|
|
219
250
|
return s ? (s.workingCopy ?? s.artifact.content) : "";
|
|
@@ -247,6 +278,7 @@ class Controller implements ReviewController {
|
|
|
247
278
|
|
|
248
279
|
// ── verbs ───────────────────────────────────
|
|
249
280
|
open(id: string): void {
|
|
281
|
+
this.locallyViewed.clear();
|
|
250
282
|
const cached = this.snap.inbox?.find((s) => s.id === id);
|
|
251
283
|
if (cached) this.update({ session: cached });
|
|
252
284
|
else void this.refreshSession(id);
|
|
@@ -361,12 +393,59 @@ class Controller implements ReviewController {
|
|
|
361
393
|
this.apply(this.client!.sessionSetWorkingCopy(session.id, content));
|
|
362
394
|
}
|
|
363
395
|
|
|
396
|
+
// ── the guided walk ─────────────────────────
|
|
397
|
+
// Marks sent but possibly not yet reflected in the session snapshot; the
|
|
398
|
+
// union keeps the walk title truthful and rapid advances from losing marks.
|
|
399
|
+
private locallyViewed = new Set<string>();
|
|
400
|
+
|
|
401
|
+
private viewedSet(): Set<string> {
|
|
402
|
+
return new Set([...(this.snap.session?.viewedPaths ?? []), ...this.locallyViewed]);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
walkStart(): void {
|
|
406
|
+
const session = this.snap.session;
|
|
407
|
+
if (!session || session.artifact.type !== "diff") return;
|
|
408
|
+
const files = this.files();
|
|
409
|
+
if (files.length === 0) return this.setStatus("nothing to walk - the diff is empty");
|
|
410
|
+
// resume at the first unviewed file; a finished walk reopens on the end card
|
|
411
|
+
this.update({ walk: { index: firstUnviewedIndex(files, this.viewedSet()) } });
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
walkForward(): void {
|
|
415
|
+
const walk = this.snap.walk;
|
|
416
|
+
const session = this.snap.session;
|
|
417
|
+
if (!walk || !session) return;
|
|
418
|
+
const files = this.files();
|
|
419
|
+
const current = files[walk.index];
|
|
420
|
+
if (!current) return; // already on the end card
|
|
421
|
+
// advancing IS the viewed mark: the step is complete once you move past
|
|
422
|
+
// it; the daemon verb merges, so only the new path travels
|
|
423
|
+
if (!this.viewedSet().has(current.path)) {
|
|
424
|
+
this.locallyViewed.add(current.path);
|
|
425
|
+
this.apply(this.client!.sessionSetViewed(session.id, [current.path]));
|
|
426
|
+
}
|
|
427
|
+
this.update({ walk: { index: walk.index + 1 } });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
walkBack(): void {
|
|
431
|
+
const walk = this.snap.walk;
|
|
432
|
+
if (!walk) return;
|
|
433
|
+
this.update({ walk: { index: Math.max(0, walk.index - 1) } });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
walkLeave(): void {
|
|
437
|
+
if (this.snap.walk) this.update({ walk: null });
|
|
438
|
+
}
|
|
439
|
+
|
|
364
440
|
submit(verdict: VerdictKind, summary: string): void {
|
|
365
441
|
const session = this.snap.session;
|
|
366
442
|
if (!session) return;
|
|
443
|
+
this.walkLeave();
|
|
367
444
|
this.client!.sessionResolve(session.id, verdict, summary)
|
|
368
445
|
.then((resolved) => {
|
|
369
|
-
|
|
446
|
+
// The completion overlay heading already states the verdict, so the
|
|
447
|
+
// status line stays empty here - only export/error messages fill it.
|
|
448
|
+
this.update({ session: resolved, status: "" });
|
|
370
449
|
// notes-vault export: guarded by config, default is manual (no-op)
|
|
371
450
|
const obsidian = this.obsidian;
|
|
372
451
|
const exporter = this.exporters.get("obsidian");
|
|
@@ -375,14 +454,13 @@ class Controller implements ReviewController {
|
|
|
375
454
|
this.setStatus(r.success && r.path ? `exported to ${r.path}` : `export failed: ${r.error ?? "unknown"}`);
|
|
376
455
|
});
|
|
377
456
|
}
|
|
378
|
-
//
|
|
379
|
-
//
|
|
457
|
+
// Hand the reviewer back to the agent. The default is a visible
|
|
458
|
+
// countdown from DEFAULT_AUTO_CLOSE that closes on its own; esc stays,
|
|
459
|
+
// a remembers the choice. A configured delay overrides; 0 closes now.
|
|
380
460
|
const delay = this.autoClose;
|
|
381
|
-
const returns = returnPaneFor(resolved.artifact.meta.herdrPane) !== undefined;
|
|
382
461
|
if (delay === 0) this.finishReview();
|
|
383
462
|
else if (typeof delay === "number") this.startCounting(delay);
|
|
384
|
-
else
|
|
385
|
-
else this.update({ completion: { phase: "prompt" } });
|
|
463
|
+
else this.startCounting(DEFAULT_AUTO_CLOSE);
|
|
386
464
|
})
|
|
387
465
|
.catch((e: unknown) => this.setStatus(String(e instanceof Error ? e.message : e)));
|
|
388
466
|
}
|
|
@@ -407,13 +485,13 @@ class Controller implements ReviewController {
|
|
|
407
485
|
}
|
|
408
486
|
|
|
409
487
|
optInAutoClose(): void {
|
|
410
|
-
//
|
|
488
|
+
// Remember the default countdown for future submits, persisted to the user
|
|
489
|
+
// config. The countdown is already running; this only makes it the default.
|
|
411
490
|
try {
|
|
412
|
-
persistAutoClose(
|
|
491
|
+
persistAutoClose(DEFAULT_AUTO_CLOSE);
|
|
413
492
|
} catch {
|
|
414
493
|
// a read-only config dir must not block closing the review
|
|
415
494
|
}
|
|
416
|
-
this.autoClose =
|
|
417
|
-
this.startCounting(3);
|
|
495
|
+
this.autoClose = DEFAULT_AUTO_CLOSE;
|
|
418
496
|
}
|
|
419
497
|
}
|
package/src/theme.ts
CHANGED
|
@@ -27,6 +27,25 @@ export interface Theme {
|
|
|
27
27
|
backdrop: string;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The dimmed variant behind the walk wizard: every reading color drops to
|
|
32
|
+
* the dim token so the file list stays legible context, not competition.
|
|
33
|
+
* Layout tokens (backgrounds, cursor) keep their values.
|
|
34
|
+
*/
|
|
35
|
+
export function dimmedTheme(theme: Theme): Theme {
|
|
36
|
+
return {
|
|
37
|
+
...theme,
|
|
38
|
+
text: theme.textDim,
|
|
39
|
+
textMuted: theme.textDim,
|
|
40
|
+
accent: theme.textDim,
|
|
41
|
+
green: theme.textDim,
|
|
42
|
+
red: theme.textDim,
|
|
43
|
+
blue: theme.textDim,
|
|
44
|
+
insFg: theme.textDim,
|
|
45
|
+
delFg: theme.textDim,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
30
49
|
export const DARK: Theme = {
|
|
31
50
|
bg: "#1f1f29",
|
|
32
51
|
panel: "#1a1a23",
|
package/src/walk.test.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** Pure walk projections: file steps, resume index, viewed counts, notes. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import type { Annotation } from "@cueloop/schema";
|
|
5
|
+
import { diffRows } from "./view-diff";
|
|
6
|
+
import { firstUnviewedIndex, noteForFile, viewedCount, walkFiles, WALK_PREVIEW_ROWS } from "./walk";
|
|
7
|
+
|
|
8
|
+
const PATCH = `diff --git a/src/a.ts b/src/a.ts
|
|
9
|
+
index 111..222 100644
|
|
10
|
+
--- a/src/a.ts
|
|
11
|
+
+++ b/src/a.ts
|
|
12
|
+
@@ -1,3 +1,4 @@
|
|
13
|
+
export const a = 1;
|
|
14
|
+
-export const b = 2;
|
|
15
|
+
+export const b = 3;
|
|
16
|
+
+export const c = 4;
|
|
17
|
+
diff --git a/src/b.ts b/src/b.ts
|
|
18
|
+
index 333..444 100644
|
|
19
|
+
--- a/src/b.ts
|
|
20
|
+
+++ b/src/b.ts
|
|
21
|
+
@@ -1,2 +1,2 @@
|
|
22
|
+
// header
|
|
23
|
+
-old line
|
|
24
|
+
+new line
|
|
25
|
+
diff --git a/src/c.ts b/src/c.ts
|
|
26
|
+
index 555..666 100644
|
|
27
|
+
--- a/src/c.ts
|
|
28
|
+
+++ b/src/c.ts
|
|
29
|
+
@@ -1,1 +1,8 @@
|
|
30
|
+
-gone
|
|
31
|
+
+one
|
|
32
|
+
+two
|
|
33
|
+
+three
|
|
34
|
+
+four
|
|
35
|
+
+five
|
|
36
|
+
+six
|
|
37
|
+
+seven
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
const rows = diffRows(PATCH);
|
|
41
|
+
const files = walkFiles(rows);
|
|
42
|
+
|
|
43
|
+
describe("walkFiles", () => {
|
|
44
|
+
test("one step per changed file, in patch order, with counted stats", () => {
|
|
45
|
+
expect(files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
|
|
46
|
+
expect(files[0]).toMatchObject({ added: 2, removed: 1 });
|
|
47
|
+
expect(files[1]).toMatchObject({ added: 1, removed: 1 });
|
|
48
|
+
expect(files[2]).toMatchObject({ added: 7, removed: 1 });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("previews carry signed changed lines and cap at the preview budget", () => {
|
|
52
|
+
expect(files[0]!.preview).toEqual([
|
|
53
|
+
{ sign: "-", text: "export const b = 2;" },
|
|
54
|
+
{ sign: "+", text: "export const b = 3;" },
|
|
55
|
+
{ sign: "+", text: "export const c = 4;" },
|
|
56
|
+
]);
|
|
57
|
+
expect(files[2]!.preview.length).toBe(WALK_PREVIEW_ROWS);
|
|
58
|
+
expect(files[2]!.preview[0]).toEqual({ sign: "-", text: "gone" });
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("firstUnviewedIndex", () => {
|
|
63
|
+
test("starts at the first file when nothing is viewed", () => {
|
|
64
|
+
expect(firstUnviewedIndex(files, new Set())).toBe(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("skips viewed files - a resumed review lands on its first gap", () => {
|
|
68
|
+
expect(firstUnviewedIndex(files, new Set(["src/a.ts"]))).toBe(1);
|
|
69
|
+
expect(firstUnviewedIndex(files, new Set(["src/a.ts", "src/b.ts"]))).toBe(2);
|
|
70
|
+
// a viewed file later in the list does not block the earlier gap
|
|
71
|
+
expect(firstUnviewedIndex(files, new Set(["src/c.ts"]))).toBe(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("all viewed resumes on the end card, not the first file", () => {
|
|
75
|
+
expect(firstUnviewedIndex(files, new Set(["src/a.ts", "src/b.ts", "src/c.ts"]))).toBe(files.length);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("viewedCount", () => {
|
|
80
|
+
test("counts only paths present in the current file set", () => {
|
|
81
|
+
expect(viewedCount(files, new Set(["src/a.ts", "src/gone-in-rev-2.ts"]))).toBe(1);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("noteForFile", () => {
|
|
86
|
+
const note = (path: string, body: string): Annotation => ({
|
|
87
|
+
id: `a_${path}`,
|
|
88
|
+
kind: "note",
|
|
89
|
+
anchor: { quote: path, prefix: "", suffix: "" },
|
|
90
|
+
body,
|
|
91
|
+
createdAt: "now",
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("finds the note anchored at the file path", () => {
|
|
95
|
+
const annotations = [note("src/a.ts", "Renames b and adds c."), note("src/b.ts", "Swaps the line.")];
|
|
96
|
+
expect(noteForFile(annotations, "src/b.ts")).toBe("Swaps the line.");
|
|
97
|
+
expect(noteForFile(annotations, "src/c.ts")).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("ignores non-note annotations that happen to quote the path", () => {
|
|
101
|
+
const comment: Annotation = {
|
|
102
|
+
id: "a_1",
|
|
103
|
+
kind: "comment",
|
|
104
|
+
anchor: { quote: "src/a.ts", prefix: "", suffix: "" },
|
|
105
|
+
body: "not a note",
|
|
106
|
+
createdAt: "now",
|
|
107
|
+
};
|
|
108
|
+
expect(noteForFile([comment], "src/a.ts")).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
});
|
package/src/walk.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guided-walk projections (#83): pure helpers over the diff row model - no
|
|
3
|
+
* IO, no React. The walk steps through the changed files in patch order;
|
|
4
|
+
* which files were seen lives on the session record (viewedPaths), which
|
|
5
|
+
* file is on screen is view state in the session controller.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Annotation } from "@cueloop/schema";
|
|
9
|
+
import type { DiffRow } from "./view-diff";
|
|
10
|
+
|
|
11
|
+
/** One wizard step: a changed file with its stats and a short diff preview. */
|
|
12
|
+
export interface WalkFile {
|
|
13
|
+
path: string;
|
|
14
|
+
added: number;
|
|
15
|
+
removed: number;
|
|
16
|
+
/** The first changed lines, signed (+/-), for the card's diff preview. */
|
|
17
|
+
preview: { sign: "+" | "-"; text: string }[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Preview lines per card: enough to recognize the change, never a full diff. */
|
|
21
|
+
export const WALK_PREVIEW_ROWS = 5;
|
|
22
|
+
|
|
23
|
+
/** Collapse the diff rows into one walk step per changed file, in patch order. */
|
|
24
|
+
export function walkFiles(rows: DiffRow[]): WalkFile[] {
|
|
25
|
+
const files: WalkFile[] = [];
|
|
26
|
+
const byPath = new Map<string, WalkFile>();
|
|
27
|
+
for (const row of rows) {
|
|
28
|
+
let file = byPath.get(row.file);
|
|
29
|
+
if (!file) {
|
|
30
|
+
file = { path: row.file, added: 0, removed: 0, preview: [] };
|
|
31
|
+
byPath.set(row.file, file);
|
|
32
|
+
files.push(file);
|
|
33
|
+
}
|
|
34
|
+
if (row.t !== "add" && row.t !== "del") continue;
|
|
35
|
+
if (row.t === "add") file.added += 1;
|
|
36
|
+
else file.removed += 1;
|
|
37
|
+
if (file.preview.length < WALK_PREVIEW_ROWS) {
|
|
38
|
+
file.preview.push({ sign: row.t === "add" ? "+" : "-", text: row.text.replace(/\n$/, "") });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return files;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Where the walk resumes: the first file not yet marked viewed. All files
|
|
46
|
+
* viewed = the end card (index === files.length), so a finished walk reopens
|
|
47
|
+
* on its summary instead of restarting.
|
|
48
|
+
*/
|
|
49
|
+
export function firstUnviewedIndex(files: WalkFile[], viewedPaths: ReadonlySet<string>): number {
|
|
50
|
+
const index = files.findIndex((file) => !viewedPaths.has(file.path));
|
|
51
|
+
return index === -1 ? files.length : index;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Viewed count against the CURRENT file set - stale paths never inflate it. */
|
|
55
|
+
export function viewedCount(files: WalkFile[], viewedPaths: ReadonlySet<string>): number {
|
|
56
|
+
return files.filter((file) => viewedPaths.has(file.path)).length;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The agent's note for a file: an annotation with kind "note" whose anchor
|
|
61
|
+
* quote is the file path (the note contract in the shared review core).
|
|
62
|
+
*/
|
|
63
|
+
export function noteForFile(annotations: Annotation[], path: string): string | undefined {
|
|
64
|
+
return annotations.find((annotation) => annotation.kind === "note" && annotation.anchor.quote === path)?.body;
|
|
65
|
+
}
|