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

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 (58) hide show
  1. package/dist/{account-alias-2yTAxoBx.js → account-alias-BpH8VvTR.js} +2 -2
  2. package/dist/agent-cli-DFWrNoGJ.js +2 -0
  3. package/dist/{agent-cli-CHNDd4Ga.js → agent-cli-DckMzOU_.js} +6 -6
  4. package/dist/{agent-client-Dc0vHFJt.js → agent-client-D4FSEDX4.js} +3 -3
  5. package/dist/{authoring-tools-D8EkIDjm.js → authoring-tools-DmIJZq0b.js} +21 -8
  6. package/dist/{background-server-DHWHzpo5.js → background-server-B1uwDQTr.js} +2 -2
  7. package/dist/{background-server-DyWnPJyZ.js → background-server-BPZ3GQan.js} +1 -1
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-runner-DFBaVGij.js → cli-runner-DVEDbjXo.js} +17 -17
  10. package/dist/{cli-qZomwJmK.js → cli-tWBQpq27.js} +1 -1
  11. package/dist/cli.js +2 -2
  12. package/dist/{client-p9WB8Sj0.js → client-C8vT0mhW.js} +3 -3
  13. package/dist/{fuzzy-match-CfSxqzBF.js → fuzzy-match-BNLXmpbv.js} +2 -2
  14. package/dist/{headless-host-dwlqhqMY.js → headless-host-B63e2z2j.js} +1 -1
  15. package/dist/{headless-host-DJhcab6r.js → headless-host-Dl9g9Fvd.js} +37 -17
  16. package/dist/{lens-selection-9byzEgvU.js → lens-selection-EuZdag4Q.js} +1 -1
  17. package/dist/{local-data-DHedfhxw.js → local-data-C0OJttzT.js} +92 -63
  18. package/dist/{mcp-Br-r_wwR.js → mcp-D74cFQNM.js} +17 -8
  19. package/dist/profile-CAn8Qdeh.js +2 -0
  20. package/dist/{profile-D9wdlPgw.js → profile-DS1F6PBF.js} +1 -1
  21. package/dist/{remote-attach-DSGpTOJ4.js → remote-attach-DTByuwBs.js} +3 -3
  22. package/dist/{remote-language-server-Dx13Mg2m.js → remote-language-server-DQfg07BB.js} +1 -1
  23. package/dist/{remote-uninstall-Dp2o3qz0.js → remote-uninstall-aXApHJPy.js} +3 -3
  24. package/dist/{request-origin-CNSLVn7x.js → request-origin-BIBW432J.js} +12 -3
  25. package/dist/{review-app-launcher-BZAnR8Vp.js → review-app-launcher-Brn60fPu.js} +2 -2
  26. package/dist/{review-telemetry-BM-_KdLi.js → review-telemetry-ChlsTRPN.js} +2 -2
  27. package/dist/server/desktop-host.js +44 -10
  28. package/dist/{server-discovery-DKLIOgg3.js → server-discovery-BchUrwFW.js} +1 -1
  29. package/dist/sharing/index.d.ts +53 -3
  30. package/dist/sharing/index.js +1 -1
  31. package/dist/{src-DYHVzX3b.js → src-DEOc8bvC.js} +63 -2
  32. package/dist/{stored-document-migration-DEt8iwem.js → stored-document-migration-BCFx07Bq.js} +52 -11
  33. package/dist/{thread-state-CO97FUqK.js → thread-state-CbLB0jUA.js} +1 -1
  34. package/dist/{tool-failure-C1L1vJyz.js → tool-failure-DlU9hn7_.js} +2 -2
  35. package/instructions/animation.md +345 -0
  36. package/instructions/authoring.md +2 -2
  37. package/package.json +7 -7
  38. package/src/fixtures/blocks/animation.json +19 -0
  39. package/src/review-api/authoring-tools.ts +17 -6
  40. package/src/review-api/blocks/animation.ts +98 -0
  41. package/src/review-api/blocks/index.ts +3 -0
  42. package/src/review-api/blocks/sequence.ts +3 -1
  43. package/src/review-api/comparison-coverage.ts +120 -89
  44. package/src/review-api/component-reference.ts +16 -0
  45. package/src/review-api/document-text.ts +11 -0
  46. package/src/review-api/document.ts +26 -5
  47. package/src/review-api/http.ts +31 -2
  48. package/src/review-api/instructions.ts +11 -2
  49. package/src/review-api/local-data.ts +21 -4
  50. package/src/review-api/mcp.ts +22 -14
  51. package/src/review-preferences.ts +43 -0
  52. package/src/server/desktop-server.ts +24 -0
  53. package/src/server/review-server-core.ts +2 -0
  54. package/src/server/structural-comparisons.ts +5 -11
  55. package/src/server/structural-diff.ts +8 -2
  56. package/src/ui-telemetry-events.ts +9 -0
  57. package/dist/agent-cli-Cn4-16XA.js +0 -2
  58. package/dist/profile-BopGMZUh.js +0 -2
@@ -4,10 +4,12 @@ import path from "node:path";
4
4
  import { findReviewPackageRoot } from "@review/package-paths.js";
5
5
  import { z } from "zod";
6
6
 
7
- import { COMPONENT_REFERENCE } from "./component-reference.js";
7
+ import { componentReference } from "./component-reference.js";
8
+ import { ReviewInputError } from "./input-error.js";
8
9
 
9
10
  export const INSTRUCTION_TOPICS = [
10
11
  "authoring",
12
+ "animation",
11
13
  "file-lenses",
12
14
  "trace-archaeology",
13
15
  ] as const;
@@ -20,6 +22,7 @@ export const instructionsQuerySchema = z.strictObject({
20
22
 
21
23
  export interface InstructionContext {
22
24
  traceEnabled: boolean;
25
+ animationsEnabled?: boolean;
23
26
  }
24
27
 
25
28
  const cache = new Map<string, Promise<string>>();
@@ -44,6 +47,12 @@ export async function renderInstructions(
44
47
  context: InstructionContext,
45
48
  root = findReviewPackageRoot(import.meta.url),
46
49
  ): Promise<string> {
50
+ if (topic === "animation" && !context.animationsEnabled)
51
+ throw new ReviewInputError(
52
+ "Animation authoring is disabled. Enable Animations in Settings under Experimental Features.",
53
+ 409,
54
+ );
55
+
47
56
  if (topic === "trace-archaeology" && !context.traceEnabled)
48
57
  return "Trace capture is off on this machine, so no agent traces are available. It can be turned on in Whiteboard Desktop Settings under Experimental Features.";
49
58
 
@@ -59,7 +68,7 @@ export async function renderInstructions(
59
68
 
60
69
  return [
61
70
  await read(root, "authoring"),
62
- COMPONENT_REFERENCE,
71
+ componentReference(context.animationsEnabled === true),
63
72
  ...(context.traceEnabled
64
73
  ? [
65
74
  '## Traces\n\nAt the end, run `whiteboard trace list --session <sessionId> --json`, which covers every commit in the review. If it lists no sessions, there are no traces to quote: skip this step. Otherwise read `session_get_instructions({topic:"trace-archaeology"})` and rewrite as much as possible of the what/why, design, and requirements sections in terms of literal trace quotes from the user.',
@@ -1817,6 +1817,8 @@ export class LocalReviewData {
1817
1817
  state: "pending" | "ready" | "error";
1818
1818
  error?: unknown;
1819
1819
  partial?: ComparisonCoverage;
1820
+ abort: AbortController;
1821
+ worktree?: string;
1820
1822
  }
1821
1823
  >();
1822
1824
  private readonly coverageAbort = new AbortController();
@@ -1877,11 +1879,24 @@ export class LocalReviewData {
1877
1879
  let entry = this.coverageCache.get(key);
1878
1880
 
1879
1881
  if (!entry || entry.state === "error") {
1882
+ const worktree = pins.worktreeRevision
1883
+ ? JSON.stringify({ ...pins, worktreeRevision: undefined })
1884
+ : undefined;
1885
+
1886
+ // A newer worktree revision cancels older ones.
1887
+ for (const [cachedKey, cached] of this.coverageCache)
1888
+ if (worktree && cached.worktree === worktree) {
1889
+ cached.abort.abort();
1890
+ this.coverageCache.delete(cachedKey);
1891
+ }
1892
+
1893
+ const abort = new AbortController();
1894
+
1880
1895
  const promise = comparisonCoverage(
1881
1896
  this,
1882
1897
  reviewId,
1883
1898
  pins,
1884
- this.coverageAbort.signal,
1899
+ AbortSignal.any([this.coverageAbort.signal, abort.signal]),
1885
1900
  (partial) => {
1886
1901
  const current = this.coverageCache.get(key);
1887
1902
 
@@ -1890,7 +1905,7 @@ export class LocalReviewData {
1890
1905
  },
1891
1906
  );
1892
1907
 
1893
- entry = { promise, state: "pending" };
1908
+ entry = { promise, state: "pending", abort, worktree };
1894
1909
  this.coverageCache.set(key, entry);
1895
1910
  const current = entry;
1896
1911
 
@@ -1910,7 +1925,7 @@ export class LocalReviewData {
1910
1925
 
1911
1926
  void promise.then(
1912
1927
  (value) => {
1913
- current.partial = value;
1928
+ current.partial ??= value;
1914
1929
 
1915
1930
  if (!this.closed)
1916
1931
  this.store.setDiffStats(pins, {
@@ -1931,7 +1946,9 @@ export class LocalReviewData {
1931
1946
  );
1932
1947
  }
1933
1948
 
1934
- return entry.promise;
1949
+ return entry.state === "ready" && entry.partial
1950
+ ? Promise.resolve(entry.partial)
1951
+ : entry.promise;
1935
1952
  }
1936
1953
 
1937
1954
  changes(pins: Pins): Promise<LocalVcsDiffFileSummary[]>;
@@ -1,10 +1,11 @@
1
1
  import type { Readable, Writable } from "node:stream";
2
2
 
3
- import type { JsonValue } from "@dev.fast/json";
3
+ import { type JsonValue, isJsonObject } from "@dev.fast/json";
4
4
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
6
  import {
7
7
  CallToolRequestSchema,
8
+ type CallToolResult,
8
9
  ListToolsRequestSchema,
9
10
  } from "@modelcontextprotocol/sdk/types.js";
10
11
  import type { ReviewToolCall } from "@review/review-telemetry.js";
@@ -13,6 +14,7 @@ import type { ReviewSessionAgent } from "@review/ui-telemetry-events.js";
13
14
  import {
14
15
  type AuthoringTool,
15
16
  type ConnectedReview,
17
+ ToolText,
16
18
  toolResultText,
17
19
  } from "./agent-client.js";
18
20
  import { authoringTools } from "./authoring-tools.js";
@@ -37,6 +39,7 @@ function mcpAuthoringGuidance(context: { traceEnabled: boolean }): string {
37
39
  'For why code exists, what an agent was thinking, or whether an agent solved something before, call session_get_instructions({topic:"trace-archaeology"}).',
38
40
  ]
39
41
  : []),
42
+ "For programmatic MCP calls, use structuredContent when present; otherwise parse JSON from the text content when the result is JSON. An insert through session_edit uses edit.content for the actual document component, never the MCP transport wrapper containing content or structuredContent.",
40
43
  "Never read or write Whiteboard files or SQL.",
41
44
  ].join(" ");
42
45
  }
@@ -168,20 +171,20 @@ export async function serveReviewMcp(
168
171
  )
169
172
  return { content: [{ type: "text" as const, text: RECOVERY }] };
170
173
 
171
- if (request.params.name === REVIEW_STATUS_TOOL.name && offlineStatus)
172
- return {
173
- content: [
174
- {
175
- type: "text" as const,
176
- text: JSON.stringify(
177
- await offlineStatus(
178
- error instanceof Error ? error.message : String(error),
179
- ),
180
- ),
181
- },
182
- ],
174
+ if (request.params.name === REVIEW_STATUS_TOOL.name && offlineStatus) {
175
+ const result = await offlineStatus(
176
+ error instanceof Error ? error.message : String(error),
177
+ );
178
+
179
+ const response: CallToolResult = {
180
+ content: [{ type: "text", text: JSON.stringify(result) }],
183
181
  };
184
182
 
183
+ if (isJsonObject(result)) response.structuredContent = result;
184
+
185
+ return response;
186
+ }
187
+
185
188
  throw error;
186
189
  }
187
190
 
@@ -203,7 +206,7 @@ export async function serveReviewMcp(
203
206
  const text = toolResultText(tool, result);
204
207
  report();
205
208
 
206
- return {
209
+ const response: CallToolResult = {
207
210
  content: [
208
211
  {
209
212
  type: "text",
@@ -214,6 +217,11 @@ export async function serveReviewMcp(
214
217
  },
215
218
  ],
216
219
  };
220
+
221
+ if (!(result instanceof ToolText) && isJsonObject(result))
222
+ response.structuredContent = result;
223
+
224
+ return response;
217
225
  } catch (error) {
218
226
  report(toolFailure(error, phase));
219
227
 
@@ -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
 
@@ -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
 
@@ -621,6 +621,15 @@ export const UI_TELEMETRY_EVENTS = {
621
621
  event: "review_diff_failed",
622
622
  properties: { reason: DIFF_FAILED_REASON, files: "number" },
623
623
  },
624
+ diff_loaded: {
625
+ event: "review_diff_loaded",
626
+ properties: {
627
+ first_file_ms: "number",
628
+ updates_ms: "number",
629
+ files: "number",
630
+ update_errors: "number",
631
+ },
632
+ },
624
633
  discord_clicked: {
625
634
  event: "review_discord_clicked",
626
635
  properties: { via: DISCORD_VIA },
@@ -1,2 +0,0 @@
1
- import { n as runReviewAgentCli } from "./agent-cli-CHNDd4Ga.js";
2
- export { runReviewAgentCli };
@@ -1,2 +0,0 @@
1
- import { t as openReviewProfile } from "./profile-D9wdlPgw.js";
2
- export { openReviewProfile };