@dev.fast/whiteboard 0.3.3 → 0.3.4-preview.20261019.126

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 (118) hide show
  1. package/dist/{account-alias-xOdmMnDH.js → account-alias-B5-HYi1D.js} +16 -9
  2. package/dist/{agent-cli-DT7MdaqL.js → agent-cli-CohHf6Ua.js} +15 -10
  3. package/dist/agent-cli-_YyMIMRY.js +2 -0
  4. package/dist/{agent-client-BRBzv3AV.js → agent-client-Dtc-Kn_w.js} +13 -3
  5. package/dist/{authoring-tools-DGSEag8y.js → authoring-tools-B7KU04f7.js} +34 -27
  6. package/dist/{background-server-WKzI_a98.js → background-server-C03RKxKS.js} +5 -5
  7. package/dist/{background-server-C_REWFkG.js → background-server-CB9C4uoc.js} +1 -1
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-BBSDi_dX.js → cli-CJaEb7zC.js} +1 -1
  10. package/dist/{cli-runner-DshTBske.js → cli-runner-DGDIqh3V.js} +26 -26
  11. package/dist/cli.js +4 -3
  12. package/dist/{client-BN_FDxFr.js → client-lKVF_Elo.js} +253 -47
  13. package/dist/{fuzzy-match-bFX6wm8j.js → fuzzy-match-BmVJJm9I.js} +42 -14
  14. package/dist/{headless-host-CzvYpKOX.js → headless-host-DRtjf9WA.js} +1 -1
  15. package/dist/{headless-host-CS4Gv0c5.js → headless-host-XhL1X4vX.js} +523 -146
  16. package/dist/lens-selection-CyJapcsl.js +214 -0
  17. package/dist/{local-data-DtQTIzHX.js → local-data-Cpl4ENbJ.js} +2434 -328
  18. package/dist/{mcp-DpKnnmHH.js → mcp-ovmiUE_x.js} +5 -9
  19. package/dist/{package-paths-jMg6JxDP.js → package-paths-BWxO-UEL.js} +1 -1
  20. package/dist/{profile-DaqmODhc.js → profile-BadSkCkI.js} +20 -8
  21. package/dist/profile-DJvkBJNQ.js +2 -0
  22. package/dist/{remote-attach-DCngg8cl.js → remote-attach-Dd-YobM6.js} +4 -4
  23. package/dist/{remote-extensions-BCAbQZTv.js → remote-extensions-CNXwqK0D.js} +3 -3
  24. package/dist/{remote-extensions-Cf3L-ZGG.js → remote-extensions-VP-kfq0C.js} +1 -1
  25. package/dist/{remote-language-server-UWQgpoh-.js → remote-language-server-BXk87KCr.js} +5 -5
  26. package/dist/{remote-uninstall-CcDOcGyi.js → remote-uninstall-DuOzsvaD.js} +6 -6
  27. package/dist/{request-origin-Zcn_CjX4.js → request-origin-B9qCPiVB.js} +45 -12
  28. package/dist/{review-app-launcher-tqSQPlbu.js → review-app-launcher-C7el2Uoy.js} +2 -2
  29. package/dist/{review-home-paths-Lc02WFuX.js → review-home-paths-D8AoCdd2.js} +19 -17
  30. package/dist/{review-telemetry-BHWJsnoD.js → review-telemetry-BsoRMC2w.js} +5 -5
  31. package/dist/runtime-Bj2tD33R.js +27 -0
  32. package/dist/runtime.d.ts +1 -1
  33. package/dist/runtime.js +1 -1
  34. package/dist/{s3-77-qkEhs.js → s3-CsG2EWED.js} +1 -1
  35. package/dist/{s3-config-DDWWMh5x.js → s3-config-CjcLyFV5.js} +5 -3
  36. package/dist/{s3-config-0xuCh-F_.js → s3-config-zz4GWh2T.js} +1 -1
  37. package/dist/server/desktop-host.js +29 -76
  38. package/dist/{server-discovery-BVqrE1_-.js → server-discovery-CKjw3-6h.js} +6 -5
  39. package/dist/sharing/index.d.ts +486 -70
  40. package/dist/sharing/index.js +1 -1
  41. package/dist/{src-DsM3jO8G.js → src-BiWNbcF_.js} +89 -12
  42. package/dist/{src-BMPuxWv4.js → src-WPaQn75m.js} +208 -73
  43. package/dist/{stored-document-migration-CChxtFAy.js → stored-document-migration-dFbewRTG.js} +270 -269
  44. package/dist/{thread-state-5pYr6_zM.js → thread-state-Do5LX10I.js} +7 -2
  45. package/dist/{tool-failure-BFt2xj9i.js → tool-failure-DvI-gys2.js} +10 -3
  46. package/instructions/authoring.md +3 -2
  47. package/package.json +4 -4
  48. package/src/agent-connect.ts +8 -2
  49. package/src/ask/agents.ts +51 -9
  50. package/src/ask/checkout-files.ts +2 -5
  51. package/src/ask/thread-state.ts +4 -0
  52. package/src/ask/thread.ts +16 -0
  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/review-api/README.md +52 -13
  61. package/src/review-api/activity.ts +1 -4
  62. package/src/review-api/agent-cli.ts +9 -1
  63. package/src/review-api/anchor-quotes.ts +14 -7
  64. package/src/review-api/ask-history.ts +137 -51
  65. package/src/review-api/authoring-tools.ts +45 -30
  66. package/src/review-api/blocks/definition.ts +1 -1
  67. package/src/review-api/blocks/markdown.ts +2 -2
  68. package/src/review-api/board-order.ts +144 -0
  69. package/src/review-api/checkout-groups.ts +560 -0
  70. package/src/review-api/document-headings.ts +58 -27
  71. package/src/review-api/document-text.ts +31 -2
  72. package/src/review-api/document.ts +281 -34
  73. package/src/review-api/foreign-references.ts +328 -0
  74. package/src/review-api/github-provider.ts +51 -13
  75. package/src/review-api/http.ts +702 -123
  76. package/src/review-api/instructions.ts +0 -19
  77. package/src/review-api/link-preview.ts +113 -0
  78. package/src/review-api/local-data.ts +310 -46
  79. package/src/review-api/mcp.ts +3 -11
  80. package/src/review-api/onboarding.ts +1 -1
  81. package/src/review-api/profile.ts +37 -6
  82. package/src/review-api/public-tools.ts +24 -2
  83. package/src/review-api/pull-request.ts +82 -49
  84. package/src/review-api/request-origin.ts +10 -0
  85. package/src/review-api/review-progress.ts +61 -27
  86. package/src/review-api/scratchpad-plan-upgrade.ts +250 -0
  87. package/src/review-api/store-migrations.ts +357 -0
  88. package/src/review-api/store-schema.ts +38 -29
  89. package/src/review-api/store.ts +2144 -480
  90. package/src/review-api/workspaces.ts +52 -13
  91. package/src/review-api/worktree-events.ts +3 -5
  92. package/src/review-api/worktree-source.ts +3 -7
  93. package/src/review-api/worktree-structural.integration.ts +72 -0
  94. package/src/review-stack.ts +15 -23
  95. package/src/runtime.ts +4 -30
  96. package/src/server/desktop-host.ts +6 -1
  97. package/src/server/desktop-server.ts +1 -31
  98. package/src/server/headless-host.ts +21 -14
  99. package/src/server/hono-http.ts +2 -1
  100. package/src/server/legacy-tutorial-cleanup.ts +2 -3
  101. package/src/server/review-gateway-list.ts +3 -5
  102. package/src/server/review-gateway-memory.ts +1 -1
  103. package/src/server/review-lifecycle-telemetry.ts +2 -2
  104. package/src/server/review-server-core.ts +1 -5
  105. package/src/server/structural-comparisons.ts +47 -0
  106. package/src/server-discovery.ts +4 -2
  107. package/src/sharing/export.ts +36 -7
  108. package/src/sharing/host.ts +34 -14
  109. package/src/sharing/import.ts +156 -24
  110. package/src/sharing/repository.ts +128 -20
  111. package/src/startup-trace.ts +3 -3
  112. package/src/stored-document-migration.ts +110 -19
  113. package/src/ui-telemetry-events.ts +33 -11
  114. package/dist/agent-cli-DeCrUC2s.js +0 -2
  115. package/dist/profile-Cz7L781D.js +0 -2
  116. package/dist/runtime-CKiWhGrf.js +0 -52
  117. package/instructions/scratchpad.md +0 -23
  118. package/src/review-preferences.ts +0 -88
@@ -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,6 +1,8 @@
1
+ import { type JsonValue, isStringValue } from "@dev.fast/json";
1
2
  import {
2
3
  type Anchor,
3
4
  type LensSource,
5
+ commitLinkTarget,
4
6
  parseAnchor,
5
7
  selectionProblem,
6
8
  sourceAnchors,
@@ -144,6 +146,11 @@ const targets = <Repository extends Record<string, z.ZodType>>(
144
146
  repository: Repository,
145
147
  ) =>
146
148
  z.discriminatedUnion("kind", [
149
+ /** The project of the repository, no change: a plan. */
150
+ z.strictObject({
151
+ kind: z.literal("project"),
152
+ ...repository,
153
+ }),
147
154
  z.strictObject({
148
155
  kind: z.literal("worktree"),
149
156
  ...repository,
@@ -159,8 +166,16 @@ const targets = <Repository extends Record<string, z.ZodType>>(
159
166
 
160
167
  export const reviewTargetSchema = targets({ repositoryId: label });
161
168
 
162
- /** How agents name a target: by the checkout's path, registered on acceptance. */
163
- export const pathTargetSchema = targets({ repositoryPath: label });
169
+ /** Targets as agents write them: a checkout path instead of a repository id,
170
+ * plus a PR by URL or number. */
171
+ export const pathTargetSchema = z.discriminatedUnion("kind", [
172
+ ...targets({ repositoryPath: label }).options,
173
+ z.strictObject({
174
+ kind: z.literal("pullRequest"),
175
+ repositoryPath: label,
176
+ pullRequest: z.union([label, z.number().int().positive()]),
177
+ }),
178
+ ]);
164
179
 
165
180
  export type ReviewTarget = z.infer<typeof reviewTargetSchema>;
166
181
 
@@ -175,11 +190,99 @@ const unitTypes = new Set(["step", "flow_node", "flow_edge"]);
175
190
  export const isUnit = (element: Element): element is Unit =>
176
191
  unitTypes.has(element.type);
177
192
 
193
+ /** Rewrites old review-source: and review-trace: links to their whiteboard: form. */
194
+ export function canonicalLink(href: string): string {
195
+ return href.replace(
196
+ /^review-(source|trace):/i,
197
+ (_, kind: string) => `whiteboard:${kind.toLowerCase()}/`,
198
+ );
199
+ }
200
+
201
+ /** Markdown link destinations and definitions that begin with `target`, a
202
+ * regex source; the match's first group is what comes before it. */
203
+ const linkDestination = (target: string) =>
204
+ new RegExp(String.raw`(\]\(\s*<?|^\s*\[[^\]\n]+\]:\s*<?)` + target, "gim");
205
+
206
+ /** Every Markdown text in `value`, rewritten. */
207
+ function rewriteMarkdown<T>(
208
+ value: T,
209
+ rewrite: (markdown: string) => string,
210
+ ): T {
211
+ // SAFETY: a JSON round trip of stored or parsed JSON, rewriting only
212
+ // Markdown text.
213
+ return JSON.parse(JSON.stringify(value), (key, field: JsonValue) =>
214
+ key === "markdown" && isStringValue(field) ? rewrite(field) : field,
215
+ ) as T;
216
+ }
217
+
218
+ const retiredLinks = linkDestination("review-(source|trace):");
219
+
220
+ /** Rewrites old link prefixes in Markdown link destinations and definitions. */
221
+ export function respellLinks(markdown: string): string {
222
+ return markdown.replace(
223
+ retiredLinks,
224
+ (_, before: string, kind: string) =>
225
+ `${before}whiteboard:${kind.toLowerCase()}/`,
226
+ );
227
+ }
228
+
229
+ /** Every Markdown text in `document` with its links spelled the current
230
+ * way. */
231
+ export function respellDocumentLinks<T>(document: T): T {
232
+ return rewriteMarkdown(document, respellLinks);
233
+ }
234
+
235
+ export function respellEditLinks(edit: Edit): Edit {
236
+ return rewriteMarkdown(edit, respellLinks);
237
+ }
238
+
239
+ /** Names a block from outside its board. Block ids are unique only within a board. */
240
+ export type BlockRef = { board: string; block: string };
241
+
242
+ export type BoardRef = BlockRef | { board: string; block?: undefined };
243
+
244
+ /** Parses whiteboard:board/<id> or whiteboard:board/<id>#<blockId>. */
245
+ export function boardLink(href: string): BoardRef | undefined {
246
+ const match = /^whiteboard:board\/([^#/]+)(?:#(.+))?$/.exec(href);
247
+
248
+ if (!match) return undefined;
249
+
250
+ try {
251
+ const board = decodeURIComponent(match[1]!);
252
+
253
+ return match[2] === undefined
254
+ ? { board }
255
+ : { board, block: decodeURIComponent(match[2]) };
256
+ } catch {
257
+ return undefined;
258
+ }
259
+ }
260
+
261
+ /** A link from a block to a board or one of its blocks. */
262
+ export interface BoardLinkRef {
263
+ from: BlockRef;
264
+ to: BoardRef;
265
+ }
266
+
267
+ /** Every board link in a document. Malformed links are skipped; writes reject them. */
268
+ export function boardLinks(board: string, document: Block[]): BoardLinkRef[] {
269
+ return elements(document).flatMap((element) =>
270
+ element.type === "markdown"
271
+ ? [...markdownNodes(parseMarkdown(element.markdown))].flatMap((node) => {
272
+ const to =
273
+ node.type === "link" ? boardLink(node.url ?? "") : undefined;
274
+
275
+ return to ? [{ from: { board, block: element.id! }, to }] : [];
276
+ })
277
+ : [],
278
+ );
279
+ }
280
+
178
281
  /** Inline trace quotes keep paragraph/list layout while using retained resources. */
179
282
  export function traceQuoteLink(
180
283
  href: string,
181
284
  ): { traceId: string; eventId: string } | undefined {
182
- const match = /^review-trace:([^#]+)#(.+)$/.exec(href);
285
+ const match = /^whiteboard:trace\/([^#]+)#(.+)$/.exec(canonicalLink(href));
183
286
 
184
287
  return match
185
288
  ? {
@@ -215,9 +318,12 @@ export function resourceReferences(document: Block[]): Block[] {
215
318
 
216
319
  return [...markdownNodes(parseMarkdown(block.markdown))].flatMap(
217
320
  (node): Block[] => {
218
- if (node.type !== "link" || !node.url?.startsWith("review-trace:"))
321
+ if (
322
+ node.type !== "link" ||
323
+ !canonicalLink(node.url ?? "").startsWith("whiteboard:trace/")
324
+ )
219
325
  return [];
220
- const quote = traceQuoteLink(node.url);
326
+ const quote = traceQuoteLink(node.url ?? "");
221
327
 
222
328
  if (!quote) throw new ReviewInputError("Invalid trace quote link.");
223
329
 
@@ -237,7 +343,13 @@ function documentReferences(
237
343
  {
238
344
  tolerant = false,
239
345
  markdown = true,
240
- }: { tolerant?: boolean; markdown?: boolean } = {},
346
+ repositoryId,
347
+ }: {
348
+ tolerant?: boolean;
349
+ markdown?: boolean;
350
+ /** The board's repository, which commit links read from. */
351
+ repositoryId?: string;
352
+ } = {},
241
353
  ): {
242
354
  id: string;
243
355
  source: LensSource;
@@ -274,27 +386,53 @@ function documentReferences(
274
386
  return [...markdownNodes(parseMarkdown(element.markdown))].flatMap(
275
387
  (node) => {
276
388
  if (node.type !== "link") return [];
277
- const href = node.url ?? "";
389
+ const href = canonicalLink(node.url ?? "");
278
390
 
279
- if (!/^review-source:/i.test(href)) {
391
+ if (!href.startsWith("whiteboard:source/")) {
280
392
  // Trace links are checked by resourceReferences.
281
- if (href.startsWith("review-trace:")) return [];
393
+ if (href.startsWith("whiteboard:trace/")) return [];
394
+
395
+ // Board links are checked against the store on write.
396
+ if (href.startsWith("whiteboard:board/"))
397
+ return boardLink(href)
398
+ ? []
399
+ : reject(
400
+ `Use whiteboard:board/<sessionId> or whiteboard:board/<sessionId>#<blockId> to link a board, in block ${element.id}.`,
401
+ );
282
402
 
283
403
  if (/^(?:https?:\/\/|mailto:|#)/i.test(href)) return [];
284
404
 
285
405
  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.`,
406
+ `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.`,
287
407
  );
288
408
  }
289
409
 
290
410
  let target: string;
291
411
 
292
412
  try {
293
- target = decodeURIComponent(href.slice("review-source:".length));
413
+ target = decodeURIComponent(
414
+ href.slice("whiteboard:source/".length),
415
+ );
294
416
  } catch {
295
417
  return reject("Invalid URL encoding in source link.");
296
418
  }
297
419
 
420
+ // A commit of the board's repository: the commit is the link's pins.
421
+ const commit = commitLinkTarget(target);
422
+
423
+ if (commit)
424
+ return repositoryId
425
+ ? select(commit.anchor, {
426
+ repositoryId,
427
+ head: commit.commit,
428
+ }).map((source) => ({
429
+ id: `${element.id}:${node.url}`,
430
+ source,
431
+ }))
432
+ : reject(
433
+ `A commit link names a commit of the board's repository, and this board has none, in block ${element.id}.`,
434
+ );
435
+
298
436
  // Links share the anchor grammar blocks use.
299
437
  const anchor = target.replace(/^(head|base|diff)\//i, (side) =>
300
438
  side.toLowerCase(),
@@ -302,7 +440,7 @@ function documentReferences(
302
440
 
303
441
  if (!parseAnchor(anchor))
304
442
  return reject(
305
- "Use review-source:head/path#L10-L24 (or base, or diff/path#L84-R90 across sides) for a source link.",
443
+ "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.",
306
444
  );
307
445
 
308
446
  return select(anchor, element.pins).map((source) => ({
@@ -414,10 +552,108 @@ function documentReferences(
414
552
  /** All authored attachments, including code peeks, select the aligned diff. */
415
553
  export const selectionReferences = documentReferences;
416
554
 
555
+ const commitLinkDestination = linkDestination(
556
+ String.raw`whiteboard:source\/([0-9a-f]{7,40})\/`,
557
+ );
558
+
559
+ /** The commits a document's Markdown commit links name, as written. */
560
+ export function commitLinkCommits(document: Block[]): string[] {
561
+ const commits = new Set<string>();
562
+
563
+ JSON.stringify(document, (key, value: JsonValue) => {
564
+ if (key === "markdown" && isStringValue(value))
565
+ for (const match of value.matchAll(commitLinkDestination))
566
+ commits.add(match[2]!);
567
+
568
+ return value;
569
+ });
570
+
571
+ return [...commits];
572
+ }
573
+
574
+ /** Rewrites the commits Markdown commit links name, as a short commit
575
+ * becomes the full one on save. */
576
+ export function respellCommitLinks(
577
+ document: Block[],
578
+ commits: ReadonlyMap<string, string>,
579
+ ): Block[] {
580
+ return rewriteMarkdown(document, (markdown) =>
581
+ markdown.replace(
582
+ commitLinkDestination,
583
+ (link, before: string, commit: string) =>
584
+ `${before}whiteboard:source/${commits.get(commit) ?? commit}/`,
585
+ ),
586
+ );
587
+ }
588
+
589
+ /** Blocks that carry pins of their own for the references in them. */
590
+ const pinnedBlockTypes = new Set([
591
+ "markdown",
592
+ "code_peek",
593
+ "sequence",
594
+ "flow_diagram",
595
+ "call_stack_diff",
596
+ "database_lens",
597
+ ]);
598
+
599
+ const sideLinkDestination = linkDestination(
600
+ String.raw`whiteboard:source\/(head|base)\/`,
601
+ );
602
+
603
+ /**
604
+ * Makes every reference that reads at the document's pins carry them
605
+ * itself, for a board about to lose its pins: Markdown head/ and base/
606
+ * links become commit links, and a block whose references still lean on
607
+ * the document's pins (any anchor, or a diff/ link) takes them as its own.
608
+ * Nothing changes what it points at.
609
+ */
610
+ export function pinDocumentReferences(document: Block[], pins: Pins): Block[] {
611
+ const own: SourcePins = {
612
+ repositoryId: pins.repositoryId,
613
+ base: pins.base,
614
+ head: pins.head,
615
+ };
616
+
617
+ const pinned = structuredClone(document);
618
+
619
+ for (const element of elements(pinned)) {
620
+ if (!pinnedBlockTypes.has(element.type)) continue;
621
+
622
+ // SAFETY: every pinned block type has optional source pins of its own.
623
+ const block = element as Block & { pins?: SourcePins };
624
+
625
+ if (block.pins) continue;
626
+
627
+ if (block.type === "markdown")
628
+ block.markdown = respellLinks(block.markdown).replace(
629
+ sideLinkDestination,
630
+ (_, before: string, side: string) =>
631
+ `${before}whiteboard:source/${side.toLowerCase() === "base" ? pins.base : pins.head}/`,
632
+ );
633
+
634
+ if (
635
+ documentReferences([block], { tolerant: true }).some(
636
+ (reference) => !reference.source.pins,
637
+ )
638
+ )
639
+ block.pins = own;
640
+ }
641
+
642
+ return pinned;
643
+ }
644
+
645
+ /** The repository a board's commit links read from: its target's. */
646
+ export function boardRepositoryId(board: {
647
+ target?: { repositoryId: string };
648
+ pins?: { repositoryId: string };
649
+ }): string | undefined {
650
+ return board.target?.repositoryId ?? board.pins?.repositoryId;
651
+ }
652
+
417
653
  /** Per-side read coordinates for endpoint validation and retained source quotes. */
418
654
  export function sourceReferences(
419
655
  document: Block[],
420
- options: { tolerant?: boolean } = {},
656
+ options: { tolerant?: boolean; repositoryId?: string } = {},
421
657
  ) {
422
658
  return selectionReferences(document, options).flatMap((ref) =>
423
659
  sourceAnchors(ref.source).map((source) => ({ ...ref, source })),
@@ -425,12 +661,16 @@ export function sourceReferences(
425
661
  }
426
662
 
427
663
  /** Includes lenses and maps without inline ranges. */
428
- export function hasCodeReferences(review: {
664
+ /** Whether a board reads code at its own pins: a commit link or a block
665
+ * with pins of its own names its commits itself. */
666
+ export function readsAtBoardPins(review: {
429
667
  document: Block[];
430
668
  lenses?: readonly Lens[];
431
669
  }): boolean {
432
670
  return Boolean(
433
- sourceReferences(review.document).length ||
671
+ sourceReferences(review.document, { tolerant: true }).some(
672
+ (reference) => !reference.source.pins,
673
+ ) ||
434
674
  review.lenses?.length ||
435
675
  resourceReferences(review.document).some(
436
676
  (block) => block.type === "software_map",
@@ -514,6 +754,8 @@ export interface EditSummary {
514
754
  units?: string[];
515
755
  /** The agent whose courier draws this edit, when the host could tell. */
516
756
  activityId?: string;
757
+ /** The Ask thread that made this edit, if any. */
758
+ threadId?: string;
517
759
  }
518
760
 
519
761
  /** A component an edit wrote, named so the author can address it. */
@@ -711,18 +953,33 @@ export function assignFreshIds(
711
953
  element: Element,
712
954
  allocate: (prefix: string) => string,
713
955
  ): void {
714
- if (element.id !== undefined)
715
- throw new ReviewInputError("IDs are assigned by the server.");
716
- element.id = allocate(idPrefix(element));
956
+ assignIds(element, allocate, true);
957
+ }
717
958
 
718
- for (const child of children(element)) assignFreshIds(child, allocate);
959
+ /** Give ids to the parts of an element tree that have none, keeping the
960
+ * rest: a person's save names the blocks it kept and leaves new ones bare. */
961
+ export function assignMissingIds(
962
+ element: Element,
963
+ allocate: (prefix: string) => string,
964
+ ): void {
965
+ assignIds(element, allocate, false);
966
+ }
719
967
 
968
+ function assignIds(
969
+ element: Element,
970
+ allocate: (prefix: string) => string,
971
+ fresh: boolean,
972
+ ): void {
720
973
  const assign = (item: { id?: string }, prefix: string) => {
721
- if (item.id !== undefined)
974
+ if (item.id === undefined) item.id = allocate(prefix);
975
+ else if (fresh)
722
976
  throw new ReviewInputError("IDs are assigned by the server.");
723
- item.id = allocate(prefix);
724
977
  };
725
978
 
979
+ assign(element, idPrefix(element));
980
+
981
+ for (const child of children(element)) assignIds(child, allocate, fresh);
982
+
726
983
  if (element.type === "call_stack_diff")
727
984
  for (const frame of [...element.base, ...element.head])
728
985
  assign(frame, "frame");
@@ -735,16 +992,10 @@ export function assignFreshIds(
735
992
  }
736
993
  }
737
994
 
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
995
  export function applyEdit(
744
996
  document: Block[],
745
997
  edit: Edit,
746
998
  allocate: (prefix: string) => string,
747
- options: ApplyEditOptions = {},
748
999
  ): Applied {
749
1000
  adoptFlowUnits(document, allocate);
750
1001
 
@@ -808,14 +1059,10 @@ export function applyEdit(
808
1059
  // Resolve the destination before detaching, then compute its final position.
809
1060
  if (from) from.splice(from.indexOf(element), 1);
810
1061
 
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
1062
  const index =
814
- afterId !== undefined
815
- ? siblings.findIndex((s) => s.id === afterId) + 1
816
- : !parent && !from && options.placement === "first"
817
- ? 0
818
- : siblings.length;
1063
+ afterId === undefined
1064
+ ? siblings.length
1065
+ : siblings.findIndex((s) => s.id === afterId) + 1;
819
1066
 
820
1067
  siblings.splice(index, 0, element);
821
1068
  };