@dev.fast/whiteboard 0.4.0 → 0.4.1-preview.20261026.138

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 (78) hide show
  1. package/dist/{account-alias-2yTAxoBx.js → account-alias-BPlPnzQM.js} +7 -7
  2. package/dist/{agent-cli-CHNDd4Ga.js → agent-cli-BbCf9uid.js} +11 -11
  3. package/dist/agent-cli-ipN_JmzG.js +2 -0
  4. package/dist/{agent-client-Dc0vHFJt.js → agent-client-DfpM8F5N.js} +3 -3
  5. package/dist/{authoring-tools-D8EkIDjm.js → authoring-tools-bm39DRoV.js} +23 -10
  6. package/dist/{background-server-DyWnPJyZ.js → background-server--0EeCCeV.js} +1 -1
  7. package/dist/{background-server-DHWHzpo5.js → background-server-B--5p72o.js} +6 -6
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-qZomwJmK.js → cli-DBTQIroM.js} +2 -2
  10. package/dist/{cli-runner-DFBaVGij.js → cli-runner-CRFCdb7G.js} +25 -25
  11. package/dist/cli.js +3 -3
  12. package/dist/{fuzzy-match-CfSxqzBF.js → fuzzy-match-l3mJZ1Ge.js} +5 -5
  13. package/dist/{headless-host-dwlqhqMY.js → headless-host-5ei3nHS8.js} +1 -1
  14. package/dist/{headless-host-DJhcab6r.js → headless-host-BkJ9OUQY.js} +131 -299
  15. package/dist/{client-p9WB8Sj0.js → import-Bwvf9Wos.js} +505 -390
  16. package/dist/{lens-selection-9byzEgvU.js → lens-selection-EuZdag4Q.js} +1 -1
  17. package/dist/{local-data-DHedfhxw.js → local-data-BCSeTyic.js} +98 -154
  18. package/dist/{mcp-Br-r_wwR.js → mcp-_Xg1gQuA.js} +17 -8
  19. package/dist/{package-paths-BeYHYjeR.js → package-paths-Bg731FHS.js} +2 -2
  20. package/dist/profile-C8zL4u3L.js +2 -0
  21. package/dist/{profile-D9wdlPgw.js → profile-xzrUFgbT.js} +4 -4
  22. package/dist/{remote-attach-DSGpTOJ4.js → remote-attach-DF25Epoa.js} +4 -4
  23. package/dist/{remote-extensions-WtJO7pkb.js → remote-extensions-BJamgsCe.js} +1 -1
  24. package/dist/{remote-extensions-CsI_BJ_f.js → remote-extensions-CeywZL-Z.js} +4 -4
  25. package/dist/{remote-language-server-Dx13Mg2m.js → remote-language-server-CH8sG_CU.js} +6 -6
  26. package/dist/{remote-uninstall-Dp2o3qz0.js → remote-uninstall-Cq-lkBY7.js} +7 -7
  27. package/dist/{request-origin-CNSLVn7x.js → request-origin-CargJFvH.js} +14 -3
  28. package/dist/{review-app-launcher-BZAnR8Vp.js → review-app-launcher-D8ApNDUV.js} +3 -3
  29. package/dist/{review-home-paths-CG-sb9Cw.js → review-home-paths-DcB4xLD-.js} +2 -2
  30. package/dist/{review-telemetry-BM-_KdLi.js → review-telemetry-BW7gJrSo.js} +7 -7
  31. package/dist/{runtime-C6_1K-3Z.js → runtime-DY6Ctyog.js} +1 -1
  32. package/dist/runtime.js +1 -1
  33. package/dist/{s3-CsG2EWED.js → s3-BIWv4h1J.js} +2 -2
  34. package/dist/{s3-config-zz4GWh2T.js → s3-config-Du3RBIvv.js} +1 -1
  35. package/dist/server/desktop-host.js +51 -17
  36. package/dist/{server-discovery-DKLIOgg3.js → server-discovery-aHK_1r3U.js} +5 -5
  37. package/dist/sharing/index.d.ts +209 -98
  38. package/dist/sharing/index.js +1 -1
  39. package/dist/{src-DYHVzX3b.js → src-DEOc8bvC.js} +63 -2
  40. package/dist/{src-Z1x4-Qmb.js → src-DjRaCctk.js} +6 -6
  41. package/dist/{stored-document-migration-DEt8iwem.js → stored-document-migration-CwmTwTb8.js} +139 -13
  42. package/dist/{thread-state-CO97FUqK.js → thread-state-CbLB0jUA.js} +1 -1
  43. package/dist/{tool-failure-C1L1vJyz.js → tool-failure-DZaQzi4f.js} +2 -2
  44. package/instructions/animation.md +345 -0
  45. package/instructions/authoring.md +2 -2
  46. package/package.json +6 -6
  47. package/src/fixtures/blocks/animation.json +19 -0
  48. package/src/review-api/authoring-tools.ts +17 -6
  49. package/src/review-api/blocks/animation.ts +98 -0
  50. package/src/review-api/blocks/index.ts +3 -0
  51. package/src/review-api/blocks/sequence.ts +3 -1
  52. package/src/review-api/comparison-coverage.ts +120 -89
  53. package/src/review-api/component-reference.ts +16 -0
  54. package/src/review-api/document-text.ts +11 -0
  55. package/src/review-api/document.ts +26 -5
  56. package/src/review-api/http.ts +31 -2
  57. package/src/review-api/instructions.ts +11 -2
  58. package/src/review-api/local-data.ts +21 -4
  59. package/src/review-api/mcp.ts +22 -14
  60. package/src/review-api/onboarding.ts +100 -26
  61. package/src/review-preferences.ts +43 -0
  62. package/src/server/desktop-server.ts +24 -0
  63. package/src/server/json-review-reporting.ts +1 -1
  64. package/src/server/review-server-core.ts +2 -0
  65. package/src/server/structural-comparisons.ts +5 -11
  66. package/src/server/structural-diff.ts +8 -2
  67. package/src/sharing/cli.ts +1 -1
  68. package/src/sharing/client.ts +54 -222
  69. package/src/sharing/export.ts +172 -114
  70. package/src/sharing/host.ts +32 -36
  71. package/src/sharing/import.ts +93 -147
  72. package/src/sharing/index.ts +1 -6
  73. package/src/sharing/routes.ts +18 -17
  74. package/src/ui-telemetry-events.ts +11 -0
  75. package/dist/agent-cli-Cn4-16XA.js +0 -2
  76. package/dist/profile-BopGMZUh.js +0 -2
  77. package/dist/{s3-config-CjcLyFV5.js → s3-config-D7Yt0VhS.js} +1 -1
  78. package/dist/{src-X9ZebeOD.js → src-DXnn7RrI.js} +1 -1
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import crypto from "node:crypto";
3
- import { stat } from "node:fs/promises";
3
+ import { readFile, stat } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
 
@@ -82,25 +82,54 @@ async function modified(file: string): Promise<number> {
82
82
  }
83
83
  }
84
84
 
85
+ /** The folder holding a bare clone's `.git` file, as in `project/.bare`;
86
+ * else the clone itself. */
87
+ async function bareRoot(bare: string) {
88
+ const parent = path.dirname(bare);
89
+
90
+ try {
91
+ const gitdir = /^gitdir:\s*(.+)$/m.exec(
92
+ await readFile(path.join(parent, ".git"), "utf8"),
93
+ )?.[1];
94
+
95
+ if (gitdir && path.resolve(parent, gitdir.trim()) === bare) return parent;
96
+ } catch {
97
+ // No `.git` file beside it.
98
+ }
99
+
100
+ return bare;
101
+ }
102
+
103
+ /** A folder's project: its main checkout, its bare clone, or itself. */
104
+ async function projectOf(folder: string, run: OnboardingRun) {
105
+ const git = (args: string[], cwd = folder) =>
106
+ run("git", args, cwd).then((output) => output.trim());
107
+
108
+ const common = path.resolve(
109
+ folder,
110
+ await git(["rev-parse", "--path-format=absolute", "--git-common-dir"]),
111
+ );
112
+
113
+ if (path.basename(common) === ".git")
114
+ return { root: path.dirname(common), common, bare: false };
115
+
116
+ if ((await git(["rev-parse", "--is-bare-repository"], common)) === "true")
117
+ return { root: await bareRoot(common), common, bare: true };
118
+
119
+ return {
120
+ root: await git(["rev-parse", "--show-toplevel"]),
121
+ common,
122
+ bare: false,
123
+ };
124
+ }
125
+
85
126
  /** The repository a folder belongs to, and when it last saw Git activity.
86
127
  * Worktrees resolve to their main checkout, which lists them as targets. */
87
128
  async function repositoryOf(folder: string, run: OnboardingRun) {
88
129
  try {
89
130
  if (!(await stat(folder)).isDirectory()) return undefined;
90
131
 
91
- const common = path.resolve(
92
- folder,
93
- (
94
- await run(
95
- "git",
96
- ["rev-parse", "--path-format=absolute", "--git-common-dir"],
97
- folder,
98
- )
99
- ).trim(),
100
- );
101
-
102
- // A bare repository has no checkout to draw from.
103
- if (path.basename(common) !== ".git") return undefined;
132
+ const { root, common } = await projectOf(folder, run);
104
133
 
105
134
  const activity = await Promise.all(
106
135
  ["index", "FETCH_HEAD", path.join("logs", "HEAD")].map((file) =>
@@ -108,7 +137,7 @@ async function repositoryOf(folder: string, run: OnboardingRun) {
108
137
  ),
109
138
  );
110
139
 
111
- return { root: path.dirname(common), active: Math.max(...activity) };
140
+ return { root, active: Math.max(...activity) };
112
141
  } catch {
113
142
  return undefined;
114
143
  }
@@ -151,6 +180,8 @@ export async function onboardingProjects(
151
180
  interface Worktree {
152
181
  path: string;
153
182
  branch?: string;
183
+ bare?: true;
184
+ prunable?: true;
154
185
  }
155
186
 
156
187
  /** `git worktree list --porcelain`: blocks of `worktree`, `HEAD`, `branch`. */
@@ -162,15 +193,40 @@ export function parseWorktrees(output: string): Worktree[] {
162
193
  if (line.startsWith("worktree ")) {
163
194
  current = { path: line.slice("worktree ".length) };
164
195
  worktrees.push(current);
165
- } else if (current && line.startsWith("branch "))
196
+ } else if (!current) continue;
197
+ else if (line.startsWith("branch "))
166
198
  current.branch = line
167
199
  .slice("branch ".length)
168
200
  .replace(/^refs\/heads\//, "");
201
+ else if (line === "bare") current.bare = true;
202
+ else if (line.startsWith("prunable")) current.prunable = true;
169
203
  }
170
204
 
171
205
  return worktrees;
172
206
  }
173
207
 
208
+ const drawable = (worktree: Worktree) =>
209
+ !worktree.bare &&
210
+ !worktree.prunable &&
211
+ // Whiteboard's own checkouts live under the git dir.
212
+ !worktree.path.split(/[\\/]/).includes("dev-fast") &&
213
+ // Scratch checkouts, such as an agent's throwaway worktree.
214
+ !isInside(TEMPORARY_ROOTS, worktree.path);
215
+
216
+ /** In a bare clone, a pull request is drawn from the worktree on its branch. */
217
+ async function checkoutOf(project: string, branch: string, run: OnboardingRun) {
218
+ const worktrees = parseWorktrees(
219
+ await run("git", ["worktree", "list", "--porcelain"], project).catch(
220
+ () => "",
221
+ ),
222
+ );
223
+
224
+ if (!worktrees[0]?.bare) return project;
225
+ const usable = worktrees.filter(drawable);
226
+
227
+ return (usable.find((tree) => tree.branch === branch) ?? usable[0])?.path;
228
+ }
229
+
174
230
  const pullRequestsSchema = z.array(
175
231
  z.object({
176
232
  number: z.number().int().positive(),
@@ -281,20 +337,31 @@ export async function onboardingTargets(
281
337
  run: OnboardingRun = runCommand,
282
338
  ): Promise<ReviewOnboardingTargetsResponse> {
283
339
  let root: string;
340
+ let bare = false;
284
341
 
285
342
  try {
286
343
  root = (
287
344
  await run("git", ["rev-parse", "--show-toplevel"], directory)
288
345
  ).trim();
289
346
  } catch {
290
- throw new ReviewInputError("Choose a folder inside a Git repository.", 400);
347
+ const project = await projectOf(directory, run).catch(() => undefined);
348
+
349
+ if (!project?.bare)
350
+ throw new ReviewInputError(
351
+ "Choose a folder inside a Git repository.",
352
+ 400,
353
+ );
354
+ root = project.root;
355
+ bare = true;
291
356
  }
292
357
 
293
358
  const [branch, worktreeList, base, prs, times] = await Promise.all([
294
- run("git", ["branch", "--show-current"], root).then(
295
- (output) => output.trim(),
296
- () => "",
297
- ),
359
+ bare
360
+ ? ""
361
+ : run("git", ["branch", "--show-current"], root).then(
362
+ (output) => output.trim(),
363
+ () => "",
364
+ ),
298
365
  run("git", ["worktree", "list", "--porcelain"], root).catch(() => ""),
299
366
  defaultBase(root, run),
300
367
  pullRequests(root, run),
@@ -311,10 +378,7 @@ export async function onboardingTargets(
311
378
  (worktree) =>
312
379
  worktree.branch &&
313
380
  path.resolve(worktree.path) !== path.resolve(root) &&
314
- // Whiteboard's own checkouts live under the git dir.
315
- !worktree.path.split(/[\\/]/).includes("dev-fast") &&
316
- // Scratch checkouts, such as an agent's throwaway worktree.
317
- !isInside(TEMPORARY_ROOTS, worktree.path),
381
+ drawable(worktree),
318
382
  )
319
383
  .map((worktree) =>
320
384
  local({
@@ -417,7 +481,17 @@ export function registerOnboardingRoutes(
417
481
  const { target } = input;
418
482
 
419
483
  // A pull request is drawn from the project; a branch from its checkout.
420
- const cwd = target.kind === "pr" ? input.projectPath : target.path;
484
+ const cwd =
485
+ target.kind === "pr"
486
+ ? await checkoutOf(input.projectPath, target.branch, run)
487
+ : target.path;
488
+
489
+ if (!cwd)
490
+ throw new ReviewInputError(
491
+ "This bare clone has no worktrees. Add one with git worktree add.",
492
+ 400,
493
+ );
494
+
421
495
  let head: string;
422
496
 
423
497
  try {
@@ -0,0 +1,43 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { isJsonObject, parseJsonText } from "@dev.fast/review-protocol";
5
+ import { writePrivateJsonAtomic } from "@dev.fast/trace-core";
6
+
7
+ import { devReviewHome } from "./review-home-paths";
8
+
9
+ /**
10
+ * Machine-wide Review preferences the server itself needs. Workbench settings
11
+ * do not work here: the review server never reads the workbench
12
+ * configuration.
13
+ */
14
+ function reviewPreferencesPath(devHome = devReviewHome()): string {
15
+ return path.join(devHome, "preferences.json");
16
+ }
17
+
18
+ /** Whether agents are offered animation authoring; existing animations
19
+ * always render. A missing or unreadable file means off; it never throws. */
20
+ export async function readAnimationsEnabled(
21
+ devHome?: string,
22
+ ): Promise<boolean> {
23
+ try {
24
+ const raw = parseJsonText(
25
+ await readFile(reviewPreferencesPath(devHome), "utf8"),
26
+ );
27
+
28
+ return isJsonObject(raw) && raw.animationsEnabled === true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ export async function writeAnimationsEnabled(
35
+ enabled: boolean,
36
+ devHome?: string,
37
+ ): Promise<boolean> {
38
+ await writePrivateJsonAtomic(reviewPreferencesPath(devHome), {
39
+ animationsEnabled: enabled,
40
+ });
41
+
42
+ return enabled;
43
+ }
@@ -46,6 +46,10 @@ import {
46
46
  reviewInstanceDiscoveryPath,
47
47
  reviewLegacyDiscoveryPath,
48
48
  } from "@review/review-home-paths";
49
+ import {
50
+ readAnimationsEnabled,
51
+ writeAnimationsEnabled,
52
+ } from "@review/review-preferences";
49
53
  import {
50
54
  ReviewTelemetry,
51
55
  type ReviewTelemetryContext,
@@ -173,6 +177,10 @@ export function createGlobalReviewServer(
173
177
  let closing = false;
174
178
  const cliPath = path.join(input.packageRoot, "dist", "cli.js");
175
179
 
180
+ // The animations preference, read once at listen and kept current by the
181
+ // settings endpoint below: this server is the only writer while it runs.
182
+ let animationsEnabled = false;
183
+
176
184
  const discovery: ReviewDesktopDiscovery = {
177
185
  version: REVIEW_DESKTOP_DISCOVERY_VERSION,
178
186
  instanceId,
@@ -205,6 +213,7 @@ export function createGlobalReviewServer(
205
213
  relay,
206
214
  token,
207
215
  instanceId,
216
+ animations: () => animationsEnabled,
208
217
  status: () => {
209
218
  const { key, channel, checkout, appVersion, cliVersion, instanceId } =
210
219
  discovery;
@@ -257,6 +266,20 @@ export function createGlobalReviewServer(
257
266
  app.all("/reviews-api", (context) => gateway.fetch(context.req.raw));
258
267
  app.all("/reviews-api/*", (context) => gateway.fetch(context.req.raw));
259
268
  app.get("/remote-hosts", () => serverJson(200, gateway.hosts()));
269
+ app.get("/preferences/animations", () =>
270
+ serverJson(200, { enabled: animationsEnabled }),
271
+ );
272
+ app.put("/preferences/animations", async (context) => {
273
+ const request = z
274
+ .object({ enabled: z.boolean() })
275
+ .safeParse(await readBoundedRequestJson(context.req.raw));
276
+
277
+ if (!request.success)
278
+ throw new ReviewServerError("enabled must be a boolean.", 400);
279
+ animationsEnabled = await writeAnimationsEnabled(request.data.enabled);
280
+
281
+ return serverJson(200, { enabled: animationsEnabled });
282
+ });
260
283
  app.post("/app/focus", async () => {
261
284
  const result = await relay.dispatch({
262
285
  name: "focusWindow",
@@ -499,6 +522,7 @@ export function createGlobalReviewServer(
499
522
  },
500
523
  listen: async () => {
501
524
  await migrateDiffrConfig();
525
+ animationsEnabled = await readAnimationsEnabled();
502
526
  boundPort = await listen(loopback.server, input.port);
503
527
  discovery.url = urlForBoundPort();
504
528
 
@@ -168,7 +168,7 @@ export function createJsonReviewReporting(
168
168
  files: { "review.json": JSON.stringify(snapshot) },
169
169
  omitted: [],
170
170
  }),
171
- map: async () => JSON.stringify(imported.presentation.maps),
171
+ map: async () => JSON.stringify(imported.maps),
172
172
  diff: () => jsonReviewBugReportSource(store, snapshot).diff(),
173
173
  }
174
174
  : jsonReviewBugReportSource(store, snapshot),
@@ -132,6 +132,7 @@ export interface WhiteboardCoreInput {
132
132
  token: string;
133
133
  instanceId: string;
134
134
  softwareMapEnabled?: boolean;
135
+ animations?: () => boolean;
135
136
  status: () => JsonObject;
136
137
  hooks?: ReviewApiHooks;
137
138
  ask?: {
@@ -198,6 +199,7 @@ export function createWhiteboardCore(input: WhiteboardCoreInput) {
198
199
  threads: askThreads,
199
200
  agents: async () => detectAskAgents(await ask?.env),
200
201
  },
202
+ input.animations,
201
203
  );
202
204
 
203
205
  // A shared store mounts the publisher with the rest of sharing.
@@ -42,19 +42,16 @@ export class StructuralComparisons {
42
42
  } finally {
43
43
  entry.readers--;
44
44
 
45
- if (
46
- (!entry.done && !entry.initialReady && !entry.readers) ||
47
- entry.error ||
48
- entry.failed
49
- ) {
45
+ // Summaries arrive after the files, so a run nobody reads stops. Full
46
+ // comparisons keep coverage as a reader until diffr completes.
47
+ if ((!entry.done && !entry.readers) || entry.error || entry.failed) {
50
48
  entry.abort.abort();
51
49
  this.entries.delete(key);
52
50
  }
53
- // Keep the most recently read idle comparisons, including background enrichment.
54
- // Coverage can finish before an editor subscribes; it must not cancel summaries.
55
51
 
52
+ // Keep the most recently read finished comparisons.
56
53
  const idle = [...this.entries].filter(
57
- ([, value]) => (value.done || value.initialReady) && !value.readers,
54
+ ([, value]) => value.done && !value.readers,
58
55
  );
59
56
 
60
57
  for (const [oldKey, old] of idle.slice(0, -IDLE_COMPARISONS)) {
@@ -124,7 +121,6 @@ class Comparison {
124
121
  done = false;
125
122
  error: unknown;
126
123
  failed = false;
127
- initialReady = false;
128
124
  private remaining: Set<string> | undefined;
129
125
  private readonly listeners = new Set<() => void>();
130
126
 
@@ -144,13 +140,11 @@ class Comparison {
144
140
  (entry) => (entry.file.rhs ?? entry.file.lhs)!.path,
145
141
  ),
146
142
  );
147
- this.initialReady = this.remaining.size === 0;
148
143
  } else if (event.type === "file") {
149
144
  const path = (event.file.rhs ?? event.file.lhs)!.path;
150
145
 
151
146
  if (!this.remaining?.delete(path))
152
147
  throw new Error(`Unexpected structural result: ${path}`);
153
- this.initialReady = this.remaining.size === 0;
154
148
  }
155
149
 
156
150
  if (event.type === "complete")
@@ -61,7 +61,7 @@ export async function* structuralDiff(
61
61
  args.push(`${comparison.base}...${comparison.head}`);
62
62
  }
63
63
 
64
- args.push("--", ...(input.paths ?? []));
64
+ args.push("--defer", "--", ...(input.paths ?? []));
65
65
 
66
66
  // Plugins can exceed two minutes between records. Apply this timeout only
67
67
  // until the manifest arrives; plugins enforce their own request timeouts.
@@ -101,6 +101,7 @@ export async function* structuralDiff(
101
101
  let completed = false;
102
102
  let aborted: StructuralProblem | undefined;
103
103
  let failed = 0;
104
+ const sent = new Set<string>();
104
105
  const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
105
106
 
106
107
  try {
@@ -131,7 +132,12 @@ export async function* structuralDiff(
131
132
 
132
133
  failed = event.failed;
133
134
  aborted = event.aborted;
134
- } else if (event.type !== "file") {
135
+ } else if (event.type === "file") {
136
+ sent.add((event.file.rhs ?? event.file.lhs)!.path);
137
+ } else if (
138
+ event.type !== "update" ||
139
+ !sent.has((event.file.rhs ?? event.file.lhs)!.path)
140
+ ) {
135
141
  throw new Error(`Unexpected diffr event: ${event.type}`);
136
142
  }
137
143
 
@@ -43,7 +43,7 @@ export async function runShareCli(input: {
43
43
  : z.number().int().nonnegative().parse(Number(input.version));
44
44
 
45
45
  input.stderr.write(
46
- "Sharing includes retained images, maps, and full trace conversations. Anyone with the link can download them. Reviews with code require GitHub repository access to fetch the pinned commits.\n",
46
+ "Sharing uploads the whiteboard with its maps, full trace conversations, and the code lines it references. Anyone with the link can read them; no GitHub access is needed. Opening it in Whiteboard Desktop also fetches the pinned commits from GitHub.\n",
47
47
  );
48
48
 
49
49
  const result = resultSchema.parse(