@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/package.json +5 -5
- package/src/App.diff.test.tsx +63 -0
- package/src/App.tsx +68 -4
- package/src/app-screens.tsx +0 -1
- package/src/app-view-model.ts +1 -0
- package/src/components/AnnotationCards.tsx +1 -1
- package/src/components/DiffContentView.scroll.test.tsx +29 -1
- package/src/components/DiffContentView.tsx +33 -4
- package/src/components/ThreadView.selection.test.tsx +23 -0
- package/src/components/ThreadView.tsx +2 -2
- package/src/components/WelcomePlayground.stories.tsx +1 -1
- package/src/components/WelcomePlayground.test.tsx +10 -8
- package/src/components/WelcomePlayground.tsx +2 -5
- package/src/components/primitives/Button.tsx +5 -2
- package/src/config.test.ts +62 -1
- package/src/config.ts +66 -0
- package/src/index.ts +16 -1
- package/src/intent-dispatch.test.ts +1 -0
- package/src/launch-layout.test.ts +16 -1
- package/src/launch-layout.ts +6 -1
- package/src/prototype-browser.test.ts +19 -2
- package/src/prototype-browser.ts +7 -7
- package/src/theme-presets.ts +5 -0
- package/src/theme.ts +4 -0
- package/src/thread-controller.ts +13 -0
- package/src/use-settings-dialog.tsx +44 -0
- package/src/view-diff.test.ts +145 -3
- package/src/view-diff.ts +201 -11
- package/src/view-plan.ts +2 -0
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 {
|
|
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 {
|
|
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 });
|
package/src/launch-layout.ts
CHANGED
|
@@ -14,11 +14,16 @@ export interface LaunchLayout {
|
|
|
14
14
|
zoomChanges: boolean;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
/** `cueloop diff
|
|
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 {
|
|
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
|
});
|
package/src/prototype-browser.ts
CHANGED
|
@@ -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);
|
package/src/theme-presets.ts
CHANGED
|
@@ -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",
|
package/src/thread-controller.ts
CHANGED
|
@@ -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
|
|
package/src/view-diff.test.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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", () => {
|