@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev.fast/whiteboard",
3
- "version": "0.4.0",
3
+ "version": "0.4.1-preview.20261026.137",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -60,8 +60,8 @@
60
60
  "smol-toml": "1.9.0",
61
61
  "undici": "7.29.1",
62
62
  "yauzl": "3.4.0",
63
- "zod": "4.6.5",
64
- "@dev.fast/diffr": "0.1.20"
63
+ "zod": "4.6.6",
64
+ "@dev.fast/diffr": "0.1.21"
65
65
  },
66
66
  "devDependencies": {
67
67
  "@dev.fast/local-vcs": "^0.1.0",
@@ -74,11 +74,11 @@
74
74
  "tsx": "4.21.0",
75
75
  "typescript": "6.0.3",
76
76
  "vitest": "4.1.11",
77
- "@dev.fast/json": "0.0.0",
78
77
  "@dev.fast/review-protocol": "0.0.0",
78
+ "@dev.fast/review-share-protocol": "0.1.2",
79
79
  "@dev.fast/trace-core": "0.0.0",
80
- "@dev.fast/trace-protocol": "0.5.0",
81
- "@dev.fast/review-share-protocol": "0.1.2"
80
+ "@dev.fast/json": "0.0.0",
81
+ "@dev.fast/trace-protocol": "0.5.0"
82
82
  },
83
83
  "engines": {
84
84
  "node": ">=24 <25"
@@ -88,7 +88,7 @@
88
88
  "repo": "devdotfast/review",
89
89
  "docs": "packages/review/instructions/authoring.md"
90
90
  },
91
- "gitHead": "90564c926cee5f245d498cb56585759cca9b312b",
91
+ "gitHead": "46629c2739b7efbdb10cd7a98cfacb315e8d7ae0",
92
92
  "scripts": {
93
93
  "prebuild": "pnpm run build:workspace-deps",
94
94
  "build": "node --max-old-space-size=8192 node_modules/tsdown/dist/run.mjs",
@@ -0,0 +1,19 @@
1
+ [
2
+ {
3
+ "id": "block-1",
4
+ "type": "animation",
5
+ "title": "Order status",
6
+ "height": 300,
7
+ "frames": [
8
+ { "key": "before", "title": "An order arrives", "label": "ARRIVE" },
9
+ {
10
+ "key": "change",
11
+ "title": "Its status is queued",
12
+ "label": "QUEUE",
13
+ "caption": "The status line sets it."
14
+ }
15
+ ],
16
+ "code": { "status": "head/order.ts#L1" },
17
+ "html": "<script>const s = whiteboard.scene({ columns: 2 });\nconst client = s.actor({ id: \"client\", at: [1, 1], title: \"Client\", note: \"createOrder()\" });\nconst queue = s.store({ id: \"queue\", at: [2, 1], title: \"Queue\" });\ns.link(client, queue, { label: \"enqueue\", enter: \"change\" });\ns.send(client, queue, \"order\", \"change\");\ns.on(\"change\", queue, { note: \"1 pending\", tone: \"added\" });\nqueue.item(\"order\", { enter: \"change\", tone: \"added\" });\ns.peek(\"status\", { at: [1, 2, 1, 2], enter: \"change\" });</script>"
18
+ }
19
+ ]
@@ -6,7 +6,7 @@ import {
6
6
  activityEndSchema,
7
7
  activityUpdateSchema,
8
8
  } from "./activity.js";
9
- import { pathTargetSchema, publishedEditSchema } from "./document.js";
9
+ import { pathTargetSchema, publishedEdits } from "./document.js";
10
10
  import { instructionsQuerySchema } from "./instructions.js";
11
11
  import { uploadSchema } from "./local-data.js";
12
12
  import { inspectQuerySchema } from "./read-schemas.js";
@@ -14,9 +14,12 @@ import { REVIEW_STATUS_TOOL } from "./status-tool.js";
14
14
  import { commandSchema } from "./store.js";
15
15
 
16
16
  /** The host publishes its input schemas, except where agents write less than
17
- * the host accepts (publishedEditSchema, publishedLensEditSchema) or name a
17
+ * the host accepts (publishedEdits, publishedLensEditSchema) or name a
18
18
  * checkout by path (pathTargetSchema); adapters validate nothing. */
19
- export function authoringTools(traceEnabled = false) {
19
+ export function authoringTools(
20
+ traceEnabled = false,
21
+ animationsEnabled = false,
22
+ ) {
20
23
  const id = z.string().min(1);
21
24
  const review = { reviewId: id };
22
25
 
@@ -38,7 +41,7 @@ export function authoringTools(traceEnabled = false) {
38
41
  create:
39
42
  'Create a whiteboard. target names its repository by the checkout\'s path, and what it shows: {kind:"project"} for a plan in that repository\'s project with no change; {kind:"worktree", base?} for the saved files in the checkout, uncommitted ones included and untracked ones left out (git add -N a new file to include it), against base, by default the repository\'s default branch, from the merge base of base and HEAD, which follows rebases; {kind:"commits", head, base?} for immutable commits (omitted base means source at head with no diff; supply the parent to review introduced changes); {kind:"pullRequest", pullRequest} for a GitHub PR by URL or number, fetched into that checkout and pinned at the PR head and GitHub diff base. For a branch stacked on another, pass the branch below it as base. Group plan implementations or a PR stack under one project-target overview with parentId, and order its children with dependsOn; Git bases do not set these relationships. Revisions are resolved on acceptance. With a PR and no title the PR\'s title is used. When the existing review for that PR is in Trash, creation reports its ID; restore it with review_lifecycle state:"active" before continuing. When an active review for that PR exists, it is returned instead, reporting whether its head moved and whether an agent is working on it; update it in place, move its target with review_set_target, or create a separate review with reuseExisting:false. The result carries review, as review_list shows it, with its target and repositoryPath, so no follow-up read is needed before diffing. When Desktop is available the review opens there and the result reports opened, softwareMapEnabled and environmentIssues, as review_open does; set open:false to author in the background without taking over Desktop. parentId and dependsOn set its place among the project\'s reviews, as review_set_parent and review_set_dependencies do; projectId files it in another project, by project.id from review_list.',
40
43
  set_target:
41
- "Move the review to a new target (the same targets review_create takes), such as the commits after a rebase or amend, or commits or a PR for a board that had none. The document and component IDs are kept. Revisions are resolved on acceptance. Returns warnings for retained source ranges in files the new commits changed, to verify, and resources that no longer match; fix them with review_edit. Earlier versions keep their source. A PR target names the PR; other targets keep it within the same repository and clear it across repositories.",
44
+ "Move the review to a new target (the same targets review_create takes), such as the commits after a rebase or amend, or commits or a PR for a board that had none. The document and component IDs are kept. Revisions are resolved on acceptance. Returns warnings for retained source ranges in files the new commits changed, to verify, and resources that no longer match; fix them with review_edit. Earlier versions keep their source. A PR target names the PR; other targets keep it within the same repository and clear it across repositories. To refresh a PR review, pass its PR target again: a commits target with its branch names pins their tips, not the PR's diff base.",
42
45
  edit: [
43
46
  "Edit one document component. content is one component, named by its type; each type's fields are in the Components section of session_get_instructions.",
44
47
  "Anchors are head/path#L10-L20 or base/path#L7, or diff/path#L84-R90 for a range across sides, with repository-relative paths and 1-based inclusive lines. pins {head, base?} say which commits of the whiteboard's repository anchors quote: a step, frame, attachment or operation without pins uses its block's, and a block without them the review's.",
@@ -97,7 +100,15 @@ export function authoringTools(traceEnabled = false) {
97
100
  (traceEnabled
98
101
  ? ' Call review_get_instructions({topic:"trace-archaeology"}) for why code exists, what an agent was thinking, or whether an agent solved this before.'
99
102
  : ""),
100
- instructionsQuerySchema.partial(),
103
+ instructionsQuerySchema
104
+ .extend({
105
+ topic: animationsEnabled
106
+ ? instructionsQuerySchema.shape.topic
107
+ : z
108
+ .enum(["authoring", "file-lenses", "trace-archaeology"])
109
+ .default("authoring"),
110
+ })
111
+ .partial(),
101
112
  "GET",
102
113
  "/instructions",
103
114
  ),
@@ -144,7 +155,7 @@ export function authoringTools(traceEnabled = false) {
144
155
  open: z.boolean().optional(),
145
156
  }),
146
157
  ...(type === "set_target" && { target: pathTargetSchema }),
147
- ...(type === "edit" && { edit: publishedEditSchema }),
158
+ ...(type === "edit" && { edit: publishedEdits(animationsEnabled) }),
148
159
  }),
149
160
  "POST",
150
161
  "/commands",
@@ -0,0 +1,98 @@
1
+ import { anchorSchema } from "@review/lens-selection.js";
2
+ import { ReviewInputError } from "@review/review-api/input-error.js";
3
+ import { z } from "zod";
4
+
5
+ import {
6
+ type BlockDefinition,
7
+ defineBlock,
8
+ elementPins,
9
+ label,
10
+ text,
11
+ } from "./definition.js";
12
+
13
+ /** A name the stage's script passes to the `whiteboard` shim. */
14
+ const handle = z
15
+ .string()
16
+ .regex(
17
+ /^[A-Za-z][\w-]{0,63}$/,
18
+ "Use a short name: a letter, then letters, digits, _ or -.",
19
+ );
20
+
21
+ export const animationFrameSchema = z.strictObject({
22
+ key: handle.describe(
23
+ "Names the frame for the stage: data-frame on <html>, data-enter/data-exit, and whiteboard.on(key, ...).",
24
+ ),
25
+ title: label,
26
+ label: z
27
+ .string()
28
+ .min(1)
29
+ .max(24)
30
+ .optional()
31
+ .describe(
32
+ "A short name for the step strip under the stage, e.g. PUSH. Give every frame one, or none.",
33
+ ),
34
+ caption: text
35
+ .optional()
36
+ .describe("One or two short sentences shown under the stage."),
37
+ duration: z
38
+ .number()
39
+ .int()
40
+ .min(300)
41
+ .max(30_000)
42
+ .optional()
43
+ .describe("Milliseconds this frame stays up while playing. Default 3000."),
44
+ });
45
+
46
+ export type AnimationFrame = z.infer<typeof animationFrameSchema>;
47
+
48
+ export const animationSchema = defineBlock("animation", {
49
+ title: label,
50
+ html: text
51
+ .min(1)
52
+ .max(200_000)
53
+ .describe(
54
+ 'The stage\'s body markup: elements, <style> and <script>. It runs in a sandboxed frame with a global `whiteboard` shim; see session_get_instructions({topic:"animation"}).',
55
+ ),
56
+ frames: z
57
+ .array(animationFrameSchema)
58
+ .min(1)
59
+ .max(60)
60
+ .describe(
61
+ "Every possible frame. Stage scripts may select an ordered branch with whiteboard.setSequence(keys); keys stay unique and stable.",
62
+ ),
63
+ code: z
64
+ .record(handle, anchorSchema)
65
+ .default({})
66
+ .describe(
67
+ "Source ranges the stage shows as code peeks, by name: whiteboard.code(name).",
68
+ ),
69
+ height: z
70
+ .union([z.literal("auto"), z.number().int().min(120).max(1200)])
71
+ .default("auto")
72
+ .describe(
73
+ "Fit the stage to its content by default, or set a fixed height in CSS pixels.",
74
+ ),
75
+ pins: elementPins,
76
+ });
77
+
78
+ export type AnimationBlock = z.infer<typeof animationSchema>;
79
+
80
+ export const animation = {
81
+ type: "animation",
82
+ schema: animationSchema,
83
+ check(block: AnimationBlock) {
84
+ const keys = new Set(block.frames.map((frame) => frame.key));
85
+
86
+ if (keys.size !== block.frames.length)
87
+ throw new ReviewInputError(
88
+ "Animation frame keys must be unique within the animation.",
89
+ );
90
+
91
+ const labelled = block.frames.filter((frame) => frame.label).length;
92
+
93
+ if (labelled && labelled !== block.frames.length)
94
+ throw new ReviewInputError(
95
+ "Give every animation frame a label, or none: the step strip needs one per frame.",
96
+ );
97
+ },
98
+ } satisfies BlockDefinition<AnimationBlock>;
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
 
3
+ import { animation } from "./animation.js";
3
4
  import { call_stack_diff } from "./call_stack_diff.js";
4
5
  import { type CalloutBlock, callout, calloutSchema } from "./callout.js";
5
6
  import { code } from "./code.js";
@@ -28,6 +29,7 @@ export const leafSchema = z.discriminatedUnion("type", [
28
29
  trace_quote.schema,
29
30
  software_map.schema,
30
31
  flow_diagram.schema,
32
+ animation.schema,
31
33
  ]);
32
34
 
33
35
  export type LeafBlock = z.infer<typeof leafSchema>;
@@ -53,6 +55,7 @@ export const blocks = {
53
55
  trace_quote,
54
56
  software_map,
55
57
  flow_diagram,
58
+ animation,
56
59
  section,
57
60
  callout,
58
61
  } satisfies Definitions;
@@ -32,7 +32,9 @@ export const stepSchema = z
32
32
  /** The call site this arrow stands for, then any ranges directly about
33
33
  * that call, each shown as its own card. */
34
34
  source: z
35
- .union([anchorSchema, z.array(anchorSchema).min(1).max(4)])
35
+ .union([anchorSchema, z.array(anchorSchema).min(1).max(4)], {
36
+ error: "source must be one range or a list of 1 to 4 ranges",
37
+ })
36
38
  .optional(),
37
39
  pins: elementPins,
38
40
  explanation: label.optional(),
@@ -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 (