@dev.fast/whiteboard 0.3.4-preview.20261019.126 → 0.3.4

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.
Files changed (115) hide show
  1. package/dist/{account-alias-B5-HYi1D.js → account-alias-CprZojXd.js} +8 -13
  2. package/dist/agent-cli-C6SPSII0.js +2 -0
  3. package/dist/{agent-cli-CohHf6Ua.js → agent-cli-CThN7Q59.js} +10 -15
  4. package/dist/{agent-client-Dtc-Kn_w.js → agent-client-BOOi7EUR.js} +3 -13
  5. package/dist/{authoring-tools-B7KU04f7.js → authoring-tools-BNwuMnz6.js} +27 -34
  6. package/dist/{background-server-C03RKxKS.js → background-server-CkvxA-6O.js} +5 -5
  7. package/dist/{background-server-CB9C4uoc.js → background-server-DLSDogkI.js} +1 -1
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-CJaEb7zC.js → cli-R4yfa5DT.js} +1 -1
  10. package/dist/{cli-runner-DGDIqh3V.js → cli-runner-Cuc9FSzu.js} +26 -26
  11. package/dist/cli.js +3 -4
  12. package/dist/{client-lKVF_Elo.js → client-BN_FDxFr.js} +47 -253
  13. package/dist/{fuzzy-match-BmVJJm9I.js → fuzzy-match-BO9bK37N.js} +4 -6
  14. package/dist/{headless-host-DRtjf9WA.js → headless-host-CVBOQyRS.js} +1 -1
  15. package/dist/{headless-host-XhL1X4vX.js → headless-host-Cog6SpS9.js} +140 -513
  16. package/dist/{local-data-Cpl4ENbJ.js → local-data-D_9tvdsp.js} +323 -2419
  17. package/dist/{mcp-ovmiUE_x.js → mcp-D1hziANa.js} +9 -5
  18. package/dist/{package-paths-BWxO-UEL.js → package-paths-C--__jEv.js} +1 -1
  19. package/dist/profile-CYPmvBoA.js +2 -0
  20. package/dist/{profile-BadSkCkI.js → profile-DokFw782.js} +8 -20
  21. package/dist/{remote-attach-Dd-YobM6.js → remote-attach-CEJ-kvDY.js} +4 -4
  22. package/dist/{remote-extensions-CNXwqK0D.js → remote-extensions-Blcjr9Ze.js} +3 -3
  23. package/dist/{remote-extensions-VP-kfq0C.js → remote-extensions-GN8Gf_qG.js} +1 -1
  24. package/dist/{remote-language-server-BXk87KCr.js → remote-language-server-BTKBQ7x4.js} +5 -5
  25. package/dist/{remote-uninstall-DuOzsvaD.js → remote-uninstall-DW7Gdryh.js} +6 -6
  26. package/dist/{request-origin-B9qCPiVB.js → request-origin-Zcn_CjX4.js} +12 -45
  27. package/dist/{review-app-launcher-C7el2Uoy.js → review-app-launcher-CkwLclzR.js} +2 -2
  28. package/dist/{review-home-paths-D8AoCdd2.js → review-home-paths-j2BRAaNv.js} +17 -19
  29. package/dist/{review-telemetry-BsoRMC2w.js → review-telemetry-BLNwYnLV.js} +5 -5
  30. package/dist/runtime-CKiWhGrf.js +52 -0
  31. package/dist/runtime.d.ts +1 -1
  32. package/dist/runtime.js +1 -1
  33. package/dist/{s3-CsG2EWED.js → s3-DtJFWC0G.js} +1 -1
  34. package/dist/{s3-config-CjcLyFV5.js → s3-config-BTO12tCC.js} +2 -4
  35. package/dist/{s3-config-zz4GWh2T.js → s3-config-CSy3fx8H.js} +1 -1
  36. package/dist/server/desktop-host.js +76 -29
  37. package/dist/{server-discovery-CKjw3-6h.js → server-discovery-Bey8IqXH.js} +3 -3
  38. package/dist/sharing/index.d.ts +70 -484
  39. package/dist/sharing/index.js +1 -1
  40. package/dist/{src-WPaQn75m.js → src-BMPuxWv4.js} +73 -208
  41. package/dist/{src-BiWNbcF_.js → src-DsM3jO8G.js} +12 -89
  42. package/dist/{stored-document-migration-dFbewRTG.js → stored-document-migration-CChxtFAy.js} +269 -270
  43. package/dist/{thread-state-Do5LX10I.js → thread-state-5pYr6_zM.js} +2 -7
  44. package/dist/{tool-failure-DvI-gys2.js → tool-failure-C9MpbXJJ.js} +3 -10
  45. package/instructions/authoring.md +2 -3
  46. package/instructions/scratchpad.md +23 -0
  47. package/package.json +4 -4
  48. package/src/ask/agents.ts +1 -13
  49. package/src/ask/checkout-files.ts +5 -2
  50. package/src/ask/thread-state.ts +0 -4
  51. package/src/ask/thread.ts +0 -16
  52. package/src/ask/threads.ts +3 -24
  53. package/src/cli-install.ts +2 -13
  54. package/src/cli-runner.ts +1 -4
  55. package/src/cli.ts +1 -4
  56. package/src/diff-selection-migration.ts +34 -48
  57. package/src/lens-selection.ts +2 -38
  58. package/src/review-api/README.md +13 -52
  59. package/src/review-api/activity.ts +4 -1
  60. package/src/review-api/agent-cli.ts +1 -9
  61. package/src/review-api/anchor-quotes.ts +7 -14
  62. package/src/review-api/ask-history.ts +51 -137
  63. package/src/review-api/authoring-tools.ts +30 -45
  64. package/src/review-api/blocks/definition.ts +1 -1
  65. package/src/review-api/blocks/markdown.ts +2 -2
  66. package/src/review-api/document-headings.ts +27 -58
  67. package/src/review-api/document-text.ts +2 -31
  68. package/src/review-api/document.ts +34 -281
  69. package/src/review-api/github-provider.ts +13 -51
  70. package/src/review-api/http.ts +123 -702
  71. package/src/review-api/instructions.ts +19 -0
  72. package/src/review-api/local-data.ts +46 -310
  73. package/src/review-api/mcp.ts +11 -3
  74. package/src/review-api/onboarding.ts +1 -1
  75. package/src/review-api/profile.ts +6 -37
  76. package/src/review-api/public-tools.ts +2 -24
  77. package/src/review-api/pull-request.ts +49 -82
  78. package/src/review-api/request-origin.ts +0 -10
  79. package/src/review-api/review-progress.ts +27 -61
  80. package/src/review-api/store-schema.ts +29 -38
  81. package/src/review-api/store.ts +478 -2142
  82. package/src/review-api/worktree-events.ts +5 -3
  83. package/src/review-api/worktree-source.ts +7 -3
  84. package/src/review-api/worktree-structural.integration.ts +0 -72
  85. package/src/review-preferences.ts +88 -0
  86. package/src/review-stack.ts +23 -15
  87. package/src/runtime.ts +30 -4
  88. package/src/server/desktop-host.ts +1 -6
  89. package/src/server/desktop-server.ts +31 -1
  90. package/src/server/headless-host.ts +2 -0
  91. package/src/server/hono-http.ts +1 -2
  92. package/src/server/legacy-tutorial-cleanup.ts +3 -2
  93. package/src/server/review-gateway-list.ts +5 -3
  94. package/src/server/review-gateway-memory.ts +1 -1
  95. package/src/server/review-lifecycle-telemetry.ts +2 -2
  96. package/src/server/review-server-core.ts +5 -1
  97. package/src/server/structural-comparisons.ts +0 -47
  98. package/src/sharing/export.ts +7 -36
  99. package/src/sharing/host.ts +14 -34
  100. package/src/sharing/import.ts +24 -156
  101. package/src/sharing/repository.ts +20 -128
  102. package/src/startup-trace.ts +3 -3
  103. package/src/stored-document-migration.ts +19 -110
  104. package/src/ui-telemetry-events.ts +11 -33
  105. package/dist/agent-cli-_YyMIMRY.js +0 -2
  106. package/dist/lens-selection-CyJapcsl.js +0 -214
  107. package/dist/profile-DJvkBJNQ.js +0 -2
  108. package/dist/runtime-Bj2tD33R.js +0 -27
  109. package/src/heading-anchors.ts +0 -42
  110. package/src/review-api/board-order.ts +0 -144
  111. package/src/review-api/checkout-groups.ts +0 -560
  112. package/src/review-api/foreign-references.ts +0 -328
  113. package/src/review-api/link-preview.ts +0 -113
  114. package/src/review-api/scratchpad-plan-upgrade.ts +0 -250
  115. package/src/review-api/store-migrations.ts +0 -357
@@ -1,6 +1,8 @@
1
+ import { execFile } from "node:child_process";
1
2
  import { isAbsolute, relative } from "node:path";
3
+ import { promisify } from "node:util";
2
4
 
3
- import { runVcs } from "@dev.fast/local-vcs";
5
+ const exec = promisify(execFile);
4
6
 
5
7
  const normalize = (name: string) => name.replaceAll("\\", "/").normalize("NFC");
6
8
 
@@ -51,7 +53,7 @@ export async function ignoredDirectories(
51
53
  gitDirectory?: string,
52
54
  ): Promise<string[]> {
53
55
  try {
54
- const { stdout } = await runVcs(
56
+ const { stdout } = await exec(
55
57
  "git",
56
58
  [
57
59
  "-C",
@@ -103,7 +105,7 @@ export async function gitDirectoryPrefix(
103
105
  common: string,
104
106
  ): Promise<string | null> {
105
107
  try {
106
- const { stdout } = await runVcs("git", [
108
+ const { stdout } = await exec("git", [
107
109
  "-C",
108
110
  rootPath,
109
111
  "rev-parse",
@@ -1,7 +1,9 @@
1
+ import { execFile } from "node:child_process";
1
2
  import { createHash } from "node:crypto";
2
3
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
4
+ import { promisify } from "node:util";
3
5
 
4
- import { type LocalVcs, gitCommonDir, runVcs } from "@dev.fast/local-vcs";
6
+ import { type LocalVcs, gitCommonDir } from "@dev.fast/local-vcs";
5
7
  import { isMissingFileError } from "@review/fs-utils.js";
6
8
 
7
9
  import { checkoutFs } from "./checkout-fs.js";
@@ -9,6 +11,8 @@ import { ReviewInputError } from "./document.js";
9
11
 
10
12
  const { lstat, readFile, readlink, realpath } = checkoutFs;
11
13
 
14
+ const exec = promisify(execFile);
15
+
12
16
  export const EMPTY_SOURCE = "empty-worktree-baseline";
13
17
 
14
18
  /** Never follow a working-copy link outside the selected checkout. */
@@ -45,7 +49,7 @@ export async function workingFiles(vcs: LocalVcs): Promise<string[]> {
45
49
  "Working source requires a Git-backed jj repository.",
46
50
  );
47
51
 
48
- const { stdout } = await runVcs(
52
+ const { stdout } = await exec(
49
53
  "git",
50
54
  [
51
55
  "-C",
@@ -96,7 +100,7 @@ export async function untrackedFileCount(
96
100
  if (vcs.kind !== "git") return undefined;
97
101
 
98
102
  try {
99
- const { stdout } = await runVcs(
103
+ const { stdout } = await exec(
100
104
  "git",
101
105
  [
102
106
  "-C",
@@ -318,75 +318,3 @@ it("compares a staged file in an unborn repository with empty source", async ()
318
318
  rmSync(root, { recursive: true, force: true });
319
319
  }
320
320
  }, 30_000);
321
-
322
- it("shows a peek on an untracked file as head lines", async () => {
323
- const root = mkdtempSync(path.join(tmpdir(), "worktree-untracked-"));
324
- const repository = path.join(root, "repository");
325
- mkdirSync(repository);
326
-
327
- const git = (...args: string[]) =>
328
- execFileSync("git", ["-C", repository, ...args], {
329
- encoding: "utf8",
330
- }).trim();
331
-
332
- git("init", "-q", "-b", "main");
333
- writeFileSync(path.join(repository, "value.ts"), "export const value = 1;\n");
334
- git("add", "value.ts");
335
- git(
336
- "-c",
337
- "user.name=Test",
338
- "-c",
339
- "user.email=test@example.invalid",
340
- "commit",
341
- "-qm",
342
- "Base",
343
- );
344
- writeFileSync(path.join(repository, "new.ts"), "export const a = 1;\n");
345
-
346
- const local = openLocalReviewStore(path.join(root, "reviews.db"));
347
-
348
- const selection = {
349
- file: "new.ts",
350
- start: { side: "head" as const, line: 1 },
351
- end: { side: "head" as const, line: 1 },
352
- };
353
-
354
- try {
355
- const { id } = await local.data.register(repository);
356
-
357
- const { reviewId } = await local.store.execute({
358
- operation: {
359
- type: "create",
360
- title: "Live",
361
- target: { kind: "worktree", repositoryId: id },
362
- },
363
- });
364
-
365
- await local.store.execute({
366
- operation: {
367
- type: "edit",
368
- reviewId,
369
- edit: {
370
- type: "insert",
371
- content: { type: "code_peek", source: formatAnchor(selection) },
372
- },
373
- },
374
- });
375
-
376
- const progress = await (
377
- await createReviewApi(local.store, local.data).request(
378
- `/${reviewId}/progress`,
379
- )
380
- ).json();
381
-
382
- expect(progress.untrackedFiles).toBe(1);
383
- expect(progress.unavailableSelections).toEqual({});
384
- expect(progress.resolvedSelections[selectionKey(selection)]).toEqual([
385
- { file: "new.ts", side: "head", fromLine: 1, toLine: 1 },
386
- ]);
387
- } finally {
388
- await local.data.close();
389
- await local.store.close();
390
- rmSync(root, { recursive: true, force: true });
391
- }
392
- });
@@ -0,0 +1,88 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ type JsonValue,
6
+ isJsonObject,
7
+ parseJsonText,
8
+ } from "@dev.fast/review-protocol";
9
+ import { writePrivateJsonAtomic } from "@dev.fast/trace-core";
10
+
11
+ import { devReviewHome } from "./review-home-paths";
12
+
13
+ /**
14
+ * Machine-wide Review preferences the server itself needs. Workbench settings
15
+ * do not work here: the review server never reads the workbench
16
+ * configuration.
17
+ */
18
+ export interface ReviewPreferences {
19
+ /**
20
+ * Whether the scratchpad exists on this machine. Off, the server neither
21
+ * makes nor lists it and Review's instructions do not offer it to agents.
22
+ */
23
+ scratchpadEnabled: boolean;
24
+ }
25
+
26
+ const DEFAULT_REVIEW_PREFERENCES: ReviewPreferences = {
27
+ scratchpadEnabled: false,
28
+ };
29
+
30
+ function reviewPreferencesPath(devHome = devReviewHome()): string {
31
+ return path.join(devHome, "preferences.json");
32
+ }
33
+
34
+ /** A missing or unreadable file falls back to the defaults; it never throws. */
35
+ export async function readReviewPreferences(
36
+ devHome?: string,
37
+ ): Promise<ReviewPreferences> {
38
+ try {
39
+ const raw = parseJsonText(
40
+ await readFile(reviewPreferencesPath(devHome), "utf8"),
41
+ );
42
+
43
+ return {
44
+ scratchpadEnabled: parseScratchpadEnabled(raw),
45
+ };
46
+ } catch {
47
+ return { ...DEFAULT_REVIEW_PREFERENCES };
48
+ }
49
+ }
50
+
51
+ export async function writeReviewPreferences(
52
+ preferences: ReviewPreferences,
53
+ devHome?: string,
54
+ ): Promise<ReviewPreferences> {
55
+ const next: ReviewPreferences = {
56
+ scratchpadEnabled: preferences.scratchpadEnabled === true,
57
+ };
58
+
59
+ await writePrivateJsonAtomic(reviewPreferencesPath(devHome), next);
60
+
61
+ return next;
62
+ }
63
+
64
+ /** Reads the scratchpad preference; a missing file means off. */
65
+ export async function readScratchpadEnabled(
66
+ devHome?: string,
67
+ ): Promise<boolean> {
68
+ return (await readReviewPreferences(devHome)).scratchpadEnabled;
69
+ }
70
+
71
+ /** Sets the scratchpad preference, keeping the other preferences as they are. */
72
+ export async function writeScratchpadEnabled(
73
+ enabled: boolean,
74
+ devHome?: string,
75
+ ): Promise<boolean> {
76
+ const current = await readReviewPreferences(devHome);
77
+
78
+ return (
79
+ await writeReviewPreferences(
80
+ { ...current, scratchpadEnabled: enabled },
81
+ devHome,
82
+ )
83
+ ).scratchpadEnabled;
84
+ }
85
+
86
+ function parseScratchpadEnabled(raw: JsonValue): boolean {
87
+ return isJsonObject(raw) && raw.scratchpadEnabled === true;
88
+ }
@@ -1,10 +1,15 @@
1
- import { defaultBranch, git, runVcs } from "@dev.fast/local-vcs";
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ import { defaultBranch, git } from "@dev.fast/local-vcs";
2
5
  import type {
3
6
  ReviewApiSummary,
4
7
  ReviewStackLayer,
5
8
  } from "@dev.fast/review-protocol";
6
9
  import { z } from "zod";
7
10
 
11
+ const execFileAsync = promisify(execFile);
12
+
8
13
  const GitHubStacksSchema = z.array(
9
14
  z.object({
10
15
  pull_requests: z.array(
@@ -22,7 +27,7 @@ type Subject = Pick<ReviewApiSummary, "origin"> & { reviewId?: string };
22
27
 
23
28
  type Candidate = Pick<
24
29
  ReviewApiSummary,
25
- "reviewId" | "title" | "origin" | "pins" | "trashedAt" | "project"
30
+ "reviewId" | "title" | "origin" | "pins" | "trashedAt" | "repositoryGroup"
26
31
  >;
27
32
 
28
33
  interface StackEntry {
@@ -135,7 +140,7 @@ async function localStack(
135
140
  repositoryPath: string,
136
141
  ): Promise<StackEntry[]> {
137
142
  const group = reviews.find((review) => review.reviewId === subject.reviewId)
138
- ?.project?.id;
143
+ ?.repositoryGroup?.key;
139
144
 
140
145
  const current = subject.origin?.branch;
141
146
 
@@ -147,7 +152,9 @@ async function localStack(
147
152
 
148
153
  const name = (ref: string) => ref.replace(/^origin\//, "");
149
154
 
150
- const inGroup = reviews.filter((review) => review.project?.id === group);
155
+ const inGroup = reviews.filter(
156
+ (review) => review.repositoryGroup?.key === group,
157
+ );
151
158
 
152
159
  const latest = (field: "branch" | "baseBranch", branch: string) => {
153
160
  const matches = inGroup.filter(
@@ -201,13 +208,9 @@ async function localStack(
201
208
  async function forkPoint(repositoryPath: string, main: string, branch: string) {
202
209
  const ref = `refs/heads/${branch}`;
203
210
 
204
- const fork = await git(
205
- repositoryPath,
206
- ["merge-base", { rev: main }, { rev: ref }],
207
- {
208
- allowFailure: true,
209
- },
210
- );
211
+ const fork = await git(repositoryPath, ["merge-base", main, ref], {
212
+ allowFailure: true,
213
+ });
211
214
 
212
215
  if (!fork.ok) return null;
213
216
 
@@ -215,7 +218,7 @@ async function forkPoint(repositoryPath: string, main: string, branch: string) {
215
218
 
216
219
  const log = await git(
217
220
  repositoryPath,
218
- ["log", "--reverse", "--format=%s", { rev: `${base}..${ref}` }],
221
+ ["log", "--reverse", "--format=%s", `${base}..${ref}`],
219
222
  { allowFailure: true },
220
223
  );
221
224
 
@@ -226,10 +229,15 @@ async function defaultRunGitHubApi(
226
229
  host: string,
227
230
  endpoint: string,
228
231
  ): Promise<string> {
229
- const { stdout } = await runVcs(
232
+ const { stdout } = await execFileAsync(
230
233
  "gh",
231
- ["api", "--hostname", { value: host }, { value: endpoint }],
232
- { timeout: 10_000, maxBuffer: 2 * 1024 * 1024 },
234
+ ["api", "--hostname", host, endpoint],
235
+ {
236
+ env: { ...process.env, GH_PROMPT_DISABLED: "1" },
237
+ encoding: "utf8",
238
+ timeout: 10_000,
239
+ maxBuffer: 2 * 1024 * 1024,
240
+ },
233
241
  );
234
242
 
235
243
  return stdout;
package/src/runtime.ts CHANGED
@@ -1,7 +1,6 @@
1
+ import { spawn } from "node:child_process";
1
2
  import path from "node:path";
2
3
 
3
- import { runVcs } from "@dev.fast/local-vcs";
4
-
5
4
  export async function resolveReviewRoot(
6
5
  cwd: string,
7
6
  execFile: typeof execFilePromise = execFilePromise,
@@ -40,9 +39,36 @@ export async function resolveReviewRoot(
40
39
  }
41
40
 
42
41
  function execFilePromise(
43
- file: "git" | "jj",
42
+ file: string,
44
43
  args: string[],
45
44
  options: { cwd?: string } = {},
46
45
  ): Promise<{ stdout: string }> {
47
- return runVcs(file, args, options);
46
+ return execFileUntraced(file, args, options);
47
+ }
48
+
49
+ function execFileUntraced(
50
+ file: string,
51
+ args: string[],
52
+ options: { cwd?: string } = {},
53
+ ): Promise<{ stdout: string }> {
54
+ return new Promise((resolve, reject) => {
55
+ const child = spawn(file, args, {
56
+ cwd: options.cwd,
57
+ stdio: ["ignore", "pipe", "pipe"],
58
+ });
59
+
60
+ let stdout = "";
61
+ let stderr = "";
62
+ child.stdout?.on("data", (chunk) => {
63
+ stdout += String(chunk);
64
+ });
65
+ child.stderr?.on("data", (chunk) => {
66
+ stderr += String(chunk);
67
+ });
68
+ child.on("error", reject);
69
+ child.on("exit", (code) => {
70
+ if (code === 0) resolve({ stdout });
71
+ else reject(new Error(stderr.trim() || `${file} exited with ${code}`));
72
+ });
73
+ });
48
74
  }
@@ -84,8 +84,6 @@ export async function runDesktopHost(
84
84
  const { local, shared, server } = await takeOverServerHome(home, async () => {
85
85
  const local = await openReviewProfile(home, {
86
86
  manageWorkspaces: true,
87
- // Desktop shows each PR's state beside its board.
88
- pullRequestStates: true,
89
87
  });
90
88
 
91
89
  // JSON is the sole user-review store. A failure is surfaced, never replaced
@@ -199,12 +197,9 @@ function requiredPositiveInteger(
199
197
 
200
198
  if (process.env.DEV_FAST_REVIEW_DESKTOP_HOST_AUTOSTART !== "0") {
201
199
  void runDesktopHost().catch((error) => {
202
- // Exit now: a host that couldn't start (a store that couldn't be
203
- // upgraded) must not linger with timers alive, or Desktop waits out its
204
- // ready timeout instead of reporting why.
205
200
  process.stderr.write(
206
201
  `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
207
- () => process.exit(1),
208
202
  );
203
+ process.exitCode = 1;
209
204
  });
210
205
  }
@@ -47,6 +47,10 @@ import {
47
47
  reviewInstanceDiscoveryPath,
48
48
  reviewLegacyDiscoveryPath,
49
49
  } from "@review/review-home-paths";
50
+ import {
51
+ readScratchpadEnabled,
52
+ writeScratchpadEnabled,
53
+ } from "@review/review-preferences";
50
54
  import {
51
55
  ReviewTelemetry,
52
56
  type ReviewTelemetryContext,
@@ -172,6 +176,10 @@ export function createGlobalReviewServer(
172
176
  let closing = false;
173
177
  const cliPath = path.join(input.packageRoot, "dist", "cli.js");
174
178
 
179
+ // The scratchpad preference, read once at listen and kept current by the
180
+ // settings endpoint below: this server is the only writer while it runs.
181
+ let scratchpadEnabled = false;
182
+
175
183
  const discovery: ReviewDesktopDiscovery = {
176
184
  version: REVIEW_DESKTOP_DISCOVERY_VERSION,
177
185
  instanceId,
@@ -204,6 +212,7 @@ export function createGlobalReviewServer(
204
212
  relay,
205
213
  token,
206
214
  instanceId,
215
+ scratchpad: () => scratchpadEnabled,
207
216
  status: () => {
208
217
  const { key, channel, checkout, appVersion, cliVersion, instanceId } =
209
218
  discovery;
@@ -256,6 +265,25 @@ export function createGlobalReviewServer(
256
265
  app.all("/reviews-api", (context) => gateway.fetch(context.req.raw));
257
266
  app.all("/reviews-api/*", (context) => gateway.fetch(context.req.raw));
258
267
  app.get("/remote-hosts", () => serverJson(200, gateway.hosts()));
268
+ app.get("/preferences/scratchpad", () =>
269
+ serverJson(200, { enabled: scratchpadEnabled }),
270
+ );
271
+ app.put("/preferences/scratchpad", async (context) => {
272
+ const request = z
273
+ .object({ enabled: z.boolean() })
274
+ .safeParse(await readBoundedRequestJson(context.req.raw));
275
+
276
+ if (!request.success)
277
+ throw new ReviewServerError("enabled must be a boolean.", 400);
278
+
279
+ scratchpadEnabled = await writeScratchpadEnabled(request.data.enabled);
280
+
281
+ // Home watches the catalog; the pad appears or goes without a store write.
282
+ if (scratchpadEnabled) await reviewStore.ensureScratchpad();
283
+ reviewStore.invalidateCatalog();
284
+
285
+ return serverJson(200, { enabled: scratchpadEnabled });
286
+ });
259
287
  app.post("/app/focus", async () => {
260
288
  const result = await relay.dispatch({
261
289
  name: "focusWindow",
@@ -497,6 +525,7 @@ export function createGlobalReviewServer(
497
525
  store: reviewStore,
498
526
  data: input.reviewData,
499
527
  });
528
+ scratchpadEnabled = await readScratchpadEnabled();
500
529
  boundPort = await listen(httpServer, input.port);
501
530
  discovery.url = urlForBoundPort();
502
531
 
@@ -554,7 +583,8 @@ export function sessionStartedSource(
554
583
  try {
555
584
  const snapshot = reviewStore.read(reviewUuid);
556
585
 
557
- const sourceKind = snapshot.target?.kind;
586
+ const sourceKind =
587
+ snapshot.kind === "scratchpad" ? snapshot.kind : snapshot.target?.kind;
558
588
 
559
589
  const pullRequestUrl = snapshot.origin?.pullRequestUrl;
560
590
 
@@ -221,6 +221,8 @@ async function serve(input: HeadlessServerInput) {
221
221
  token: discovery.token,
222
222
  instanceId: discovery.instanceId,
223
223
  softwareMapEnabled: input.softwareMapEnabled,
224
+ // The scratchpad is the laptop's alone, even with a Desktop attached.
225
+ scratchpad: () => false,
224
226
  status: () => ({ key: "headless", home: input.stateDir }),
225
227
  ask: {
226
228
  tools: cliAskTools(() => (existsSync(cliPath) ? cliPath : undefined), {
@@ -7,7 +7,6 @@ import {
7
7
  parseJsonText,
8
8
  } from "@dev.fast/review-protocol";
9
9
  import { type HttpBindings, getRequestListener } from "@hono/node-server";
10
- import { REVIEW_PERSON_HEADER } from "@review/review-api/request-origin";
11
10
  import { REVIEW_APP_SESSION_ID_HEADER } from "@review/ui-telemetry-events";
12
11
  import type { Hono } from "hono";
13
12
  import type { ContentfulStatusCode } from "hono/utils/http-status";
@@ -74,7 +73,7 @@ export function applyCorsHeaders(
74
73
 
75
74
  response.headers.set(
76
75
  "access-control-allow-headers",
77
- `content-type, x-review-token, ${REVIEW_PERSON_HEADER}, ${REVIEW_APP_SESSION_ID_HEADER}`,
76
+ `content-type, x-review-token, ${REVIEW_APP_SESSION_ID_HEADER}`,
78
77
  );
79
78
  response.headers.set(
80
79
  "access-control-allow-methods",
@@ -13,13 +13,14 @@ import { devReviewHome } from "@review/review-home-paths";
13
13
  */
14
14
  export async function removeLegacyTutorial(input: {
15
15
  store: ReviewStore;
16
- data: Pick<LocalReviewData, "forgetRepository">;
16
+ data: LocalReviewData;
17
17
  root?: string;
18
18
  }): Promise<void> {
19
19
  const tutorialRoot = input.root ?? path.join(devReviewHome(), "tutorial");
20
20
 
21
21
  try {
22
- await input.store.removeLegacyTutorials();
22
+ for (const reviewId of input.store.legacyTutorialIds())
23
+ await input.store.execute({ operation: { type: "delete", reviewId } });
23
24
 
24
25
  for (const repository of input.store.repositories()) {
25
26
  const relative = path.relative(tutorialRoot, repository.path);
@@ -32,9 +32,11 @@ function decorate(
32
32
 
33
33
  return {
34
34
  ...entry,
35
- // A project on another machine is that machine's.
36
- ...(entry.project && {
37
- project: { id: `${alias}:${entry.project.id}`, name: entry.project.name },
35
+ ...(entry.repositoryGroup && {
36
+ repositoryGroup: {
37
+ key: `${alias}:${entry.repositoryGroup.key}`,
38
+ label: entry.repositoryGroup.label,
39
+ },
38
40
  }),
39
41
  host: alias,
40
42
  hostState,
@@ -16,7 +16,7 @@ const listEntrySchema = z.looseObject({
16
16
  repositoryName: z.string(),
17
17
  viewedAt: z.string().nullable(),
18
18
  trashedAt: z.string().nullable(),
19
- project: z.object({ id: z.string(), name: z.string() }).optional(),
19
+ repositoryGroup: z.object({ key: z.string(), label: z.string() }).optional(),
20
20
  });
21
21
 
22
22
  export const listEntriesSchema = z.array(z.unknown()).transform(
@@ -34,12 +34,12 @@ export function reviewLifecycleTelemetry(
34
34
  );
35
35
 
36
36
  return {
37
- onReviewCreated: ({ reviewId, blocks, via, agentKind }) => {
37
+ onReviewCreated: ({ reviewId, kind, blocks, via, agentKind }) => {
38
38
  if (reported.has(reviewId)) return;
39
39
  reported.add(reviewId);
40
40
 
41
41
  if (via === "api" || via === "mcp") authoring.set(reviewId, agentKind);
42
- const properties: PostHogCaptureProperties = { blocks, via };
42
+ const properties: PostHogCaptureProperties = { kind, blocks, via };
43
43
 
44
44
  if (agentKind) properties.agent_kind = agentKind;
45
45
  capture("review_review_created", properties, reviewId);
@@ -132,6 +132,7 @@ export interface WhiteboardCoreInput {
132
132
  token: string;
133
133
  instanceId: string;
134
134
  softwareMapEnabled?: boolean;
135
+ scratchpad: () => boolean;
135
136
  status: () => JsonObject;
136
137
  hooks?: ReviewApiHooks;
137
138
  ask?: {
@@ -191,6 +192,7 @@ export function createWhiteboardCore(input: WhiteboardCoreInput) {
191
192
  callbacks.open,
192
193
  shared,
193
194
  callbacks.capabilities,
195
+ input.scratchpad,
194
196
  () => traceMachineEnabled(),
195
197
  input.status,
196
198
  input.hooks,
@@ -234,7 +236,9 @@ export function relayReviewCallbacks(
234
236
 
235
237
  return z.object({ softwareMapEnabled: z.boolean() }).parse(result.result);
236
238
  },
237
- async capabilities(): Promise<AuthoringCapabilities> {
239
+ async capabilities(): Promise<
240
+ Omit<AuthoringCapabilities, "scratchpadEnabled">
241
+ > {
238
242
  if (!relay.attached)
239
243
  return { desktopAvailable: false, softwareMapEnabled };
240
244
 
@@ -1,8 +1,3 @@
1
- import { stat } from "node:fs/promises";
2
- import { homedir } from "node:os";
3
- import path from "node:path";
4
-
5
- import { git, gitCommonDir } from "@dev.fast/local-vcs";
6
1
  import type { StructuralDiffEvent } from "@dev.fast/review-protocol";
7
2
 
8
3
  import {
@@ -23,7 +18,6 @@ export class StructuralComparisons {
23
18
 
24
19
  const key = JSON.stringify([
25
20
  configGeneration,
26
- await classificationStamp(input.repositoryPath),
27
21
  input.repositoryPath,
28
22
  input.comparison,
29
23
  input.paths,
@@ -72,47 +66,6 @@ export class StructuralComparisons {
72
66
 
73
67
  let configGeneration = 0;
74
68
 
75
- const classificationFiles = new Map<string, Promise<string[]>>();
76
-
77
- /** Git attribute and diffr config files that diffr's classifier reads. */
78
- function classificationInputs(root: string): Promise<string[]> {
79
- let files = classificationFiles.get(root);
80
-
81
- if (!files) {
82
- const config =
83
- process.env.XDG_CONFIG_HOME || path.join(homedir(), ".config");
84
-
85
- const diffrDirectory =
86
- process.env.DIFFR_CONFIG_DIR || path.join(config, "diffr");
87
-
88
- files = Promise.all([
89
- gitCommonDir(root),
90
- git(root, ["config", "--path", "core.attributesFile"], {
91
- allowFailure: true,
92
- }),
93
- ]).then(([common, user]) => [
94
- ...(common ? [path.join(common, "info", "attributes")] : []),
95
- path.join(root, ".gitattributes"),
96
- user.stdout.trim() || path.join(config, "git", "attributes"),
97
- path.join(diffrDirectory, "config.toml"),
98
- ]);
99
- classificationFiles.set(root, files);
100
- }
101
-
102
- return files;
103
- }
104
-
105
- async function classificationStamp(root: string) {
106
- return Promise.all(
107
- (await classificationInputs(root)).map((file) =>
108
- stat(file).then(
109
- ({ mtimeMs, size }) => [mtimeMs, size],
110
- () => null,
111
- ),
112
- ),
113
- );
114
- }
115
-
116
69
  export function invalidateStructuralComparisons(): void {
117
70
  configGeneration++;
118
71
  }