@dev.fast/whiteboard 0.3.4 → 0.3.5-preview.20261021.131

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 (120) hide show
  1. package/dist/{account-alias-CprZojXd.js → account-alias-CisiYUZT.js} +13 -8
  2. package/dist/agent-cli-BglNRDZN.js +2 -0
  3. package/dist/{agent-cli-CThN7Q59.js → agent-cli-TJ7_5_B3.js} +15 -10
  4. package/dist/{agent-client-BOOi7EUR.js → agent-client-H53ajvrm.js} +13 -3
  5. package/dist/{authoring-tools-BNwuMnz6.js → authoring-tools-BCquUsB3.js} +34 -27
  6. package/dist/{background-server-DLSDogkI.js → background-server-BSYXD9au.js} +1 -1
  7. package/dist/{background-server-CkvxA-6O.js → background-server-Xom8cA_u.js} +5 -5
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-R4yfa5DT.js → cli-BOnV4cCh.js} +1 -1
  10. package/dist/{cli-runner-Cuc9FSzu.js → cli-runner-cRY5NOno.js} +70 -34
  11. package/dist/cli.js +4 -3
  12. package/dist/{client-BN_FDxFr.js → client-UH1bEG7Z.js} +260 -49
  13. package/dist/{fuzzy-match-BO9bK37N.js → fuzzy-match-D_x3wumC.js} +6 -4
  14. package/dist/{headless-host-CVBOQyRS.js → headless-host-BEe-aFtB.js} +1 -1
  15. package/dist/{headless-host-Cog6SpS9.js → headless-host-CEb5d-A0.js} +563 -175
  16. package/dist/lens-selection-BxPk53c8.js +214 -0
  17. package/dist/{local-data-D_9tvdsp.js → local-data-mbCb1HDH.js} +2448 -347
  18. package/dist/{mcp-D1hziANa.js → mcp-cBtzXsmN.js} +9 -12
  19. package/dist/{package-paths-C--__jEv.js → package-paths-BkeQsdaw.js} +1 -1
  20. package/dist/profile-Dbyidrcr.js +2 -0
  21. package/dist/{profile-DokFw782.js → profile-duqp5uPa.js} +20 -8
  22. package/dist/{remote-attach-CEJ-kvDY.js → remote-attach-BeKuMm_x.js} +4 -4
  23. package/dist/{remote-extensions-GN8Gf_qG.js → remote-extensions-CX2fAec1.js} +1 -1
  24. package/dist/{remote-extensions-Blcjr9Ze.js → remote-extensions-CYWNTVSf.js} +3 -3
  25. package/dist/{remote-language-server-BTKBQ7x4.js → remote-language-server-DLWluAeR.js} +5 -5
  26. package/dist/{remote-uninstall-DW7Gdryh.js → remote-uninstall-Y3S0YmJs.js} +6 -6
  27. package/dist/{request-origin-Zcn_CjX4.js → request-origin-icXPv6jh.js} +55 -18
  28. package/dist/{review-app-launcher-CkwLclzR.js → review-app-launcher-DZvG5ZAh.js} +2 -2
  29. package/dist/{review-home-paths-j2BRAaNv.js → review-home-paths-BwH08ihj.js} +19 -17
  30. package/dist/{review-telemetry-BLNwYnLV.js → review-telemetry-AZ0imsmJ.js} +6 -6
  31. package/dist/runtime-CF4JXdb8.js +27 -0
  32. package/dist/runtime.d.ts +1 -1
  33. package/dist/runtime.js +1 -1
  34. package/dist/{s3-DtJFWC0G.js → s3-CsG2EWED.js} +1 -1
  35. package/dist/{s3-config-BTO12tCC.js → s3-config-CjcLyFV5.js} +4 -2
  36. package/dist/{s3-config-CSy3fx8H.js → s3-config-zz4GWh2T.js} +1 -1
  37. package/dist/server/desktop-host.js +28 -104
  38. package/dist/{server-discovery-Bey8IqXH.js → server-discovery-DeVTdcqK.js} +3 -3
  39. package/dist/sharing/index.d.ts +484 -74
  40. package/dist/sharing/index.js +1 -1
  41. package/dist/{src-DsM3jO8G.js → src-DYZLCUP_.js} +98 -18
  42. package/dist/{src-BMPuxWv4.js → src-DtfSndqS.js} +269 -227
  43. package/dist/{stored-document-migration-CChxtFAy.js → stored-document-migration-B29lve9-.js} +318 -271
  44. package/dist/{thread-state-5pYr6_zM.js → thread-state-BPpvKFwQ.js} +12 -2
  45. package/dist/{tool-failure-C9MpbXJJ.js → tool-failure-CQhyLht3.js} +10 -3
  46. package/instructions/authoring.md +20 -5
  47. package/package.json +4 -4
  48. package/src/agent-selection.ts +6 -0
  49. package/src/ask/agents.ts +13 -1
  50. package/src/ask/checkout-files.ts +2 -5
  51. package/src/ask/thread-state.ts +4 -0
  52. package/src/ask/thread.ts +61 -31
  53. package/src/ask/threads.ts +24 -3
  54. package/src/cli-install.ts +13 -2
  55. package/src/cli-runner.ts +4 -1
  56. package/src/cli.ts +4 -1
  57. package/src/diff-selection-migration.ts +48 -34
  58. package/src/heading-anchors.ts +42 -0
  59. package/src/lens-selection.ts +38 -2
  60. package/src/markdown.ts +42 -0
  61. package/src/posthog-capture-client.ts +1 -1
  62. package/src/review-api/README.md +52 -13
  63. package/src/review-api/activity.ts +1 -4
  64. package/src/review-api/agent-cli.ts +9 -1
  65. package/src/review-api/anchor-quotes.ts +14 -7
  66. package/src/review-api/ask-history.ts +118 -51
  67. package/src/review-api/authoring-tools.ts +45 -30
  68. package/src/review-api/azure-devops-provider.ts +2 -20
  69. package/src/review-api/azure-remote.ts +21 -0
  70. package/src/review-api/blocks/definition.ts +1 -1
  71. package/src/review-api/blocks/markdown.ts +2 -2
  72. package/src/review-api/board-order.ts +144 -0
  73. package/src/review-api/checkout-groups.ts +586 -0
  74. package/src/review-api/document-headings.ts +58 -27
  75. package/src/review-api/document-text.ts +31 -2
  76. package/src/review-api/document.ts +362 -67
  77. package/src/review-api/foreign-references.ts +328 -0
  78. package/src/review-api/github-provider.ts +51 -13
  79. package/src/review-api/http.ts +734 -135
  80. package/src/review-api/instructions.ts +0 -19
  81. package/src/review-api/link-preview.ts +113 -0
  82. package/src/review-api/local-data.ts +310 -46
  83. package/src/review-api/mcp.ts +7 -13
  84. package/src/review-api/onboarding.ts +1 -1
  85. package/src/review-api/profile.ts +37 -6
  86. package/src/review-api/public-tools.ts +24 -2
  87. package/src/review-api/pull-request.ts +82 -49
  88. package/src/review-api/request-origin.ts +10 -0
  89. package/src/review-api/review-progress.ts +61 -27
  90. package/src/review-api/scratchpad-plan-upgrade.ts +250 -0
  91. package/src/review-api/store-migrations.ts +355 -0
  92. package/src/review-api/store-schema.ts +38 -29
  93. package/src/review-api/store.ts +2176 -495
  94. package/src/review-api/worktree-events.ts +3 -5
  95. package/src/review-api/worktree-source.ts +3 -7
  96. package/src/review-api/worktree-structural.integration.ts +72 -0
  97. package/src/review-stack.ts +15 -23
  98. package/src/runtime.ts +4 -30
  99. package/src/server/desktop-host.ts +6 -1
  100. package/src/server/desktop-server.ts +1 -36
  101. package/src/server/headless-host.ts +0 -2
  102. package/src/server/hono-http.ts +2 -1
  103. package/src/server/review-gateway-list.ts +3 -5
  104. package/src/server/review-gateway-memory.ts +1 -1
  105. package/src/server/review-lifecycle-telemetry.ts +2 -2
  106. package/src/server/review-server-core.ts +1 -5
  107. package/src/server/structural-comparisons.ts +47 -0
  108. package/src/sharing/export.ts +36 -7
  109. package/src/sharing/host.ts +34 -14
  110. package/src/sharing/import.ts +156 -24
  111. package/src/sharing/repository.ts +143 -24
  112. package/src/startup-trace.ts +3 -3
  113. package/src/stored-document-migration.ts +110 -19
  114. package/src/ui-telemetry-events.ts +44 -18
  115. package/dist/agent-cli-C6SPSII0.js +0 -2
  116. package/dist/profile-CYPmvBoA.js +0 -2
  117. package/dist/runtime-CKiWhGrf.js +0 -52
  118. package/instructions/scratchpad.md +0 -23
  119. package/src/review-preferences.ts +0 -88
  120. package/src/server/legacy-tutorial-cleanup.ts +0 -38
@@ -1,3 +1,5 @@
1
+ import { boardTitle } from "@dev.fast/review-protocol";
2
+
1
3
  import {
2
4
  type Element,
3
5
  ReviewInputError,
@@ -6,11 +8,22 @@ import {
6
8
  } from "./document.js";
7
9
  import type { Snapshot } from "./store.js";
8
10
 
11
+ interface RelatedBoard {
12
+ id: string;
13
+ title: string;
14
+ }
15
+
9
16
  /** A reading view of saved content, not another document format to maintain. */
10
17
  export function documentText(
11
18
  snapshot: Snapshot,
12
19
  targetId?: string,
13
20
  full = false,
21
+ /** Where the whiteboard sits among its project's, by title. */
22
+ relations?: {
23
+ parent?: RelatedBoard;
24
+ children: RelatedBoard[];
25
+ dependsOn: RelatedBoard[];
26
+ },
14
27
  ): string {
15
28
  const target =
16
29
  targetId === undefined
@@ -22,11 +35,27 @@ export function documentText(
22
35
  const detailed = full || target !== undefined;
23
36
 
24
37
  const lines = [
25
- `# ${snapshot.title}`,
38
+ `# ${boardTitle(snapshot.title)}`,
26
39
  `Session ${snapshot.reviewId} · version ${snapshot.version}`,
27
- "",
28
40
  ];
29
41
 
42
+ if (relations && targetId === undefined) {
43
+ const list = (boards: RelatedBoard[]) =>
44
+ boards
45
+ .map((board) => `${boardTitle(board.title)} (${board.id})`)
46
+ .join(", ");
47
+
48
+ if (relations.parent) lines.push(`Parent: ${list([relations.parent])}`);
49
+
50
+ if (relations.children.length)
51
+ lines.push(`Children, in order: ${list(relations.children)}`);
52
+
53
+ if (relations.dependsOn.length)
54
+ lines.push(`Depends on: ${list(relations.dependsOn)}`);
55
+ }
56
+
57
+ lines.push("");
58
+
30
59
  const write = (depth: number, text: string) => {
31
60
  for (const line of text.split("\n")) lines.push(" ".repeat(depth) + line);
32
61
  };
@@ -1,14 +1,24 @@
1
+ import {
2
+ type JsonValue,
3
+ isJsonArray,
4
+ isJsonObject,
5
+ isStringValue,
6
+ } from "@dev.fast/json";
1
7
  import {
2
8
  type Anchor,
3
9
  type LensSource,
10
+ commitLinkTarget,
4
11
  parseAnchor,
5
12
  selectionProblem,
6
13
  sourceAnchors,
7
14
  } from "@review/lens-selection.js";
8
15
  import {
16
+ type MarkdownNode,
17
+ linkDestinations,
9
18
  markdownNodes,
10
19
  markdownText,
11
20
  parseMarkdown,
21
+ rewriteLinkDestinations,
12
22
  } from "@review/markdown.js";
13
23
  import {
14
24
  type FileLineRange,
@@ -144,6 +154,11 @@ const targets = <Repository extends Record<string, z.ZodType>>(
144
154
  repository: Repository,
145
155
  ) =>
146
156
  z.discriminatedUnion("kind", [
157
+ /** The project of the repository, no change: a plan. */
158
+ z.strictObject({
159
+ kind: z.literal("project"),
160
+ ...repository,
161
+ }),
147
162
  z.strictObject({
148
163
  kind: z.literal("worktree"),
149
164
  ...repository,
@@ -159,8 +174,16 @@ const targets = <Repository extends Record<string, z.ZodType>>(
159
174
 
160
175
  export const reviewTargetSchema = targets({ repositoryId: label });
161
176
 
162
- /** How agents name a target: by the checkout's path, registered on acceptance. */
163
- export const pathTargetSchema = targets({ repositoryPath: label });
177
+ /** Targets as agents write them: a checkout path instead of a repository id,
178
+ * plus a PR by URL or number. */
179
+ export const pathTargetSchema = z.discriminatedUnion("kind", [
180
+ ...targets({ repositoryPath: label }).options,
181
+ z.strictObject({
182
+ kind: z.literal("pullRequest"),
183
+ repositoryPath: label,
184
+ pullRequest: z.union([label, z.number().int().positive()]),
185
+ }),
186
+ ]);
164
187
 
165
188
  export type ReviewTarget = z.infer<typeof reviewTargetSchema>;
166
189
 
@@ -175,11 +198,131 @@ const unitTypes = new Set(["step", "flow_node", "flow_edge"]);
175
198
  export const isUnit = (element: Element): element is Unit =>
176
199
  unitTypes.has(element.type);
177
200
 
201
+ /** Rewrites old review-source: and review-trace: links to their whiteboard: form. */
202
+ export function canonicalLink(href: string): string {
203
+ return href.replace(
204
+ /^review-(source|trace):/i,
205
+ (_, kind: string) => `whiteboard:${kind.toLowerCase()}/`,
206
+ );
207
+ }
208
+
209
+ /** Markdown link destinations and definitions that begin with `target`, a
210
+ * regex source; the match's first group is what comes before it. */
211
+ const linkDestination = (target: string) =>
212
+ new RegExp(String.raw`(\]\(\s*<?|^\s*\[[^\]\n]+\]:\s*<?)` + target, "gim");
213
+
214
+ function rewriteMarkdown<T>(
215
+ value: T,
216
+ rewrite: (markdown: string) => string,
217
+ ): T {
218
+ const visit = (node: JsonValue) => {
219
+ if (isJsonArray(node)) node.forEach(visit);
220
+ else if (isJsonObject(node))
221
+ for (const [key, field] of Object.entries(node))
222
+ if (key === "markdown" && isStringValue(field)) {
223
+ const rewritten = rewrite(field);
224
+
225
+ if (rewritten !== field) node[key] = rewritten;
226
+ } else visit(field);
227
+ };
228
+
229
+ // SAFETY: documents and edits are JSON; only Markdown text changes.
230
+ visit(value as JsonValue);
231
+
232
+ return value;
233
+ }
234
+
235
+ const retiredLinks = linkDestination("review-(source|trace):");
236
+
237
+ /** Rewrites old link prefixes in link destinations only, not code. */
238
+ export function respellLinks(markdown: string): string {
239
+ if (!/review-(source|trace):/i.test(markdown)) return markdown;
240
+
241
+ return rewriteLinkDestinations(markdown, (span) =>
242
+ span.replace(
243
+ retiredLinks,
244
+ (_, before: string, kind: string) =>
245
+ `${before}whiteboard:${kind.toLowerCase()}/`,
246
+ ),
247
+ );
248
+ }
249
+
250
+ /** Every Markdown text in `document` with its links spelled the current
251
+ * way. */
252
+ export function respellDocumentLinks<T>(document: T): T {
253
+ return rewriteMarkdown(document, respellLinks);
254
+ }
255
+
256
+ export function respellEditLinks(edit: Edit): Edit {
257
+ return rewriteMarkdown(edit, respellLinks);
258
+ }
259
+
260
+ /** Names a block from outside its board. Block ids are unique only within a board. */
261
+ export type BlockRef = { board: string; block: string };
262
+
263
+ export type BoardRef = BlockRef | { board: string; block?: undefined };
264
+
265
+ /** Parses whiteboard:board/<id> or whiteboard:board/<id>#<blockId>. */
266
+ export function boardLink(href: string): BoardRef | undefined {
267
+ const match = /^whiteboard:board\/([^#/]+)(?:#(.+))?$/.exec(href);
268
+
269
+ if (!match) return undefined;
270
+
271
+ try {
272
+ const board = decodeURIComponent(match[1]!);
273
+
274
+ return match[2] === undefined
275
+ ? { board }
276
+ : { board, block: decodeURIComponent(match[2]) };
277
+ } catch {
278
+ return undefined;
279
+ }
280
+ }
281
+
282
+ /** A link from a block to a board or one of its blocks. */
283
+ export interface BoardLinkRef {
284
+ from: BlockRef;
285
+ to: BoardRef;
286
+ }
287
+
288
+ const parsedBlocks = new WeakMap<
289
+ object,
290
+ { markdown: string; nodes: MarkdownNode[] }
291
+ >();
292
+
293
+ /** Parsed once per text; a save reads links, sources and resources from
294
+ * it. */
295
+ function blockNodes(block: { markdown: string }): MarkdownNode[] {
296
+ const cached = parsedBlocks.get(block);
297
+
298
+ if (cached?.markdown === block.markdown) return cached.nodes;
299
+
300
+ const nodes = [...markdownNodes(parseMarkdown(block.markdown))];
301
+
302
+ parsedBlocks.set(block, { markdown: block.markdown, nodes });
303
+
304
+ return nodes;
305
+ }
306
+
307
+ /** Every board link in a document. Malformed links are skipped; writes reject them. */
308
+ export function boardLinks(board: string, document: Block[]): BoardLinkRef[] {
309
+ return elements(document).flatMap((element) =>
310
+ element.type === "markdown"
311
+ ? blockNodes(element).flatMap((node) => {
312
+ const to =
313
+ node.type === "link" ? boardLink(node.url ?? "") : undefined;
314
+
315
+ return to ? [{ from: { board, block: element.id! }, to }] : [];
316
+ })
317
+ : [],
318
+ );
319
+ }
320
+
178
321
  /** Inline trace quotes keep paragraph/list layout while using retained resources. */
179
322
  export function traceQuoteLink(
180
323
  href: string,
181
324
  ): { traceId: string; eventId: string } | undefined {
182
- const match = /^review-trace:([^#]+)#(.+)$/.exec(href);
325
+ const match = /^whiteboard:trace\/([^#]+)#(.+)$/.exec(canonicalLink(href));
183
326
 
184
327
  return match
185
328
  ? {
@@ -213,17 +356,18 @@ export function resourceReferences(document: Block[]): Block[] {
213
356
  ? [block]
214
357
  : [];
215
358
 
216
- return [...markdownNodes(parseMarkdown(block.markdown))].flatMap(
217
- (node): Block[] => {
218
- if (node.type !== "link" || !node.url?.startsWith("review-trace:"))
219
- return [];
220
- const quote = traceQuoteLink(node.url);
359
+ return blockNodes(block).flatMap((node): Block[] => {
360
+ if (
361
+ node.type !== "link" ||
362
+ !canonicalLink(node.url ?? "").startsWith("whiteboard:trace/")
363
+ )
364
+ return [];
365
+ const quote = traceQuoteLink(node.url ?? "");
221
366
 
222
- if (!quote) throw new ReviewInputError("Invalid trace quote link.");
367
+ if (!quote) throw new ReviewInputError("Invalid trace quote link.");
223
368
 
224
- return [{ type: "trace_quote", ...quote, text: markdownText(node) }];
225
- },
226
- );
369
+ return [{ type: "trace_quote", ...quote, text: markdownText(node) }];
370
+ });
227
371
  });
228
372
  }
229
373
 
@@ -237,7 +381,13 @@ function documentReferences(
237
381
  {
238
382
  tolerant = false,
239
383
  markdown = true,
240
- }: { tolerant?: boolean; markdown?: boolean } = {},
384
+ repositoryId,
385
+ }: {
386
+ tolerant?: boolean;
387
+ markdown?: boolean;
388
+ /** The board's repository, which commit links read from. */
389
+ repositoryId?: string;
390
+ } = {},
241
391
  ): {
242
392
  id: string;
243
393
  source: LensSource;
@@ -271,46 +421,68 @@ function documentReferences(
271
421
  if (element.type === "markdown" && !markdown) return [];
272
422
 
273
423
  if (element.type === "markdown")
274
- return [...markdownNodes(parseMarkdown(element.markdown))].flatMap(
275
- (node) => {
276
- if (node.type !== "link") return [];
277
- const href = node.url ?? "";
424
+ return blockNodes(element).flatMap((node) => {
425
+ if (node.type !== "link") return [];
426
+ const href = canonicalLink(node.url ?? "");
427
+
428
+ if (!href.startsWith("whiteboard:source/")) {
429
+ // Trace links are checked by resourceReferences.
430
+ if (href.startsWith("whiteboard:trace/")) return [];
431
+
432
+ // Board links are checked against the store on write.
433
+ if (href.startsWith("whiteboard:board/"))
434
+ return boardLink(href)
435
+ ? []
436
+ : reject(
437
+ `Use whiteboard:board/<sessionId> or whiteboard:board/<sessionId>#<blockId> to link a board, in block ${element.id}.`,
438
+ );
439
+
440
+ if (/^(?:https?:\/\/|mailto:|#)/i.test(href)) return [];
441
+
442
+ return reject(
443
+ `Unsupported Markdown link ${JSON.stringify(href)} in block ${element.id}. Use [label](whiteboard:source/head/path#L10-L24) or whiteboard:source/base/path#L10-L24 for repository files, whiteboard:source/diff/path#L84-R90 across sides, or whiteboard:source/<commit>/path#L10-L24 at a commit of the board's repository, with a repository-relative path and verified line numbers. Link another board as whiteboard:board/<sessionId>, or one of its blocks as whiteboard:board/<sessionId>#<blockId>. External links must use https://, http://, or mailto:; document anchors use #heading.`,
444
+ );
445
+ }
278
446
 
279
- if (!/^review-source:/i.test(href)) {
280
- // Trace links are checked by resourceReferences.
281
- if (href.startsWith("review-trace:")) return [];
447
+ let target: string;
282
448
 
283
- if (/^(?:https?:\/\/|mailto:|#)/i.test(href)) return [];
449
+ try {
450
+ target = decodeURIComponent(href.slice("whiteboard:source/".length));
451
+ } catch {
452
+ return reject("Invalid URL encoding in source link.");
453
+ }
284
454
 
285
- return reject(
286
- `Unsupported Markdown link ${JSON.stringify(href)} in block ${element.id}. Use [label](review-source:head/path#L10-L24) or review-source:base/path#L10-L24 for repository files, or review-source:diff/path#L84-R90 across sides, with a repository-relative path and verified line numbers. External links must use https://, http://, or mailto:; document anchors use #heading.`,
287
- );
288
- }
455
+ // A commit of the board's repository: the commit is the link's pins.
456
+ const commit = commitLinkTarget(target);
289
457
 
290
- let target: string;
458
+ if (commit)
459
+ return repositoryId
460
+ ? select(commit.anchor, {
461
+ repositoryId,
462
+ head: commit.commit,
463
+ }).map((source) => ({
464
+ id: `${element.id}:${node.url}`,
465
+ source,
466
+ }))
467
+ : reject(
468
+ `A commit link names a commit of the board's repository, and this board has none, in block ${element.id}.`,
469
+ );
291
470
 
292
- try {
293
- target = decodeURIComponent(href.slice("review-source:".length));
294
- } catch {
295
- return reject("Invalid URL encoding in source link.");
296
- }
471
+ // Links share the anchor grammar blocks use.
472
+ const anchor = target.replace(/^(head|base|diff)\//i, (side) =>
473
+ side.toLowerCase(),
474
+ );
297
475
 
298
- // Links share the anchor grammar blocks use.
299
- const anchor = target.replace(/^(head|base|diff)\//i, (side) =>
300
- side.toLowerCase(),
476
+ if (!parseAnchor(anchor))
477
+ return reject(
478
+ "Use whiteboard:source/head/path#L10-L24 (or base, diff/path#L84-R90 across sides, or <commit>/path#L10-L24 at a commit of the board's repository) for a source link.",
301
479
  );
302
480
 
303
- if (!parseAnchor(anchor))
304
- return reject(
305
- "Use review-source:head/path#L10-L24 (or base, or diff/path#L84-R90 across sides) for a source link.",
306
- );
307
-
308
- return select(anchor, element.pins).map((source) => ({
309
- id: `${element.id}:${node.url}`,
310
- source,
311
- }));
312
- },
313
- );
481
+ return select(anchor, element.pins).map((source) => ({
482
+ id: `${element.id}:${node.url}`,
483
+ source,
484
+ }));
485
+ });
314
486
 
315
487
  if (element.type === "code_peek")
316
488
  return select(element.source, element.pins).map((source) => ({
@@ -414,10 +586,122 @@ function documentReferences(
414
586
  /** All authored attachments, including code peeks, select the aligned diff. */
415
587
  export const selectionReferences = documentReferences;
416
588
 
589
+ const commitLinkDestination = linkDestination(
590
+ String.raw`whiteboard:source\/([0-9a-f]{7,40})\/`,
591
+ );
592
+
593
+ const hasCommitLink = /whiteboard:source\//i;
594
+
595
+ /** Commits named by commit links, not by code samples. */
596
+ export function commitLinkCommits(document: Block[]): string[] {
597
+ const commits = new Set<string>();
598
+
599
+ for (const element of elements(document))
600
+ if (element.type === "markdown" && hasCommitLink.test(element.markdown))
601
+ for (const { from, to } of linkDestinations(blockNodes(element)))
602
+ for (const match of element.markdown
603
+ .slice(from, to)
604
+ .matchAll(commitLinkDestination))
605
+ commits.add(match[2]!);
606
+
607
+ return [...commits];
608
+ }
609
+
610
+ /** Rewrites the commits Markdown commit links name, as a short commit
611
+ * becomes the full one on save. */
612
+ export function respellCommitLinks(
613
+ document: Block[],
614
+ commits: ReadonlyMap<string, string>,
615
+ ): Block[] {
616
+ return rewriteMarkdown(document, (markdown) =>
617
+ hasCommitLink.test(markdown)
618
+ ? rewriteLinkDestinations(markdown, (span) =>
619
+ span.replace(
620
+ commitLinkDestination,
621
+ (_, before: string, commit: string) =>
622
+ `${before}whiteboard:source/${commits.get(commit) ?? commit}/`,
623
+ ),
624
+ )
625
+ : markdown,
626
+ );
627
+ }
628
+
629
+ /** Blocks that carry pins of their own for the references in them. */
630
+ const pinnedBlockTypes = new Set([
631
+ "markdown",
632
+ "code_peek",
633
+ "sequence",
634
+ "flow_diagram",
635
+ "call_stack_diff",
636
+ "database_lens",
637
+ ]);
638
+
639
+ const hasSideLink = /whiteboard:source\/(head|base)\//i;
640
+
641
+ const sideLinkDestination = linkDestination(
642
+ String.raw`whiteboard:source\/(head|base)\/`,
643
+ );
644
+
645
+ /**
646
+ * Makes every reference that reads at the document's pins carry them
647
+ * itself, for a board about to lose its pins: Markdown head/ and base/
648
+ * links become commit links, and a block whose references still lean on
649
+ * the document's pins (any anchor, or a diff/ link) takes them as its own.
650
+ * Nothing changes what it points at.
651
+ */
652
+ export function pinDocumentReferences(document: Block[], pins: Pins): Block[] {
653
+ const own: SourcePins = {
654
+ repositoryId: pins.repositoryId,
655
+ base: pins.base,
656
+ head: pins.head,
657
+ };
658
+
659
+ const pinned = structuredClone(document);
660
+
661
+ for (const element of elements(pinned)) {
662
+ if (!pinnedBlockTypes.has(element.type)) continue;
663
+
664
+ // SAFETY: every pinned block type has optional source pins of its own.
665
+ const block = element as Block & { pins?: SourcePins };
666
+
667
+ if (block.pins) continue;
668
+
669
+ if (block.type === "markdown") {
670
+ block.markdown = respellLinks(block.markdown);
671
+
672
+ if (hasSideLink.test(block.markdown))
673
+ block.markdown = rewriteLinkDestinations(block.markdown, (span) =>
674
+ span.replace(
675
+ sideLinkDestination,
676
+ (_, before: string, side: string) =>
677
+ `${before}whiteboard:source/${side.toLowerCase() === "base" ? pins.base : pins.head}/`,
678
+ ),
679
+ );
680
+ }
681
+
682
+ if (
683
+ documentReferences([block], { tolerant: true }).some(
684
+ (reference) => !reference.source.pins,
685
+ )
686
+ )
687
+ block.pins = own;
688
+ }
689
+
690
+ return pinned;
691
+ }
692
+
693
+ /** The repository a board's commit links read from: its target's. */
694
+ export function boardRepositoryId(board: {
695
+ target?: { repositoryId: string };
696
+ pins?: { repositoryId: string };
697
+ }): string | undefined {
698
+ return board.target?.repositoryId ?? board.pins?.repositoryId;
699
+ }
700
+
417
701
  /** Per-side read coordinates for endpoint validation and retained source quotes. */
418
702
  export function sourceReferences(
419
703
  document: Block[],
420
- options: { tolerant?: boolean } = {},
704
+ options: { tolerant?: boolean; repositoryId?: string } = {},
421
705
  ) {
422
706
  return selectionReferences(document, options).flatMap((ref) =>
423
707
  sourceAnchors(ref.source).map((source) => ({ ...ref, source })),
@@ -425,12 +709,16 @@ export function sourceReferences(
425
709
  }
426
710
 
427
711
  /** Includes lenses and maps without inline ranges. */
428
- export function hasCodeReferences(review: {
712
+ /** Whether a board reads code at its own pins: a commit link or a block
713
+ * with pins of its own names its commits itself. */
714
+ export function readsAtBoardPins(review: {
429
715
  document: Block[];
430
716
  lenses?: readonly Lens[];
431
717
  }): boolean {
432
718
  return Boolean(
433
- sourceReferences(review.document).length ||
719
+ sourceReferences(review.document, { tolerant: true }).some(
720
+ (reference) => !reference.source.pins,
721
+ ) ||
434
722
  review.lenses?.length ||
435
723
  resourceReferences(review.document).some(
436
724
  (block) => block.type === "software_map",
@@ -514,6 +802,8 @@ export interface EditSummary {
514
802
  units?: string[];
515
803
  /** The agent whose courier draws this edit, when the host could tell. */
516
804
  activityId?: string;
805
+ /** The Ask thread that made this edit, if any. */
806
+ threadId?: string;
517
807
  }
518
808
 
519
809
  /** A component an edit wrote, named so the author can address it. */
@@ -711,18 +1001,33 @@ export function assignFreshIds(
711
1001
  element: Element,
712
1002
  allocate: (prefix: string) => string,
713
1003
  ): void {
714
- if (element.id !== undefined)
715
- throw new ReviewInputError("IDs are assigned by the server.");
716
- element.id = allocate(idPrefix(element));
1004
+ assignIds(element, allocate, true);
1005
+ }
717
1006
 
718
- for (const child of children(element)) assignFreshIds(child, allocate);
1007
+ /** Give ids to the parts of an element tree that have none, keeping the
1008
+ * rest: a person's save names the blocks it kept and leaves new ones bare. */
1009
+ export function assignMissingIds(
1010
+ element: Element,
1011
+ allocate: (prefix: string) => string,
1012
+ ): void {
1013
+ assignIds(element, allocate, false);
1014
+ }
719
1015
 
1016
+ function assignIds(
1017
+ element: Element,
1018
+ allocate: (prefix: string) => string,
1019
+ fresh: boolean,
1020
+ ): void {
720
1021
  const assign = (item: { id?: string }, prefix: string) => {
721
- if (item.id !== undefined)
1022
+ if (item.id === undefined) item.id = allocate(prefix);
1023
+ else if (fresh)
722
1024
  throw new ReviewInputError("IDs are assigned by the server.");
723
- item.id = allocate(prefix);
724
1025
  };
725
1026
 
1027
+ assign(element, idPrefix(element));
1028
+
1029
+ for (const child of children(element)) assignIds(child, allocate, fresh);
1030
+
726
1031
  if (element.type === "call_stack_diff")
727
1032
  for (const frame of [...element.base, ...element.head])
728
1033
  assign(frame, "frame");
@@ -735,16 +1040,10 @@ export function assignFreshIds(
735
1040
  }
736
1041
  }
737
1042
 
738
- export interface ApplyEditOptions {
739
- /** Where a root-level insert with no placement lands: the end by default. */
740
- placement?: "first" | "last";
741
- }
742
-
743
1043
  export function applyEdit(
744
1044
  document: Block[],
745
1045
  edit: Edit,
746
1046
  allocate: (prefix: string) => string,
747
- options: ApplyEditOptions = {},
748
1047
  ): Applied {
749
1048
  adoptFlowUnits(document, allocate);
750
1049
 
@@ -808,14 +1107,10 @@ export function applyEdit(
808
1107
  // Resolve the destination before detaching, then compute its final position.
809
1108
  if (from) from.splice(from.indexOf(element), 1);
810
1109
 
811
- // A running log reads newest first, so a root insert with no placement
812
- // may lead the document; a move or a unit inside a diagram never does.
813
1110
  const index =
814
- afterId !== undefined
815
- ? siblings.findIndex((s) => s.id === afterId) + 1
816
- : !parent && !from && options.placement === "first"
817
- ? 0
818
- : siblings.length;
1111
+ afterId === undefined
1112
+ ? siblings.length
1113
+ : siblings.findIndex((s) => s.id === afterId) + 1;
819
1114
 
820
1115
  siblings.splice(index, 0, element);
821
1116
  };