@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
@@ -4,6 +4,7 @@ import {
4
4
  type StructuralDiff,
5
5
  type StructuralRegion,
6
6
  type StructuralSource,
7
+ applyStructuralUpdate,
7
8
  structuralRows,
8
9
  } from "@dev.fast/review-protocol";
9
10
  import type { AlignmentRow } from "@review/lens-selection.js";
@@ -158,101 +159,131 @@ export async function comparisonCoverage(
158
159
  coverage.files.map((file, index) => [file.path, index]),
159
160
  );
160
161
 
161
- const remaining = new Set<string>();
162
-
163
- for await (const event of data.structuralChanges({
164
- reviewId,
165
- pins,
166
- signal,
167
- })) {
168
- if (event.type === "start") {
169
- for (const entry of event.files)
170
- remaining.add((entry.file.rhs ?? entry.file.lhs)!.path);
171
-
172
- if (!remaining.size) break;
173
- continue;
174
- }
175
-
176
- if (event.type === "complete" && (event.failed || event.aborted))
177
- throw new Error(
178
- event.aborted?.message ?? "Structural coverage is incomplete.",
179
- );
180
-
181
- if (event.type !== "file") continue;
182
-
183
- const path = (event.file.rhs ?? event.file.lhs)!.path;
184
-
185
- if (!remaining.delete(path))
186
- throw new Error(`Unexpected structural result: ${path}`);
187
-
188
- const baseIndex = event.file.lhs && baseFiles.get(event.file.lhs.path);
189
- const headIndex = event.file.rhs && headFiles.get(event.file.rhs.path);
190
-
191
- if (event.error) {
192
- if (
193
- event.error.code !== "unsupported_file_type" &&
194
- event.error.code !== "not_utf8"
195
- )
196
- throw new Error(`Cannot count ${path}: ${event.error.message}`);
197
-
198
- for (const index of [baseIndex, headIndex])
199
- if (index !== undefined) unsupported.add(coverage.files[index].path);
200
- } else if (event.diff.type === "text") {
201
- const diff = event.diff;
202
- const folded = foldedChanges(diff);
203
-
204
- // Match each side separately: diffr can split a Git rename into two files.
205
- for (const side of ["base", "head"] as const) {
206
- const source = side === "base" ? event.file.lhs : event.file.rhs;
207
-
208
- const index =
209
- source && (side === "base" ? baseFiles : headFiles).get(source.path);
210
-
211
- if (index === undefined) continue;
212
- const file = coverage.files[index];
213
-
214
- const visible = subtractIntervals(
215
- diff.structural_changes[side],
216
- folded[side],
217
- );
218
-
219
- coverage.files[index] = {
220
- ...file,
221
- folded: {
222
- ...(file.folded ?? emptyCoverage()),
223
- [side]: subtractIntervals(file.changed[side], visible),
224
- },
225
- };
226
- }
227
-
228
- const index = headIndex ?? baseIndex;
162
+ return new Promise((resolve, reject) => {
163
+ let resolved = false;
229
164
 
230
- if (index !== undefined) {
231
- const sources =
232
- coverage.fileSources.get(coverage.files[index].path) ?? [];
165
+ const counted = () => {
166
+ resolved = true;
167
+ resolve(current());
168
+ };
233
169
 
234
- if (
235
- sources.every(
236
- (source) =>
237
- source.file ===
238
- (source.side === "base" ? event.file.lhs : event.file.rhs)?.path,
170
+ // Deferred updates keep refining `folded` after the counts resolve.
171
+ void (async () => {
172
+ const remaining = new Set<string>();
173
+
174
+ const diffs = new Map<
175
+ string,
176
+ Extract<StructuralDiff, { type: "text" }>
177
+ >();
178
+
179
+ for await (const event of data.structuralChanges({
180
+ reviewId,
181
+ pins,
182
+ signal,
183
+ })) {
184
+ if (event.type === "start") {
185
+ for (const entry of event.files)
186
+ remaining.add((entry.file.rhs ?? entry.file.lhs)!.path);
187
+
188
+ if (!remaining.size) break;
189
+ continue;
190
+ }
191
+
192
+ if (event.type === "complete" && event.aborted)
193
+ throw new Error(event.aborted.message);
194
+
195
+ if (event.type !== "file" && event.type !== "update") continue;
196
+
197
+ const path = (event.file.rhs ?? event.file.lhs)!.path;
198
+ const baseIndex = event.file.lhs && baseFiles.get(event.file.lhs.path);
199
+ const headIndex = event.file.rhs && headFiles.get(event.file.rhs.path);
200
+ let diff: StructuralDiff | undefined;
201
+
202
+ if (event.type === "update") {
203
+ const previous = diffs.get(path);
204
+
205
+ if (!previous)
206
+ throw new Error(`Unexpected structural result: ${path}`);
207
+
208
+ if (event.error) continue;
209
+ diff = applyStructuralUpdate(previous, event);
210
+ } else if (!remaining.delete(path))
211
+ throw new Error(`Unexpected structural result: ${path}`);
212
+ else if (event.error) {
213
+ if (
214
+ event.error.code !== "unsupported_file_type" &&
215
+ event.error.code !== "not_utf8"
239
216
  )
240
- )
241
- coverage.alignments = new Map(coverage.alignments).set(
242
- coverage.files[index].path,
243
- structuralRows(diff),
244
- );
217
+ throw new Error(`Cannot count ${path}: ${event.error.message}`);
218
+
219
+ for (const index of [baseIndex, headIndex])
220
+ if (index !== undefined)
221
+ unsupported.add(coverage.files[index].path);
222
+ } else diff = event.diff;
223
+
224
+ if (diff?.type === "text") {
225
+ diffs.set(path, diff);
226
+ const folded = foldedChanges(diff);
227
+
228
+ // Match each side separately: diffr can split a Git rename into two files.
229
+ for (const side of ["base", "head"] as const) {
230
+ const source = side === "base" ? event.file.lhs : event.file.rhs;
231
+
232
+ const index =
233
+ source &&
234
+ (side === "base" ? baseFiles : headFiles).get(source.path);
235
+
236
+ if (index === undefined) continue;
237
+ const file = coverage.files[index];
238
+
239
+ const visible = subtractIntervals(
240
+ diff.structural_changes[side],
241
+ folded[side],
242
+ );
243
+
244
+ coverage.files[index] = {
245
+ ...file,
246
+ folded: {
247
+ ...(file.folded ?? emptyCoverage()),
248
+ [side]: subtractIntervals(file.changed[side], visible),
249
+ },
250
+ };
251
+ }
252
+
253
+ const index = headIndex ?? baseIndex;
254
+
255
+ if (event.type === "file" && index !== undefined) {
256
+ const sources =
257
+ coverage.fileSources.get(coverage.files[index].path) ?? [];
258
+
259
+ if (
260
+ sources.every(
261
+ (source) =>
262
+ source.file ===
263
+ (source.side === "base" ? event.file.lhs : event.file.rhs)
264
+ ?.path,
265
+ )
266
+ )
267
+ coverage.alignments = new Map(coverage.alignments).set(
268
+ coverage.files[index].path,
269
+ structuralRows(diff),
270
+ );
271
+ }
272
+ }
273
+
274
+ publish?.(current());
275
+
276
+ if (!remaining.size && !resolved) counted();
245
277
  }
246
- }
247
-
248
- publish?.(current());
249
278
 
250
- if (!remaining.size) break;
251
- }
252
-
253
- if (remaining.size) throw new Error("Structural coverage is incomplete.");
279
+ if (remaining.size) throw new Error("Structural coverage is incomplete.");
254
280
 
255
- return current();
281
+ if (!resolved) counted();
282
+ })().catch((error) => {
283
+ if (resolved) console.warn("Could not refine folded coverage:", error);
284
+ else reject(error);
285
+ });
286
+ });
256
287
  }
257
288
 
258
289
  /**
@@ -1,3 +1,9 @@
1
+ /** The animation component, shown only while Animations are on. */
2
+ const ANIMATION = [
3
+ '- animation {title, html, frames: {key, title, label?, caption?, duration? (ms)}[] (1 to 60), code?: {name: anchor}, height?: "auto" (default) | number (120 to 1200 CSS px), pins?}; declare every possible frame',
4
+ 'Use an animation for change over time that a static diagram cannot show: state before and after, a cache filling, data changing as a request crosses layers. It can sit in design beside the diagram, or replace it when the motion is the point. If nothing changes between frames, use a sequence or flow_diagram instead: their steps and nodes link to code. Read session_get_instructions({topic:"animation"}) before writing one.',
5
+ ];
6
+
1
7
  /** Every component's fields, for session_get_instructions: the edit tool's
2
8
  * description must stay short enough that clients don't cut it off. */
3
9
  export const COMPONENT_REFERENCE = [
@@ -26,6 +32,16 @@ export const COMPONENT_REFERENCE = [
26
32
  "- store {label, storage: relational|document, collections: {key: {label, fields: {key: field}, source?: anchor of its schema declaration}}, dataStoreKind?: database|objectStore|bucket|artifactStore|fileStore}",
27
33
  "- field {label, dataType, nullable?, primaryKey?, references?: {store, collection, field}, fields?: {key: field}}",
28
34
  "- useCase {label, summary?, operations: operation[] (1 or more)}; operation {kind: read|write, actor, store, collection, label, source: anchor, field?, detail?, pins?}, naming declared keys",
35
+ ...ANIMATION,
29
36
  "",
30
37
  'Example (replace its path and lines with verified ones): {"type":"sequence","title":"Request","actors":{"a":"Client","b":"Server"},"steps":[{"from":"a","to":"b","label":"Send","source":"head/src/app.ts#L10-L20"}]}',
31
38
  ].join("\n");
39
+
40
+ /** Keep disabled experimental components out of agent guidance. */
41
+ export function componentReference(animationsEnabled: boolean): string {
42
+ return animationsEnabled
43
+ ? COMPONENT_REFERENCE
44
+ : COMPONENT_REFERENCE.split("\n")
45
+ .filter((line) => !ANIMATION.includes(line))
46
+ .join("\n");
47
+ }
@@ -196,6 +196,17 @@ export function documentText(
196
196
  `[${edge.id}] ${edge.from} → ${edge.to}${edge.label ? `: ${edge.label}` : ""}`,
197
197
  );
198
198
  break;
199
+ case "animation":
200
+ for (const [index, frame] of element.frames.entries())
201
+ detail(
202
+ `Frame ${index + 1} [${frame.key}]: ${frame.title}${frame.caption ? ` — ${frame.caption}` : ""}`,
203
+ );
204
+
205
+ for (const [name, anchor] of Object.entries(element.code))
206
+ detail(`Code ${name}: ${anchor}`);
207
+
208
+ if (detailed) code(depth + 1, { language: "html", text: element.html });
209
+ break;
199
210
  case "software_map":
200
211
  detail(
201
212
  `Map: ${element.mapVersionId}${element.focusElementId ? `, focus: ${element.focusElementId}` : ""}`,
@@ -198,6 +198,11 @@ const unitTypes = new Set(["step", "flow_node", "flow_edge"]);
198
198
  export const isUnit = (element: Element): element is Unit =>
199
199
  unitTypes.has(element.type);
200
200
 
201
+ /** The reference id of a code peek an animation names: the canvas finds the
202
+ * peek's resolved anchor by it. */
203
+ export const animationCodeId = (blockId: string, name: string) =>
204
+ `${blockId}:code:${name}`;
205
+
201
206
  /** Rewrites old review-source: and review-trace: links to their whiteboard: form. */
202
207
  export function canonicalLink(href: string): string {
203
208
  return href.replace(
@@ -523,6 +528,16 @@ function documentReferences(
523
528
  ),
524
529
  );
525
530
 
531
+ if (element.type === "animation")
532
+ return Object.entries(element.code).flatMap(([name, anchor]) =>
533
+ select(anchor, element.pins).map((source) => ({
534
+ id: animationCodeId(element.id!, name),
535
+ source,
536
+ label: name,
537
+ peek: true,
538
+ })),
539
+ );
540
+
526
541
  if (element.type === "call_stack_diff")
527
542
  return [...element.base, ...element.head].flatMap((frame) => {
528
543
  const pins = frame.pins ?? element.pins;
@@ -774,11 +789,17 @@ const namedByType = (types: string[]) =>
774
789
 
775
790
  /** What agents are shown: content names only its type. The host parses
776
791
  * editSchema, reporting the named kind's issues. */
777
- export const publishedEditSchema = edits(
778
- namedByType([...writableBlockTypes, "step", "flow_node", "flow_edge"]),
779
- namedByType(writableBlockTypes),
780
- z.unknown(),
781
- );
792
+ export function publishedEdits(animationsEnabled = true) {
793
+ const types = writableBlockTypes.filter(
794
+ (type) => animationsEnabled || type !== "animation",
795
+ );
796
+
797
+ return edits(
798
+ namedByType([...types, "step", "flow_node", "flow_edge"]),
799
+ namedByType(types),
800
+ z.unknown(),
801
+ );
802
+ }
782
803
 
783
804
  /**
784
805
  * What one saved version did, for a canvas drawing the document as the
@@ -70,6 +70,7 @@ import {
70
70
  type ReviewTarget,
71
71
  boardLinks,
72
72
  boardRepositoryId,
73
+ elements,
73
74
  } from "./document.js";
74
75
  import { instructionsQuerySchema, renderInstructions } from "./instructions.js";
75
76
  import type { LocalReviewData } from "./local-data.js";
@@ -206,6 +207,7 @@ export function createReviewApi(
206
207
  hooks: ReviewApiHooks = {},
207
208
  /** Desktop's Ask: local agents answering questions about a selection. */
208
209
  ask?: AskHost,
210
+ animationsEnabled: () => boolean = () => false,
209
211
  ) {
210
212
  const app = new Hono();
211
213
  app.onError((error, context) => {
@@ -372,12 +374,15 @@ export function createReviewApi(
372
374
  // renderer block tool listing and the first instructions call.
373
375
  const instructionContext = async () => ({
374
376
  traceEnabled: await traceEnabled(),
377
+ animationsEnabled: animationsEnabled(),
375
378
  });
376
379
 
377
380
  app.get("/authoring", async (context) => {
378
381
  const instructions = await instructionContext();
379
382
 
380
- return context.json(authoringTools(instructions.traceEnabled));
383
+ return context.json(
384
+ authoringTools(instructions.traceEnabled, instructions.animationsEnabled),
385
+ );
381
386
  });
382
387
  app.get("/instructions", async (context) => {
383
388
  const { topic } = instructionsQuerySchema.parse(context.req.query());
@@ -508,7 +513,10 @@ export function createReviewApi(
508
513
  );
509
514
 
510
515
  app.get("/capabilities", async (context) =>
511
- context.json(await capabilities()),
516
+ context.json({
517
+ ...(await capabilities()),
518
+ animationsEnabled: animationsEnabled(),
519
+ }),
512
520
  );
513
521
 
514
522
  app.get("/:id/activity", (context) => {
@@ -2259,6 +2267,27 @@ export function createReviewApi(
2259
2267
 
2260
2268
  const input = commandSchema.parse(request);
2261
2269
 
2270
+ if (!animationsEnabled() && input.operation.type === "edit") {
2271
+ const { edit, reviewId } = input.operation;
2272
+ const incoming = "content" in edit ? elements([edit.content]) : [];
2273
+
2274
+ const target =
2275
+ "targetId" in edit
2276
+ ? elements(readReview(reviewId).document).find(
2277
+ (element) => element.id === edit.targetId,
2278
+ )
2279
+ : undefined;
2280
+
2281
+ if (
2282
+ incoming.some((element) => element.type === "animation") ||
2283
+ target?.type === "animation"
2284
+ )
2285
+ throw new ReviewInputError(
2286
+ "Animation authoring is disabled. Enable Animations in Settings under Experimental Features to edit animations. Existing animations can still be viewed.",
2287
+ 409,
2288
+ );
2289
+ }
2290
+
2262
2291
  const command = sharedCommandSchema.safeParse(input);
2263
2292
 
2264
2293
  if (
@@ -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