@cueloop/client 0.1.0-alpha.84 → 0.1.0-alpha.86

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/src/config.ts CHANGED
@@ -32,6 +32,14 @@ export type AutoClose = "off" | number;
32
32
  * (unzoomed) pane has no room for two columns, so it is always stacked regardless of this choice. */
33
33
  export type DiffViewMode = "split" | "stacked";
34
34
 
35
+ export type ReviewWorkspaceMode = "worktree" | "current";
36
+
37
+ /** Agent review defaults shared by the slash workflow and Settings. */
38
+ export interface ReviewConfig {
39
+ skill: string;
40
+ workspace: ReviewWorkspaceMode;
41
+ }
42
+
35
43
  /** One marker-popover quick action: a preset comment body, plus optional extra lines. */
36
44
  export interface QuickAction {
37
45
  prompt: string;
@@ -122,6 +130,7 @@ export interface CueloopConfig {
122
130
  actions: QuickAction[];
123
131
  /** Directory of user-level skills surfaced in the "/" palette ([skills] path); ~/.agents/skills default. */
124
132
  skillsPath: string;
133
+ review: ReviewConfig;
125
134
  integrations: IntegrationsConfig;
126
135
  /** Opt-in experimental features ([experimental] table); all default off. */
127
136
  experimental: ExperimentalConfig;
@@ -185,6 +194,7 @@ const ConfigDocumentSchema = v.object({
185
194
  ui: v.optional(v.unknown()),
186
195
  experimental: v.optional(v.unknown()),
187
196
  skills: v.fallback(v.optional(SkillsSectionSchema), undefined),
197
+ review: v.optional(v.unknown()),
188
198
  });
189
199
 
190
200
  const IdentitySchema = v.object({
@@ -237,6 +247,10 @@ const IntegrationsSchema = v.object({ obsidian: v.optional(ObsidianSchema) });
237
247
  const ExperimentalSchema = v.object({
238
248
  prototype_pixels: v.fallback(v.optional(v.boolean()), undefined),
239
249
  });
250
+ const ReviewSchema = v.object({
251
+ skill: v.fallback(v.optional(v.string()), undefined),
252
+ workspace: v.fallback(v.optional(v.picklist(["worktree", "current"])), undefined),
253
+ });
240
254
  const ThemeOverridesSchema = v.record(v.string(), v.unknown());
241
255
 
242
256
  function isThemeToken(token: string): token is keyof Theme {
@@ -292,6 +306,11 @@ function applyUi(ui: CueloopConfig["ui"], parsed: v.InferOutput<typeof UiSchema>
292
306
  }
293
307
  }
294
308
 
309
+ function applyReview(review: ReviewConfig, parsed: v.InferOutput<typeof ReviewSchema>): void {
310
+ if (parsed.skill?.trim()) review.skill = parsed.skill.trim();
311
+ if (parsed.workspace !== undefined) review.workspace = parsed.workspace;
312
+ }
313
+
295
314
  function layer(
296
315
  base: CueloopConfig,
297
316
  raw: v.InferOutput<typeof ConfigDocumentSchema>,
@@ -306,6 +325,7 @@ function layer(
306
325
  identity: { ...base.identity },
307
326
  actions: [...base.actions],
308
327
  skillsPath: base.skillsPath,
328
+ review: { ...base.review },
309
329
  integrations: { obsidian: { ...base.integrations.obsidian } },
310
330
  experimental: { ...base.experimental },
311
331
  };
@@ -316,6 +336,7 @@ function layer(
316
336
  const ui = v.safeParse(UiSchema, raw.ui);
317
337
  const integrations = v.safeParse(IntegrationsSchema, raw.integrations);
318
338
  const experimental = v.safeParse(ExperimentalSchema, raw.experimental);
339
+ const review = v.safeParse(ReviewSchema, raw.review);
319
340
 
320
341
  if (actions) out.actions = actions;
321
342
  out.skillsPath = skillsPathFrom(raw.skills, base.skillsPath);
@@ -346,6 +367,7 @@ function layer(
346
367
  if (experimental.success && experimental.output.prototype_pixels !== undefined) {
347
368
  out.experimental.prototypePixels = experimental.output.prototype_pixels;
348
369
  }
370
+ if (review.success) applyReview(out.review, review.output);
349
371
 
350
372
  return out;
351
373
  }
@@ -368,6 +390,7 @@ export function loadConfig(
368
390
  identity: { provider: "typed" },
369
391
  actions: [...DEFAULT_QUICK_ACTIONS],
370
392
  skillsPath: join(homedir(), ".agents", "skills"),
393
+ review: { skill: "code-review", workspace: "worktree" },
371
394
  integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
372
395
  experimental: { prototypePixels: false },
373
396
  };
@@ -458,6 +481,36 @@ function persistUiSetting(key: string, rendered: string, userConfigPath?: string
458
481
  writeFileSync(path, text);
459
482
  }
460
483
 
484
+ function persistTableSetting(
485
+ table: string,
486
+ key: string,
487
+ rendered: string,
488
+ userConfigPath?: string,
489
+ ): void {
490
+ const path = userConfigPathFrom(userConfigPath);
491
+ let text = existsSync(path) ? readFileSync(path, "utf8") : "";
492
+ const escapedTable = escapeRegExp(table);
493
+ const block = new RegExp(
494
+ `(^\\[${escapedTable}\\][ \\t]*(?:#[^\\r\\n]*)?\\r?$)([\\s\\S]*?)(?=^\\[|(?![\\s\\S]))`,
495
+ "m",
496
+ );
497
+ const match = block.exec(text);
498
+
499
+ if (match) {
500
+ const assignment = new RegExp(`^(\\s*)${escapeRegExp(key)}\\s*=.*$`, "m");
501
+ const body = assignment.test(match[2]!)
502
+ ? match[2]!.replace(assignment, `$1${key} = ${rendered}`)
503
+ : `\n${key} = ${rendered}${match[2]!}`;
504
+
505
+ text =
506
+ text.slice(0, match.index) + match[1]! + body + text.slice(match.index + match[0].length);
507
+ } else {
508
+ text = `${text.trimEnd()}${text.trim() ? "\n\n" : ""}[${table}]\n${key} = ${rendered}\n`;
509
+ }
510
+ mkdirSync(dirname(path), { recursive: true });
511
+ writeFileSync(path, text);
512
+ }
513
+
461
514
  /** A double-quoted TOML basic string with the quote-breaking characters escaped. */
462
515
  function tomlString(value: string): string {
463
516
  return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
@@ -521,6 +574,19 @@ export function persistDiffView(mode: DiffViewMode, userConfigPath?: string): vo
521
574
  persistUiSetting("diff_view", `"${mode}"`, userConfigPath);
522
575
  }
523
576
 
577
+ /** Persist the configured agent review skill (`[review] skill`). */
578
+ export function persistReviewSkill(skill: string, userConfigPath?: string): void {
579
+ persistTableSetting("review", "skill", tomlString(skill), userConfigPath);
580
+ }
581
+
582
+ /** Persist where agent review work runs (`[review] workspace`). */
583
+ export function persistReviewWorkspace(
584
+ workspace: ReviewWorkspaceMode,
585
+ userConfigPath?: string,
586
+ ): void {
587
+ persistTableSetting("review", "workspace", tomlString(workspace), userConfigPath);
588
+ }
589
+
524
590
  /** Persist the pinned-thread ids (`[ui] pins`) into the user config. */
525
591
  export function persistPins(ids: readonly string[], userConfigPath?: string): void {
526
592
  persistUiSetting("pins", `[${ids.map(tomlString).join(", ")}]`, userConfigPath);
package/src/index.ts CHANGED
@@ -1,6 +1,21 @@
1
1
  export { runClient, type RunClientOptions } from "./run";
2
2
  export { snapshotWorkbench, type RepoDiff } from "./workbench-snapshot";
3
- export { defaultLayout, planLayout, reviewLayout, type LaunchLayout } from "./launch-layout";
3
+ export {
4
+ defaultLayout,
5
+ planLayout,
6
+ pullRequestReviewLayout,
7
+ reviewLayout,
8
+ type LaunchLayout,
9
+ } from "./launch-layout";
10
+ export {
11
+ diffRowAnchor,
12
+ diffRows,
13
+ diffRowText,
14
+ fileRowRange,
15
+ resolveReviewAnchorRow,
16
+ type DiffRow,
17
+ } from "./view-diff";
18
+ export { loadConfig, type ReviewWorkspaceMode } from "./config";
4
19
  export { serveClient, type ServeHandle, type ServeOptions } from "./serve";
5
20
  export { App, type AppProps } from "./App";
6
21
  export {
@@ -82,6 +82,7 @@ function baseController(): ReviewController {
82
82
  repoFiles: mock(() => Promise.resolve<string[]>([])),
83
83
  repoReadFile: mock(() => Promise.resolve<string | null>(null)),
84
84
  repoChanges: mock(() => Promise.resolve([])),
85
+ refreshPullRequest: mock(() => Promise.resolve()),
85
86
  open: mock(),
86
87
  deleteSession: mock(),
87
88
  renameSession: mock(),
@@ -1,5 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { defaultLayout, layoutFromPanes, planLayout, reviewLayout } from "./launch-layout";
2
+ import {
3
+ defaultLayout,
4
+ layoutFromPanes,
5
+ planLayout,
6
+ pullRequestReviewLayout,
7
+ reviewLayout,
8
+ } from "./launch-layout";
3
9
 
4
10
  describe("launch layout factories", () => {
5
11
  test("review opens the diff zoomed with the Changes panel", () => {
@@ -7,6 +13,15 @@ describe("launch layout factories", () => {
7
13
  expect(reviewLayout()).toEqual({ threads: true, rightSidebar: "changes", zoomChanges: true });
8
14
  });
9
15
 
16
+ test("pull request review keeps its brief beside the Changes panel", () => {
17
+ // Assert
18
+ expect(pullRequestReviewLayout()).toEqual({
19
+ threads: true,
20
+ rightSidebar: "changes",
21
+ zoomChanges: false,
22
+ });
23
+ });
24
+
10
25
  test("plan fills the middle with the thread pane and no right region", () => {
11
26
  // Assert
12
27
  expect(planLayout()).toEqual({ threads: true, rightSidebar: "off", zoomChanges: false });
@@ -14,11 +14,16 @@ export interface LaunchLayout {
14
14
  zoomChanges: boolean;
15
15
  }
16
16
 
17
- /** `cueloop diff` / `cueloop review`: the diff front and center. */
17
+ /** `cueloop diff`: the diff front and center. */
18
18
  export function reviewLayout(): LaunchLayout {
19
19
  return { threads: true, rightSidebar: "changes", zoomChanges: true };
20
20
  }
21
21
 
22
+ /** `cueloop review <pr>`: PR context beside the Changes panel. */
23
+ export function pullRequestReviewLayout(): LaunchLayout {
24
+ return { threads: true, rightSidebar: "changes", zoomChanges: false };
25
+ }
26
+
22
27
  /** `cueloop plan` / `cueloop reply`: the thread pane fills the middle; no Changes panel. */
23
28
  export function planLayout(): LaunchLayout {
24
29
  return { threads: true, rightSidebar: "off", zoomChanges: false };
@@ -1,5 +1,10 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { closePrototypeBrowser, cssBoxToCell, imageCellToCss } from "./prototype-browser";
1
+ import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ import {
3
+ closePrototypeBrowser,
4
+ cssBoxToCell,
5
+ imageCellToCss,
6
+ setPrototypeBrowserForTest,
7
+ } from "./prototype-browser";
3
8
 
4
9
  const IMAGE = { x: 1, y: 1, width: 80, height: 40 };
5
10
  const VIEWPORT = { width: 1280, height: 720 };
@@ -56,9 +61,21 @@ describe("cssBoxToCell", () => {
56
61
  });
57
62
 
58
63
  describe("closePrototypeBrowser", () => {
64
+ afterEach(() => setPrototypeBrowserForTest(null));
65
+
59
66
  test("is a safe no-op when no prototype browser was ever launched", async () => {
60
67
  // the shutdown path calls this on every quit; with no warm Chromium it must not throw or load puppeteer
61
68
  await expect(closePrototypeBrowser()).resolves.toBeUndefined();
62
69
  await expect(closePrototypeBrowser()).resolves.toBeUndefined();
63
70
  });
71
+
72
+ test("closes a warm browser once without launching Chrome", async () => {
73
+ const close = mock(async () => {});
74
+
75
+ setPrototypeBrowserForTest({ close });
76
+ await closePrototypeBrowser();
77
+ await closePrototypeBrowser();
78
+
79
+ expect(close).toHaveBeenCalledTimes(1);
80
+ });
64
81
  });
@@ -108,6 +108,13 @@ type PuppeteerBrowser = Awaited<ReturnType<typeof import("puppeteer-core").defau
108
108
  // launch is the dominant load cost, so each open only spawns a fresh page.
109
109
  let sharedBrowser: Promise<PuppeteerBrowser> | null = null;
110
110
 
111
+ /** Install only the browser lifecycle used by unit tests, without launching Chrome. */
112
+ export function setPrototypeBrowserForTest(browser: Pick<PuppeteerBrowser, "close"> | null): void {
113
+ // SAFETY: tests call only closePrototypeBrowser before resetting this seam, so close is the sole
114
+ // browser member the shared slot can read while the double is installed.
115
+ sharedBrowser = browser ? Promise.resolve(browser as PuppeteerBrowser) : null;
116
+ }
117
+
111
118
  async function warmBrowser(executablePath: string | undefined): Promise<PuppeteerBrowser> {
112
119
  // reuse the warm browser only while it is still connected; a crashed or
113
120
  // disconnected Chromium is dropped so the next open relaunches instead of
@@ -150,13 +157,6 @@ export async function closePrototypeBrowser(): Promise<void> {
150
157
  await browser?.close().catch(() => undefined);
151
158
  }
152
159
 
153
- /** Test-only: the warm browser's OS process id, or null when none is running. */
154
- export async function prototypeBrowserPidForTest(): Promise<number | null> {
155
- const browser = sharedBrowser ? await sharedBrowser.catch(() => null) : null;
156
-
157
- return browser?.process()?.pid ?? null;
158
- }
159
-
160
160
  export async function launchPrototypeRenderer(options: LaunchOptions): Promise<PrototypeRenderer> {
161
161
  const { pathToFileURL } = await import("node:url");
162
162
  const browser = await warmBrowser(options.executablePath);
@@ -43,6 +43,7 @@ const ROSE_PINE_MOON: Theme = {
43
43
  textDim: "#6e6a86",
44
44
  accent: "#ea9a97",
45
45
  accentInk: "#232136",
46
+ warning: "#f6c177",
46
47
  green: "#9ccfd8",
47
48
  red: "#eb6f92",
48
49
  blue: "#3e8fb0",
@@ -68,6 +69,7 @@ const CATPPUCCIN_MOCHA: Theme = {
68
69
  textDim: "#6c7086",
69
70
  accent: "#cba6f7",
70
71
  accentInk: "#1e1e2e",
72
+ warning: "#fab387",
71
73
  green: "#a6e3a1",
72
74
  red: "#f38ba8",
73
75
  blue: "#89b4fa",
@@ -93,6 +95,7 @@ const TOKYO_NIGHT: Theme = {
93
95
  textDim: "#565f89",
94
96
  accent: "#bb9af7",
95
97
  accentInk: "#1a1b26",
98
+ warning: "#e0af68",
96
99
  green: "#9ece6a",
97
100
  red: "#f7768e",
98
101
  blue: "#7aa2f7",
@@ -118,6 +121,7 @@ const GRUVBOX_DARK: Theme = {
118
121
  textDim: "#928374",
119
122
  accent: "#fe8019",
120
123
  accentInk: "#282828",
124
+ warning: "#fe8019",
121
125
  green: "#b8bb26",
122
126
  red: "#fb4934",
123
127
  blue: "#83a598",
@@ -143,6 +147,7 @@ const NORD: Theme = {
143
147
  textDim: "#4c566a",
144
148
  accent: "#88c0d0",
145
149
  accentInk: "#2e3440",
150
+ warning: "#d08770",
146
151
  green: "#a3be8c",
147
152
  red: "#bf616a",
148
153
  blue: "#81a1c1",
package/src/theme.ts CHANGED
@@ -15,6 +15,7 @@ export interface Theme {
15
15
  textDim: string;
16
16
  accent: string;
17
17
  accentInk: string;
18
+ warning: string;
18
19
  green: string;
19
20
  red: string;
20
21
  blue: string;
@@ -43,6 +44,7 @@ export function dimmedTheme(theme: Theme): Theme {
43
44
  text: theme.textDim,
44
45
  textMuted: theme.textDim,
45
46
  accent: theme.textDim,
47
+ warning: theme.textDim,
46
48
  green: theme.textDim,
47
49
  red: theme.textDim,
48
50
  blue: theme.textDim,
@@ -66,6 +68,7 @@ export const DARK: Theme = {
66
68
  textDim: "#6b7280",
67
69
  accent: "#cba6f7",
68
70
  accentInk: "#17171a",
71
+ warning: "#e5a45c",
69
72
  green: "#62d96b",
70
73
  red: "#f08080",
71
74
  blue: "#a9c8f5",
@@ -96,6 +99,7 @@ export const LIGHT: Theme = {
96
99
  textDim: "#868c9c",
97
100
  accent: "#5b3ea6",
98
101
  accentInk: "#fbfbfa",
102
+ warning: "#a45f12",
99
103
  green: "#2f8a3e",
100
104
  red: "#c0392b",
101
105
  blue: "#2f6fb0",
@@ -310,6 +310,8 @@ export interface ReviewController {
310
310
  repoReadFile(path: string): Promise<string | null>;
311
311
  /** The launch repo's working-tree changed files for the no-session welcome Changes tree. */
312
312
  repoChanges(): Promise<readonly DiffFileContents[]>;
313
+ /** Explicitly pull a PR diff after its head moved. */
314
+ refreshPullRequest(): Promise<void>;
313
315
  /** Open a session from the inbox. */
314
316
  open(id: string): void;
315
317
  /** Delete a session for good (inbox delete); the inbox refreshes on the event. */
@@ -864,6 +866,17 @@ class Controller implements ReviewController {
864
866
  }));
865
867
  }
866
868
 
869
+ async refreshPullRequest(): Promise<void> {
870
+ const session = this.snapshot.session;
871
+
872
+ if (!session?.artifact.meta.prRefreshHeadSha || !this.client?.sessionRefreshDiff) return;
873
+ try {
874
+ await this.client.sessionRefreshDiff(session.id);
875
+ } catch (cause) {
876
+ this.setStatus(cause instanceof Error ? cause.message : String(cause));
877
+ }
878
+ }
879
+
867
880
  // Refreshes race the connection teardown: an event can arrive while close()
868
881
  // is rejecting in-flight requests, and a fire-and-forget refresh must never
869
882
  // surface that as an unhandled rejection.
@@ -4,10 +4,13 @@ import {
4
4
  persistActions,
5
5
  persistAutoClose,
6
6
  persistDiffView,
7
+ persistReviewSkill,
8
+ persistReviewWorkspace,
7
9
  persistTheme,
8
10
  type AutoClose,
9
11
  type DiffViewMode,
10
12
  type QuickAction,
13
+ type ReviewWorkspaceMode,
11
14
  } from "./config";
12
15
  import {
13
16
  composeTheme,
@@ -90,6 +93,11 @@ export function useSettingsDialog(params: {
90
93
  identityProvider: "typed" | "github";
91
94
  onSyncGithubIdentity: () => void;
92
95
  onRenameDisplayName: () => void;
96
+ reviewSkill: string;
97
+ reviewSkillOptions: string[];
98
+ setReviewSkill: Dispatch<SetStateAction<string>>;
99
+ reviewWorkspace: ReviewWorkspaceMode;
100
+ setReviewWorkspace: Dispatch<SetStateAction<ReviewWorkspaceMode>>;
93
101
  }): SettingsDialogModel {
94
102
  const {
95
103
  theme,
@@ -109,6 +117,11 @@ export function useSettingsDialog(params: {
109
117
  identityProvider,
110
118
  onSyncGithubIdentity,
111
119
  onRenameDisplayName,
120
+ reviewSkill,
121
+ reviewSkillOptions,
122
+ setReviewSkill,
123
+ reviewWorkspace,
124
+ setReviewWorkspace,
112
125
  } = params;
113
126
 
114
127
  // open focused on the left nav, so up/down browses categories until l/tab/enter enters the body
@@ -190,6 +203,24 @@ export function useSettingsDialog(params: {
190
203
  },
191
204
  ],
192
205
  },
206
+ {
207
+ id: "review",
208
+ name: "Review",
209
+ rows: [
210
+ {
211
+ key: "reviewSkill",
212
+ label: "Skill",
213
+ kind: "cycle",
214
+ options: reviewSkillOptions,
215
+ },
216
+ {
217
+ key: "reviewWorkspace",
218
+ label: "Workspace",
219
+ kind: "cycle",
220
+ options: ["worktree", "current"],
221
+ },
222
+ ],
223
+ },
193
224
  {
194
225
  id: "actions",
195
226
  name: "Actions",
@@ -226,6 +257,8 @@ export function useSettingsDialog(params: {
226
257
  displayName: identityName ?? "-- not set --",
227
258
  identitySource: identityProvider === "github" ? "GitHub" : "typed",
228
259
  syncGithub: "enter to sync",
260
+ reviewSkill,
261
+ reviewWorkspace,
229
262
  };
230
263
  const cycleSetting = (rowKey: string): void => {
231
264
  if (rowKey === "autoClose") {
@@ -248,6 +281,17 @@ export function useSettingsDialog(params: {
248
281
  onSyncGithubIdentity();
249
282
  } else if (rowKey === "displayName") {
250
283
  onRenameDisplayName();
284
+ } else if (rowKey === "reviewSkill") {
285
+ const current = Math.max(0, reviewSkillOptions.indexOf(reviewSkill));
286
+ const next = reviewSkillOptions[(current + 1) % reviewSkillOptions.length]!;
287
+
288
+ setReviewSkill(next);
289
+ persistReviewSkill(next);
290
+ } else if (rowKey === "reviewWorkspace") {
291
+ const next: ReviewWorkspaceMode = reviewWorkspace === "worktree" ? "current" : "worktree";
292
+
293
+ setReviewWorkspace(next);
294
+ persistReviewWorkspace(next);
251
295
  }
252
296
  };
253
297
 
@@ -165,12 +165,154 @@ describe("changesMarks", () => {
165
165
  expect([...changesMarks(session, rows).keys()].length).toBeGreaterThan(0);
166
166
  });
167
167
 
168
- test("a pinned diff review paints only artifact notes, not the working tree's file targets", () => {
168
+ test("a pinned PR diff paints its file-scoped agent findings", () => {
169
+ const rows = diffRows(PATCH);
170
+ const finding = {
171
+ ...fileNote(rows),
172
+ author: "agent",
173
+ reviewComment: {
174
+ severity: "p1" as const,
175
+ title: "Finding",
176
+ path: "x.ts",
177
+ line: 2,
178
+ side: "RIGHT" as const,
179
+ },
180
+ };
181
+ const session = diffThread({ pr: "org/repo#1" }, [finding]);
182
+
183
+ expect([...changesMarks(session, rows).keys()].length).toBeGreaterThan(0);
184
+ });
185
+
186
+ test("an outdated PR finding stays on its recorded line without rebinding", () => {
187
+ const rows = diffRows(PATCH);
188
+ const finding: Annotation = {
189
+ ...fileNote(rows),
190
+ anchor: { quote: "text that is no longer present", prefix: "", suffix: "" },
191
+ author: "agent",
192
+ reviewComment: {
193
+ severity: "p1",
194
+ title: "Finding",
195
+ path: "x.ts",
196
+ line: 2,
197
+ side: "RIGHT",
198
+ },
199
+ };
200
+ const session = diffThread({ pr: "org/repo#1" }, [finding]);
201
+ const marks = changesMarks(session, rows);
202
+
203
+ const findingMark = [...marks.values()].flat().find((mark) => mark.annotationId === finding.id);
204
+
205
+ expect(findingMark?.outdated).toBe(true);
206
+ });
207
+
208
+ test("a PR finding does not fuzzily rebind to different text", () => {
209
+ const rows = diffRows(PATCH);
210
+ const finding: Annotation = {
211
+ ...fileNote(rows),
212
+ anchor: {
213
+ quote: " private item = new Map();",
214
+ prefix: "export class Store {",
215
+ suffix: "}",
216
+ },
217
+ author: "agent",
218
+ reviewComment: {
219
+ severity: "p1",
220
+ title: "Finding",
221
+ path: "x.ts",
222
+ line: 2,
223
+ side: "RIGHT",
224
+ },
225
+ };
226
+ const marks = changesMarks(diffThread({ pr: "org/repo#1" }, [finding]), rows);
227
+ const findingMark = [...marks.values()].flat().find((mark) => mark.annotationId === finding.id);
228
+
229
+ expect(findingMark?.outdated).toBe(true);
230
+ expect(findingMark?.start).toBe(0);
231
+ expect(findingMark?.end).toBe(0);
232
+ });
233
+
234
+ test("a PR finding on an empty line remains visible with its replies", () => {
235
+ const patch = `diff --git a/x.ts b/x.ts
236
+ --- a/x.ts
237
+ +++ b/x.ts
238
+ @@ -1,1 +1,2 @@
239
+ const keep = 0;
240
+ +
241
+ `;
242
+ const rows = diffRows(patch);
243
+ const range = fileRowRange(rows, "x.ts")!;
244
+ const fileRows = rows.slice(range.start, range.end);
245
+ const emptyRow = fileRows.findIndex((row) => row.kind === "add" && row.text === "\n");
246
+ const finding: Annotation = {
247
+ id: "empty-finding",
248
+ kind: "comment",
249
+ anchor: diffRowAnchor(fileRows, emptyRow),
250
+ target: { kind: "file", path: "x.ts", rev: "worktree" },
251
+ body: "Keep this blank line.",
252
+ author: "agent",
253
+ createdAt: "2026-01-01T00:00:00Z",
254
+ reviewComment: {
255
+ severity: "p2",
256
+ title: "Empty line",
257
+ path: "x.ts",
258
+ line: 2,
259
+ side: "RIGHT",
260
+ },
261
+ };
262
+ const reply: Annotation = {
263
+ ...finding,
264
+ id: "empty-reply",
265
+ body: "Visible reply.",
266
+ author: "reviewer",
267
+ replyTo: finding.id,
268
+ reviewComment: undefined,
269
+ };
270
+ const marks = changesMarks(diffThread({ pr: "org/repo#1" }, [finding, reply]), rows);
271
+ const marked = [...marks.values()].flat();
272
+ const findingMark = marked.find((mark) => mark.annotationId === finding.id);
273
+ const replyMark = marked.find((mark) => mark.annotationId === reply.id);
274
+
275
+ expect(findingMark).toMatchObject({ start: 0, end: 0 });
276
+ expect(findingMark?.outdated).toBeUndefined();
277
+ expect(replyMark?.span).toEqual(findingMark?.span);
278
+ });
279
+
280
+ test("replies stay grouped with an outdated PR finding", () => {
281
+ const rows = diffRows(PATCH);
282
+ const finding: Annotation = {
283
+ ...fileNote(rows),
284
+ anchor: { quote: "text that is no longer present", prefix: "", suffix: "" },
285
+ author: "agent",
286
+ reviewComment: {
287
+ severity: "p1",
288
+ title: "Finding",
289
+ path: "x.ts",
290
+ line: 2,
291
+ side: "RIGHT",
292
+ },
293
+ };
294
+ const reply: Annotation = {
295
+ ...finding,
296
+ id: "reply-1",
297
+ author: "reviewer",
298
+ body: "Keep this discussion visible.",
299
+ replyTo: finding.id,
300
+ reviewComment: undefined,
301
+ };
302
+ const marks = changesMarks(diffThread({ pr: "org/repo#1" }, [finding, reply]), rows);
303
+ const marked = [...marks.values()].flat();
304
+ const findingMark = marked.find((mark) => mark.annotationId === finding.id);
305
+ const replyMark = marked.find((mark) => mark.annotationId === reply.id);
306
+
307
+ expect(replyMark?.span).toEqual(findingMark?.span);
308
+ expect(replyMark?.outdated).toBe(true);
309
+ });
310
+
311
+ test("a plain pinned diff ignores working-tree file targets", () => {
169
312
  const rows = diffRows(PATCH);
170
313
  const session = diffThread({}, [fileNote(rows)]);
171
314
 
172
- // the same file-targeted note is not an artifact note, so a frozen review leaves it unpainted
173
- expect([...changesMarks(session, rows).keys()].length).toBe(0);
315
+ expect([...changesMarks(session, rows).keys()]).toEqual([]);
174
316
  });
175
317
 
176
318
  test("a shared snapshot still paints the workbench's file-targeted feedback", () => {