@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,11 +1,23 @@
1
- import { type JsonObject, isJsonObject } from "@dev.fast/json";
1
+ import {
2
+ type JsonObject,
3
+ type JsonValue,
4
+ isJsonObject,
5
+ isStringValue,
6
+ } from "@dev.fast/json";
7
+ import { defaultBranch, resolveRepoContext } from "@dev.fast/local-vcs";
2
8
  import {
3
9
  REVIEW_CLIENT_HEADER,
4
10
  REVIEW_CLIENT_REMOTE,
11
+ type ReviewApiSummary,
12
+ type ReviewBoardPlace,
13
+ type ReviewProjectThreads,
5
14
  type ReviewRemoteLanguageEnvironment,
6
15
  type ReviewRemoteNavigatorAnswer,
7
16
  type ReviewStreamLine,
8
17
  type ReviewStructuralDiffEvent,
18
+ type ReviewThreadBoard,
19
+ boardIconKind,
20
+ boardTitle,
9
21
  } from "@dev.fast/review-protocol";
10
22
  import { errorMessage } from "@dev.fast/trace-core";
11
23
  import {
@@ -51,12 +63,13 @@ import { anchorQuotes } from "./anchor-quotes.js";
51
63
  import type { AskRecord } from "./ask-history.js";
52
64
  import { authoringTools } from "./authoring-tools.js";
53
65
  import { documentText } from "./document-text.js";
54
- import { ReviewInputError } from "./document.js";
55
66
  import {
56
- instructionsQuerySchema,
57
- renderInstructions,
58
- scratchpadAvailable,
59
- } from "./instructions.js";
67
+ ReviewInputError,
68
+ type ReviewTarget,
69
+ boardLinks,
70
+ boardRepositoryId,
71
+ } from "./document.js";
72
+ import { instructionsQuerySchema, renderInstructions } from "./instructions.js";
60
73
  import type { LocalReviewData } from "./local-data.js";
61
74
  import { registerOnboardingRoutes } from "./onboarding.js";
62
75
  import {
@@ -65,6 +78,8 @@ import {
65
78
  readQuerySchemas,
66
79
  } from "./read-schemas.js";
67
80
  import {
81
+ REVIEW_PERSON_HEADER,
82
+ REVIEW_THREAD_HEADER,
68
83
  type ReviewRequestVia,
69
84
  reviewRequestOrigin,
70
85
  } from "./request-origin.js";
@@ -77,8 +92,9 @@ import {
77
92
  uncategorizedReport,
78
93
  } from "./review-progress.js";
79
94
  import {
95
+ type Author,
96
+ type PlacedBoard,
80
97
  type ReviewStore,
81
- SCRATCHPAD_ID,
82
98
  type Snapshot,
83
99
  commandSchema,
84
100
  inspectSnapshot,
@@ -142,13 +158,8 @@ const askDecisionSchema = z.strictObject({
142
158
  export interface AuthoringCapabilities {
143
159
  desktopAvailable: boolean;
144
160
  softwareMapEnabled: boolean;
145
- /** Off, the host neither makes nor lists the scratchpad, and refuses its id. */
146
- scratchpadEnabled: boolean;
147
161
  }
148
162
 
149
- const SCRATCHPAD_DISABLED =
150
- "The scratchpad is off. Turn it on in Whiteboard Desktop Settings.";
151
-
152
163
  /**
153
164
  * What the host reports about reviews, for telemetry. `onReviewCreated` fires
154
165
  * again for a replayed create command; consumers dedupe by review id.
@@ -156,7 +167,6 @@ const SCRATCHPAD_DISABLED =
156
167
  export interface ReviewApiHooks {
157
168
  onReviewCreated?: (event: {
158
169
  reviewId: string;
159
- kind: "review" | "scratchpad";
160
170
  blocks: number;
161
171
  via: ReviewRequestVia;
162
172
  agentKind?: ReviewSessionAgent;
@@ -164,6 +174,14 @@ export interface ReviewApiHooks {
164
174
  sharing?: SharingHostEvents;
165
175
  }
166
176
 
177
+ /** A person's save carries the whole board, which edits one at a time can
178
+ * grow past the size of a single command. */
179
+ const PERSON_EDIT_MAX_BYTES = 16 * 1024 * 1024;
180
+
181
+ /** A request the gateway forwarded from another machine. */
182
+ const remoteCaller = (context: Context) =>
183
+ context.req.header(REVIEW_CLIENT_HEADER) === REVIEW_CLIENT_REMOTE;
184
+
167
185
  /** Both hosts mount this behind their token authentication. */
168
186
  export function createReviewApi(
169
187
  store: ReviewStore,
@@ -174,14 +192,11 @@ export function createReviewApi(
174
192
  }) => Promise<{ softwareMapEnabled: boolean }>,
175
193
  shared?: SharedReviewStore,
176
194
  capabilities: () =>
177
- | Omit<AuthoringCapabilities, "scratchpadEnabled">
178
- | Promise<Omit<AuthoringCapabilities, "scratchpadEnabled">> = () => ({
195
+ | AuthoringCapabilities
196
+ | Promise<AuthoringCapabilities> = () => ({
179
197
  desktopAvailable: Boolean(open),
180
198
  softwareMapEnabled: false,
181
199
  }),
182
- // Synchronous because the catalog is read inside watch callbacks. The host
183
- // keeps it current from its preferences file.
184
- scratchpadEnabled: () => boolean = () => false,
185
200
  // Read per request: capture can change from outside this server.
186
201
  traceEnabled: () => Promise<boolean> = async () => false,
187
202
  /** Which server this is, for whiteboard_status. */
@@ -252,23 +267,7 @@ export function createReviewApi(
252
267
  operation: z.object({ reviewId: z.string().optional() }),
253
268
  });
254
269
 
255
- // The host that can show the scratchpad keeps it: Desktop, while the
256
- // preference is on. Headless servers never make one, and a pad made earlier
257
- // stays in the store while it is off.
258
- const ensureScratchpad = async (id?: string) => {
259
- if (open && scratchpadEnabled() && (!id || id === SCRATCHPAD_ID))
260
- await store.ensureScratchpad();
261
- };
262
-
263
- const refuseDisabledScratchpad = (id?: string) => {
264
- if (id === SCRATCHPAD_ID && !scratchpadEnabled())
265
- throw new ReviewInputError(SCRATCHPAD_DISABLED, 409);
266
- };
267
-
268
270
  const sharedGuard: MiddlewareHandler = async (context, next) => {
269
- refuseDisabledScratchpad(context.req.param("id"));
270
- await ensureScratchpad(context.req.param("id"));
271
-
272
271
  const id = context.req.param("id");
273
272
 
274
273
  if (!id || !isShared(id)) return next();
@@ -300,22 +299,68 @@ export function createReviewApi(
300
299
  return snapshot;
301
300
  };
302
301
 
303
- const catalog = () => {
304
- const local = store.list();
302
+ /** Adds a PR board's state, as this host last read it from GitHub. */
303
+ const withPullRequestState = <
304
+ Board extends {
305
+ pullRequestUrl?: string;
306
+ origin?: { pullRequestUrl?: string };
307
+ },
308
+ >(
309
+ board: Board,
310
+ ) => {
311
+ const state = data?.pullRequestState(
312
+ board.origin?.pullRequestUrl ?? board.pullRequestUrl,
313
+ );
305
314
 
306
- return [
307
- ...(scratchpadEnabled()
308
- ? local
309
- : local.filter((summary) => summary.kind !== "scratchpad")),
310
- ...(shared?.list() ?? []),
311
- ];
315
+ return state ? { ...board, pullRequestState: state } : board;
312
316
  };
313
317
 
314
- app.get("/", async (context) => {
315
- await ensureScratchpad();
318
+ const catalog = () => [
319
+ ...store.list().map(withPullRequestState),
320
+ ...(shared?.list() ?? []),
321
+ ];
316
322
 
317
- return context.json(catalog());
318
- });
323
+ /** Where a board sits among its project's, and its PR's facts, for the
324
+ * board's header. */
325
+ const placeOf = (id: string): ReviewBoardPlace => {
326
+ if (isShared(id) || !store.has(id)) return { siblings: [] };
327
+
328
+ const { board, parent, siblings, children } = store.place(id);
329
+ const url = board.origin?.pullRequestUrl;
330
+ const state = data?.pullRequestState(url);
331
+ const baseBranch = url && data?.pullRequestBaseBranch(url);
332
+ const headBranch = url && data?.pullRequestHeadBranch(url);
333
+
334
+ const place = ({ reviewId, title, target }: PlacedBoard) => ({
335
+ reviewId,
336
+ title,
337
+ icon: boardIconKind(target),
338
+ });
339
+
340
+ const listed = (child: PlacedBoard) => {
341
+ const childState = data?.pullRequestState(child.origin?.pullRequestUrl);
342
+
343
+ return {
344
+ ...place(child),
345
+ ...(child.origin?.pullRequestNumber && {
346
+ pullRequestNumber: child.origin.pullRequestNumber,
347
+ }),
348
+ ...(childState && { pullRequestState: childState }),
349
+ };
350
+ };
351
+
352
+ return {
353
+ ...(state && { pullRequestState: state }),
354
+ ...(baseBranch && { baseBranch }),
355
+ ...(headBranch && { headBranch }),
356
+ board: place(board),
357
+ ...(parent && { parent: place(parent) }),
358
+ siblings: siblings.map(listed),
359
+ ...(children?.length && { children: children.map(listed) }),
360
+ };
361
+ };
362
+
363
+ app.get("/", (context) => context.json(catalog()));
319
364
 
320
365
  app.get("/feedback-eligibility", (context) =>
321
366
  context.json({ eligible: store.feedbackEligible() }),
@@ -324,20 +369,13 @@ export function createReviewApi(
324
369
  // Server-owned state only: asking the Desktop canvas would let a stalled
325
370
  // renderer block tool listing and the first instructions call.
326
371
  const instructionContext = async () => ({
327
- desktopAvailable: Boolean(open),
328
- scratchpadEnabled: scratchpadEnabled(),
329
372
  traceEnabled: await traceEnabled(),
330
373
  });
331
374
 
332
375
  app.get("/authoring", async (context) => {
333
376
  const instructions = await instructionContext();
334
377
 
335
- return context.json(
336
- authoringTools(
337
- scratchpadAvailable(instructions),
338
- instructions.traceEnabled,
339
- ),
340
- );
378
+ return context.json(authoringTools(instructions.traceEnabled));
341
379
  });
342
380
  app.get("/instructions", async (context) => {
343
381
  const { topic } = instructionsQuerySchema.parse(context.req.query());
@@ -468,10 +506,7 @@ export function createReviewApi(
468
506
  );
469
507
 
470
508
  app.get("/capabilities", async (context) =>
471
- context.json({
472
- ...(await capabilities()),
473
- scratchpadEnabled: scratchpadEnabled(),
474
- }),
509
+ context.json(await capabilities()),
475
510
  );
476
511
 
477
512
  app.get("/:id/activity", (context) => {
@@ -503,6 +538,27 @@ export function createReviewApi(
503
538
  );
504
539
  },
505
540
  );
541
+ // The person's canvas, never an agent tool: a save of the whole board,
542
+ // the block in focus, and discarding a board left untouched.
543
+ app.post("/:id/person-edit", async (context) =>
544
+ context.json(
545
+ await store.personEdit(
546
+ context.req.param("id"),
547
+ await readBoundedRequestJson(context.req.raw, PERSON_EDIT_MAX_BYTES),
548
+ ),
549
+ ),
550
+ );
551
+ app.post("/:id/focus", async (context) =>
552
+ context.json(
553
+ store.focus(
554
+ context.req.param("id"),
555
+ await readBoundedRequestJson(context.req.raw),
556
+ ),
557
+ ),
558
+ );
559
+ app.post("/:id/discard", async (context) =>
560
+ context.json(await store.discard(context.req.param("id"))),
561
+ );
506
562
  app.get("/watch", (context) => {
507
563
  let input: unknown;
508
564
 
@@ -530,6 +586,36 @@ export function createReviewApi(
530
586
  let dirtyList = lists;
531
587
  const dirtyReviews = new Set(reviewIds);
532
588
  let sentList: string | undefined;
589
+ // Bumped when a board a review links to changes, so its links reload.
590
+ const linksRevision = new Map<string, number>();
591
+
592
+ // The PR states its links show: only a change to one of them sends the
593
+ // review again. GitHub answers for every PR on launch, a few at a time.
594
+ const linksKey = (id: string) => {
595
+ if (!data || isShared(id) || !store.has(id)) return "";
596
+ const { outgoing, backlinks } = store.links(id);
597
+
598
+ return JSON.stringify(
599
+ [...outgoing, ...backlinks].map(
600
+ (link) =>
601
+ link.pullRequestUrl &&
602
+ data.pullRequestState(link.pullRequestUrl, { seen: false }),
603
+ ),
604
+ );
605
+ };
606
+
607
+ const linksKeys = new Map(
608
+ [...reviewIds].map((reviewId) => [reviewId, linksKey(reviewId)] as const),
609
+ );
610
+
611
+ // Bumped when a review's parent or siblings change, so its header
612
+ // reloads them.
613
+ const placeRevision = new Map<string, number>();
614
+ const placeKey = (id: string) => JSON.stringify(placeOf(id));
615
+
616
+ const placeKeys = new Map(
617
+ [...reviewIds].map((reviewId) => [reviewId, placeKey(reviewId)] as const),
618
+ );
533
619
 
534
620
  const readReviewLine = (reviewId: string): ReviewStreamLine<Snapshot> => {
535
621
  try {
@@ -540,6 +626,8 @@ export function createReviewApi(
540
626
  ...readReview(reviewId),
541
627
  activity: store.activity.read(reviewId),
542
628
  coverageRevision: data?.coverageRevision ?? 0,
629
+ linksRevision: linksRevision.get(reviewId) ?? 0,
630
+ placeRevision: placeRevision.get(reviewId) ?? 0,
543
631
  },
544
632
  };
545
633
  } catch (error) {
@@ -584,6 +672,22 @@ export function createReviewApi(
584
672
  return true;
585
673
  };
586
674
 
675
+ /** Bumps the place of each board whose place changed. */
676
+ const refreshPlaces = () => {
677
+ let marked = false;
678
+
679
+ for (const [id, key] of placeKeys) {
680
+ const next = placeKey(id);
681
+
682
+ if (next === key) continue;
683
+ placeKeys.set(id, next);
684
+ placeRevision.set(id, (placeRevision.get(id) ?? 0) + 1);
685
+ marked = markReview(id) || marked;
686
+ }
687
+
688
+ return marked;
689
+ };
690
+
587
691
  const markLists = () => {
588
692
  dirtyList ||= lists;
589
693
 
@@ -598,7 +702,14 @@ export function createReviewApi(
598
702
  notify();
599
703
  }) ?? (() => {}),
600
704
  store.subscribe((result) => {
601
- if (markReview(result.reviewId)) notify();
705
+ let marked = markReview(result.reviewId);
706
+
707
+ for (const from of store.linkingBoards(result.reviewId)) {
708
+ linksRevision.set(from, (linksRevision.get(from) ?? 0) + 1);
709
+ marked = markReview(from) || marked;
710
+ }
711
+
712
+ if (marked) notify();
602
713
  }),
603
714
  store.activity.subscribe((id) => {
604
715
  if (markReview(id)) notify();
@@ -606,11 +717,31 @@ export function createReviewApi(
606
717
  store.activity.subscribeWorking(() => {
607
718
  if (markLists()) notify();
608
719
  }),
720
+ // A PR's state shows on its board in the list, on links to it, and
721
+ // with its branches in its board's header.
722
+ data?.subscribePullRequestStates(() => {
723
+ let marked = markLists();
724
+
725
+ marked = refreshPlaces() || marked;
726
+
727
+ for (const reviewId of reviewIds) {
728
+ const next = linksKey(reviewId);
729
+
730
+ if (next === linksKeys.get(reviewId)) continue;
731
+ linksKeys.set(reviewId, next);
732
+ linksRevision.set(reviewId, (linksRevision.get(reviewId) ?? 0) + 1);
733
+ marked = markReview(reviewId) || marked;
734
+ }
735
+
736
+ if (marked) notify();
737
+ }) ?? (() => {}),
609
738
  shared?.subscribe(() => {
610
739
  if (markLists()) notify();
611
740
  }) ?? (() => {}),
612
741
  store.subscribeCatalog(() => {
613
- if (markLists()) notify();
742
+ const marked = refreshPlaces();
743
+
744
+ if (markLists() || marked) notify();
614
745
  }),
615
746
  ];
616
747
 
@@ -684,21 +815,15 @@ export function createReviewApi(
684
815
  });
685
816
 
686
817
  // Traces are stored beside the review's own repository.
687
- const tracePins = (id: string, version?: number) => {
688
- const { pins } = readReview(id, version);
689
-
690
- if (!pins)
691
- throw new ReviewInputError(
692
- "This document has no source pins of its own.",
693
- 409,
694
- );
818
+ const tracePins = async (id: string, version?: number) => {
819
+ const snapshot = readReview(id, version);
695
820
 
696
- return pins;
821
+ return snapshot.pins ?? (await data.documentPins(snapshot));
697
822
  };
698
823
 
699
824
  app.get("/:id/agent-traces", async (context) => {
700
825
  const query = traceQuery.parse(context.req.query());
701
- const pins = tracePins(context.req.param("id"), query.version);
826
+ const pins = await tracePins(context.req.param("id"), query.version);
702
827
 
703
828
  return context.json(
704
829
  await listPinnedTraces(
@@ -710,7 +835,7 @@ export function createReviewApi(
710
835
  });
711
836
  app.get("/:id/agent-traces/:sessionId", async (context) => {
712
837
  const query = traceQuery.parse(context.req.query());
713
- const pins = tracePins(context.req.param("id"), query.version);
838
+ const pins = await tracePins(context.req.param("id"), query.version);
714
839
 
715
840
  const result = await readStoredTrace(
716
841
  store.repositoryPath(pins.repositoryId),
@@ -789,13 +914,27 @@ export function createReviewApi(
789
914
 
790
915
  return context.json(await data!.register(input.path));
791
916
  });
792
- app.post("/resources", async (context) =>
793
- context.json(
794
- await data!.upload(
795
- await readBoundedRequestJson(context.req.raw, 8 * 1024 * 1024),
796
- ),
797
- ),
798
- );
917
+ app.post("/resources", async (context) => {
918
+ const body = await readBoundedRequestJson(
919
+ context.req.raw,
920
+ 8 * 1024 * 1024,
921
+ );
922
+
923
+ // Agents send a checkout path; swap it for a repository id.
924
+ const named = z
925
+ .looseObject({ repositoryPath: z.string().min(1) })
926
+ .safeParse(body);
927
+
928
+ if (!named.success) return context.json(await data!.upload(body));
929
+ const { repositoryPath, ...upload } = named.data;
930
+
931
+ return context.json(
932
+ await data!.upload({
933
+ ...upload,
934
+ repositoryId: (await data!.register(repositoryPath)).id,
935
+ }),
936
+ );
937
+ });
799
938
  app.get("/:id/resources/:resourceId", async (context) => {
800
939
  const id = context.req.param("id");
801
940
  const snapshot = readReview(id);
@@ -996,6 +1135,20 @@ export function createReviewApi(
996
1135
 
997
1136
  return context.json(await data.changes(pins));
998
1137
  });
1138
+ // A commit a quote is read at, in the board's repository.
1139
+ app.get("/:id/commit/:commit", async (context) => {
1140
+ const repositoryId = boardRepositoryId(
1141
+ readReview(context.req.param("id")),
1142
+ );
1143
+
1144
+ if (!repositoryId)
1145
+ throw new ReviewInputError("This board has no repository.", 404);
1146
+
1147
+ return context.json(
1148
+ await data.commitInfo(repositoryId, context.req.param("commit")),
1149
+ );
1150
+ });
1151
+
999
1152
  app.get("/:id/commits", async (context) => {
1000
1153
  const input = readQuerySchemas.commits.parse(context.req.query());
1001
1154
 
@@ -1074,6 +1227,9 @@ export function createReviewApi(
1074
1227
  .join("\n");
1075
1228
  }
1076
1229
 
1230
+ if (target.kind === "block")
1231
+ excerpt = documentText(snapshot, target.blockId, true);
1232
+
1077
1233
  const diff = selection.selectedDiff;
1078
1234
 
1079
1235
  const text = selectionMarkdown(
@@ -1085,7 +1241,7 @@ export function createReviewApi(
1085
1241
  );
1086
1242
 
1087
1243
  return [
1088
- `Whiteboard: ${snapshot.title}`,
1244
+ `Whiteboard: ${boardTitle(snapshot.title)}`,
1089
1245
  `Session ID: ${snapshot.reviewId}`,
1090
1246
  `Version: ${snapshot.version}`,
1091
1247
  ...(selection.apiSource?.commit
@@ -1093,7 +1249,6 @@ export function createReviewApi(
1093
1249
  : []),
1094
1250
  ...(selection.apiSource?.pins
1095
1251
  ? [
1096
- `Selected repository ID: ${selection.apiSource.pins.repositoryId}`,
1097
1252
  ...(selection.apiSource.pins.base
1098
1253
  ? [`Selected base: ${selection.apiSource.pins.base}`]
1099
1254
  : []),
@@ -1102,7 +1257,6 @@ export function createReviewApi(
1102
1257
  : []),
1103
1258
  ...(snapshot.pins
1104
1259
  ? [
1105
- `Repository ID: ${snapshot.pins.repositoryId}`,
1106
1260
  `Session base: ${snapshot.pins.base}`,
1107
1261
  `Session head: ${snapshot.pins.head}`,
1108
1262
  ]
@@ -1116,10 +1270,15 @@ export function createReviewApi(
1116
1270
  }
1117
1271
 
1118
1272
  if (ask && data) {
1273
+ const projectOf = (reviewId: string) =>
1274
+ isShared(reviewId) || !store.has(reviewId)
1275
+ ? undefined
1276
+ : store.projectOf(reviewId);
1277
+
1119
1278
  const readThread = (reviewId: string, threadId: string) => {
1120
1279
  const thread = ask.threads.get(threadId);
1121
1280
 
1122
- if (!thread || thread.reviewId !== reviewId)
1281
+ if (!thread)
1123
1282
  throw new ReviewInputError("This conversation has ended.", 404);
1124
1283
 
1125
1284
  return thread;
@@ -1198,7 +1357,7 @@ export function createReviewApi(
1198
1357
  const { reviewId } = snapshot;
1199
1358
 
1200
1359
  return [
1201
- `A reviewer is reading "${snapshot.title}" in Whiteboard and has a question about ${selection.target.kind === "review" ? "it" : "a selection"}.`,
1360
+ `A reviewer is reading "${boardTitle(snapshot.title)}" in Whiteboard and has a question about ${selection.target.kind === "review" ? "it" : "a selection"}.`,
1202
1361
  checkout.live
1203
1362
  ? "Your working directory is the repository the review describes."
1204
1363
  : `Your working directory is a checkout of the review's head commit, ${checkout.head}. Answer from this code, not from other branches.`,
@@ -1326,6 +1485,9 @@ export function createReviewApi(
1326
1485
 
1327
1486
  assertWritable(reviewId);
1328
1487
 
1488
+ // Asked on screen: seen from the start.
1489
+ store.askHistory.markViewed(id, createdAt);
1490
+
1329
1491
  const thread = ask.threads.open({
1330
1492
  id,
1331
1493
  reviewId,
@@ -1334,7 +1496,10 @@ export function createReviewApi(
1334
1496
  // replaces a session the agent could not reopen.
1335
1497
  ...historyHooks({
1336
1498
  id,
1337
- reviewId,
1499
+ anchor:
1500
+ target.kind === "text" && target.anchor
1501
+ ? { board: reviewId, block: target.anchor.blockId }
1502
+ : { board: reviewId },
1338
1503
  agent: input.agent,
1339
1504
  version: snapshot.version,
1340
1505
  head: checkout.head,
@@ -1360,12 +1525,129 @@ export function createReviewApi(
1360
1525
  return context.json({ threadId: thread.id });
1361
1526
  });
1362
1527
 
1528
+ /** Every project's threads for the sidebar: who runs them, where they
1529
+ * started and live, and which boards they changed. */
1530
+ const projectThreads = (reviewId: string): ReviewProjectThreads => {
1531
+ const entries = store.askHistory.listAll();
1532
+
1533
+ const ids = entries.map((entry) => entry.id);
1534
+ const edited = store.boardsChangedByThreads(ids);
1535
+
1536
+ const boards = new Map<string, ReviewThreadBoard>();
1537
+
1538
+ const board = (id: string) => {
1539
+ let found = boards.get(id);
1540
+
1541
+ if (!found) {
1542
+ found = store.has(id)
1543
+ ? store.boardFace(id)
1544
+ : { reviewId: id, icon: boardIconKind(undefined) };
1545
+ boards.set(id, found);
1546
+ }
1547
+
1548
+ return found;
1549
+ };
1550
+
1551
+ const project = projectOf(reviewId);
1552
+
1553
+ return {
1554
+ board: reviewId,
1555
+ ...(project && { project }),
1556
+ threads: entries.map((entry) => {
1557
+ const anchor = entry.anchor ?? { board: reviewId };
1558
+ const status = ask.threads.get(entry.id)?.status;
1559
+ const { viewedAt } = entry;
1560
+
1561
+ // A turn finished since the reviewer last saw the thread.
1562
+ const unread =
1563
+ status !== "starting" &&
1564
+ status !== "running" &&
1565
+ status !== "waiting" &&
1566
+ viewedAt !== undefined &&
1567
+ entry.updatedAt > viewedAt;
1568
+
1569
+ return {
1570
+ id: entry.id,
1571
+ ...(entry.projectId && { projectId: entry.projectId }),
1572
+ ...(store.has(anchor.board) && {
1573
+ place: store.boardPlace(anchor.board),
1574
+ }),
1575
+ agent: entry.agent,
1576
+ agentName: askAgents[entry.agent].name,
1577
+ opening: entry.question ?? entry.title,
1578
+ pinned:
1579
+ entry.selection.target.kind === "text" &&
1580
+ entry.selection.target.anchor !== undefined,
1581
+ ...(status && { status }),
1582
+ ...(unread && { unread }),
1583
+ updatedAt: entry.updatedAt,
1584
+ anchor: {
1585
+ board: board(anchor.board),
1586
+ ...(anchor.block && { block: anchor.block }),
1587
+ },
1588
+ edited: edited.get(entry.id) ?? [],
1589
+ };
1590
+ }),
1591
+ };
1592
+ };
1593
+
1363
1594
  app.get("/:id/ask/threads", (context) => {
1364
1595
  const reviewId = context.req.param("id");
1365
1596
 
1366
1597
  readReview(reviewId);
1367
1598
 
1368
- return context.json({ threads: store.askHistory.list(reviewId) });
1599
+ if (context.req.query("watch") !== undefined) {
1600
+ // Sent again only when it reads differently.
1601
+ let sent = "";
1602
+
1603
+ return watch(
1604
+ () => {
1605
+ const line = JSON.stringify(projectThreads(reviewId));
1606
+
1607
+ if (line === sent) return [];
1608
+ sent = line;
1609
+
1610
+ return [line];
1611
+ },
1612
+ (notify) => {
1613
+ let timer: ReturnType<typeof setTimeout> | undefined;
1614
+
1615
+ const later = () => {
1616
+ timer ??= setTimeout(() => {
1617
+ timer = undefined;
1618
+ notify();
1619
+ }, 100);
1620
+ };
1621
+
1622
+ const stops = [
1623
+ store.askHistory.subscribe(later),
1624
+ ask.threads.subscribeStatus(later),
1625
+ // Another process's version may have changed any of these.
1626
+ store.subscribe((change) => {
1627
+ if (
1628
+ change.deleted ||
1629
+ change.lifecycle ||
1630
+ change.titleChanged ||
1631
+ change.placeChanged ||
1632
+ change.threadId ||
1633
+ change.external
1634
+ )
1635
+ later();
1636
+ }),
1637
+ ];
1638
+
1639
+ return () => {
1640
+ clearTimeout(timer);
1641
+ stops.forEach((stop) => stop());
1642
+ };
1643
+ },
1644
+ );
1645
+ }
1646
+
1647
+ return context.json({
1648
+ threads: store.askHistory.list(reviewId, projectOf(reviewId)),
1649
+ board: reviewId,
1650
+ });
1369
1651
  });
1370
1652
 
1371
1653
  // The threads of every open Ask in a review, over one connection. Before
@@ -1384,20 +1666,32 @@ export function createReviewApi(
1384
1666
  threads.map((threadId) => {
1385
1667
  const thread = ask.threads.get(threadId);
1386
1668
 
1387
- return [
1388
- threadId,
1389
- thread?.reviewId === reviewId ? thread : undefined,
1390
- ];
1669
+ return [threadId, thread];
1391
1670
  }),
1392
1671
  ),
1393
1672
  );
1394
1673
  });
1395
1674
 
1675
+ // The boards a thread changed: titles from any project, so only for a
1676
+ // local caller.
1677
+ app.get("/:id/ask/:threadId/boards", (context) => {
1678
+ const threadId = context.req.param("threadId");
1679
+ const record = store.askHistory.get(threadId);
1680
+
1681
+ const anchor =
1682
+ record?.anchor.board ?? ask.threads.get(threadId)?.reviewId;
1683
+
1684
+ if (remoteCaller(context) || !anchor)
1685
+ throw new ReviewInputError("This conversation was not found.", 404);
1686
+
1687
+ return context.json({ boards: store.boardsChangedBy(threadId) });
1688
+ });
1689
+
1396
1690
  // A saved conversation, without starting its agent.
1397
1691
  app.get("/:id/ask/:threadId", (context) => {
1398
1692
  const record = store.askHistory.get(context.req.param("threadId"));
1399
1693
 
1400
- if (record?.reviewId !== context.req.param("id"))
1694
+ if (!record)
1401
1695
  throw new ReviewInputError("This conversation was not found.", 404);
1402
1696
  const target = record.selection.target;
1403
1697
 
@@ -1429,17 +1723,19 @@ export function createReviewApi(
1429
1723
  const threadId = context.req.param("threadId");
1430
1724
  const live = ask.threads.get(threadId);
1431
1725
 
1432
- if (live?.reviewId === reviewId) return context.json({ threadId });
1726
+ if (live) return context.json({ threadId });
1433
1727
  const record = store.askHistory.get(threadId);
1434
1728
 
1435
1729
  const { picks } = askOpenSchema.parse(
1436
1730
  await readBoundedRequestJson(context.req.raw, undefined, {}),
1437
1731
  );
1438
1732
 
1439
- if (record?.reviewId !== reviewId)
1733
+ if (!record)
1440
1734
  throw new ReviewInputError("This conversation was not found.", 404);
1441
1735
 
1442
- const snapshot = readReview(reviewId, record.version);
1736
+ // The thread runs in the checkout of the board it started on.
1737
+ const anchor = record.anchor.board;
1738
+ const snapshot = readReview(anchor, record.version);
1443
1739
  const checkout = await data.agentCheckout(snapshot);
1444
1740
  const target = record.selection.target;
1445
1741
 
@@ -1455,7 +1751,7 @@ export function createReviewApi(
1455
1751
  assertWritable(reviewId);
1456
1752
  ask.threads.open({
1457
1753
  id: record.id,
1458
- reviewId,
1754
+ reviewId: anchor,
1459
1755
  agent: record.agent,
1460
1756
  cwd: checkout.rootPath,
1461
1757
  head: checkout.head,
@@ -1647,6 +1943,16 @@ export function createReviewApi(
1647
1943
  },
1648
1944
  );
1649
1945
 
1946
+ app.post("/:id/ask/:threadId/viewed", (context) => {
1947
+ const threadId = context.req.param("threadId");
1948
+
1949
+ if (!ask.threads.get(threadId) && !store.askHistory.get(threadId))
1950
+ throw new ReviewInputError("This conversation was not found.", 404);
1951
+ store.askHistory.markViewed(threadId);
1952
+
1953
+ return context.json({ ok: true });
1954
+ });
1955
+
1650
1956
  // Closing an Ask ends its agent; the conversation stays saved. A tab
1651
1957
  // switched away from or a reload leaves it running, to come back to.
1652
1958
  app.post("/:id/ask/:threadId/close", reviewAccess("read"), (context) => {
@@ -1664,17 +1970,29 @@ export function createReviewApi(
1664
1970
  const reviewId = context.req.param("id");
1665
1971
  const threadId = context.req.param("threadId");
1666
1972
 
1667
- if (store.askHistory.get(threadId)?.reviewId !== reviewId)
1973
+ const record = store.askHistory.get(threadId);
1974
+
1975
+ if (!record)
1668
1976
  throw new ReviewInputError("This conversation was not found.", 404);
1669
1977
 
1670
- if (ask.threads.get(threadId)?.reviewId === reviewId)
1671
- ask.threads.close(threadId);
1978
+ if (ask.threads.get(threadId)) ask.threads.close(threadId);
1672
1979
  store.askHistory.delete(threadId);
1673
1980
 
1674
1981
  return context.json({ ok: true });
1675
1982
  });
1676
1983
  }
1677
1984
 
1985
+ app.get("/:id/place", (context) => {
1986
+ const id = context.req.param("id");
1987
+
1988
+ if (!isShared(id)) store.assertExists(id);
1989
+
1990
+ return context.json(placeOf(id));
1991
+ });
1992
+
1993
+ // The board's branch stack: its GitHub stack, else the reviews chained
1994
+ // through the bases they name, bottom first. Apart from its place among
1995
+ // boards.
1678
1996
  app.get("/:id/stack", async (context) => {
1679
1997
  const query = readQuerySchemas.get.parse(context.req.query());
1680
1998
  const id = context.req.param("id");
@@ -1682,14 +2000,93 @@ export function createReviewApi(
1682
2000
 
1683
2001
  if (isShared(id) || !snapshot.pins) return context.json({ layers: [] });
1684
2002
 
2003
+ const repositoryPath = store
2004
+ .repositories()
2005
+ .find(
2006
+ (repository) => repository.id === snapshot.pins!.repositoryId,
2007
+ )?.path;
2008
+
2009
+ // A GitHub stack's boards may sit in any project.
1685
2010
  const layers = await resolveReviewStackLayers(snapshot, store.list(), {
1686
- repositoryPath: store
1687
- .repositories()
1688
- .find((repository) => repository.id === snapshot.pins!.repositoryId)
1689
- ?.path,
2011
+ repositoryPath,
2012
+ });
2013
+
2014
+ // The branch the stack sits on: the repository's default branch.
2015
+ const trunk =
2016
+ layers.length > 1 && repositoryPath
2017
+ ? await defaultBranch(repositoryPath).catch(() => null)
2018
+ : null;
2019
+
2020
+ return context.json({
2021
+ layers: layers.map((layer) => {
2022
+ const state = data?.pullRequestState(layer.pullRequestUrl ?? undefined);
2023
+
2024
+ return state ? { ...layer, pullRequestState: state } : layer;
2025
+ }),
2026
+ ...(trunk && { base: trunk.ref.replace(/^origin\//, "") }),
2027
+ });
2028
+ });
2029
+
2030
+ // The board's project and its whiteboards, for the board's sidebar.
2031
+ app.get("/:id/project", (context) => {
2032
+ const id = context.req.param("id");
2033
+
2034
+ if (isShared(id)) return context.json({ project: null, boards: [] });
2035
+
2036
+ const project = store.summary(id)?.project ?? null;
2037
+
2038
+ return context.json({
2039
+ project,
2040
+ boards: project
2041
+ ? store
2042
+ .listProject(project.id)
2043
+ .filter((board) => !board.trashedAt)
2044
+ .map(withPullRequestState)
2045
+ : [],
1690
2046
  });
2047
+ });
2048
+ app.get("/:id/links", (context) => {
2049
+ const id = context.req.param("id");
2050
+
2051
+ // A shared board's links name boards on the sender's machine: shown,
2052
+ // but not followed.
2053
+ if (isShared(id))
2054
+ return context.json({
2055
+ outgoing: boardLinks(id, readReview(id).document).map(
2056
+ ({ from, to }) => ({ from, to, missing: true, elsewhere: true }),
2057
+ ),
2058
+ backlinks: [],
2059
+ });
2060
+
2061
+ const { version } = readQuerySchemas.get
2062
+ .pick({ version: true })
2063
+ .parse({ version: context.req.query("version") });
2064
+
2065
+ const { outgoing, backlinks } = store.links(id, version);
1691
2066
 
1692
- return context.json({ layers });
2067
+ return context.json({
2068
+ outgoing: outgoing.map(withPullRequestState),
2069
+ backlinks: backlinks.map(withPullRequestState),
2070
+ });
2071
+ });
2072
+ // A link's hover card, read when it opens.
2073
+ app.get("/:id/links/peek", (context) => {
2074
+ const id = context.req.param("id");
2075
+
2076
+ if (isShared(id)) return context.json({});
2077
+
2078
+ const { version } = readQuerySchemas.get
2079
+ .pick({ version: true })
2080
+ .parse({ version: context.req.query("version") });
2081
+
2082
+ const board = context.req.query("board");
2083
+ const block = context.req.query("block");
2084
+
2085
+ if (!board) throw new ReviewInputError("Name the linked board.", 400);
2086
+
2087
+ return context.json(
2088
+ store.linkPeek(id, { board, ...(block && { block }) }, version),
2089
+ );
1693
2090
  });
1694
2091
 
1695
2092
  app.get("/:id/history", (context) => {
@@ -1703,9 +2100,44 @@ export function createReviewApi(
1703
2100
  version: snapshot.version,
1704
2101
  title: snapshot.title,
1705
2102
  createdAt: snapshot.createdAt,
2103
+ author: "agent",
1706
2104
  },
1707
2105
  ]);
1708
2106
  });
2107
+
2108
+ /** A board's relations as session_list reports them: only those it has. */
2109
+ const listedRelations = ({
2110
+ parentId,
2111
+ children,
2112
+ dependsOn,
2113
+ }: ReturnType<typeof store.relations>) => {
2114
+ const listed: Partial<ReturnType<typeof store.relations>> = {};
2115
+
2116
+ if (parentId) listed.parentId = parentId;
2117
+
2118
+ if (children.length) listed.children = children;
2119
+
2120
+ if (dependsOn.length) listed.dependsOn = dependsOn;
2121
+
2122
+ return listed;
2123
+ };
2124
+
2125
+ /** A board's relations, each board named by its title. */
2126
+ const relatedBoards = (id: string) => {
2127
+ const { parentId, children, dependsOn } = store.relations(id);
2128
+
2129
+ const named = (board: string) => ({
2130
+ id: board,
2131
+ title: store.read(board).title,
2132
+ });
2133
+
2134
+ return {
2135
+ ...(parentId && { parent: named(parentId) }),
2136
+ children: children.map(named),
2137
+ dependsOn: dependsOn.map(named),
2138
+ };
2139
+ };
2140
+
1709
2141
  app.get("/:id/inspect", (context) => {
1710
2142
  const query = inspectQuerySchema.parse(context.req.query());
1711
2143
  const id = context.req.param("id");
@@ -1713,12 +2145,19 @@ export function createReviewApi(
1713
2145
 
1714
2146
  return context.json(
1715
2147
  query.format === "text"
1716
- ? documentText(snapshot, query.targetId, Boolean(query.full))
2148
+ ? documentText(
2149
+ snapshot,
2150
+ query.targetId,
2151
+ Boolean(query.full),
2152
+ isShared(id) ? undefined : relatedBoards(id),
2153
+ )
1717
2154
  : query.targetId !== undefined
1718
2155
  ? inspectSnapshot(snapshot, query.targetId)
1719
- : query.full
1720
- ? snapshot
1721
- : inspectSnapshot(snapshot),
2156
+ : {
2157
+ ...(query.full ? snapshot : inspectSnapshot(snapshot)),
2158
+ // Its place among the project's boards, as session_list has it.
2159
+ ...(!isShared(id) && listedRelations(store.relations(id))),
2160
+ },
1722
2161
  );
1723
2162
  });
1724
2163
  app.get("/:id", async (context) => {
@@ -1778,27 +2217,32 @@ export function createReviewApi(
1778
2217
  await readBoundedRequestJson(context.req.raw),
1779
2218
  );
1780
2219
 
1781
- const request = await locateRepositories(body, (path) => {
1782
- if (!data) throw new ReviewInputError("Repositories are unavailable.");
2220
+ // Without local data (a bare store), a path is recorded as it is.
2221
+ const register = async (path: string) =>
2222
+ data ? data.register(path) : store.registerRepository(path);
1783
2223
 
1784
- return data.register(path);
1785
- });
2224
+ const request = withBoardRepository(
2225
+ await locateRepositories(
2226
+ await pullRequestTargets(
2227
+ body,
2228
+ register,
2229
+ (url, repositoryId) => {
2230
+ if (!data)
2231
+ throw new ReviewInputError("Repositories are unavailable.");
2232
+
2233
+ return data.resolvePullRequest(url, { id: repositoryId });
2234
+ },
2235
+ (id) => store.read(id),
2236
+ ),
2237
+ register,
2238
+ ),
2239
+ (id) => (store.has(id) ? store.read(id) : undefined),
2240
+ );
1786
2241
 
1787
2242
  const input = commandSchema.parse(request);
1788
2243
 
1789
2244
  const command = sharedCommandSchema.safeParse(input);
1790
2245
 
1791
- if (command.success) {
1792
- refuseDisabledScratchpad(command.data.operation.reviewId);
1793
- await ensureScratchpad(command.data.operation.reviewId);
1794
- }
1795
-
1796
- if (
1797
- input.operation.type === "create" &&
1798
- input.operation.kind === "scratchpad"
1799
- )
1800
- refuseDisabledScratchpad(SCRATCHPAD_ID);
1801
-
1802
2246
  if (
1803
2247
  command.success &&
1804
2248
  command.data.operation.reviewId?.startsWith("shared-")
@@ -1832,11 +2276,6 @@ export function createReviewApi(
1832
2276
  );
1833
2277
  else await shared.setAttention(parsed.operation.reviewId);
1834
2278
 
1835
- const reviewId = parsed.operation.reviewId;
1836
-
1837
- if (shared.get(reviewId).snapshot.trashedAt)
1838
- ask?.threads.closeDeletedReviews((id) => id === reviewId);
1839
-
1840
2279
  return context.json({
1841
2280
  reviewId: parsed.operation.reviewId,
1842
2281
  version: shared.get(parsed.operation.reviewId).snapshot.version,
@@ -1847,13 +2286,23 @@ export function createReviewApi(
1847
2286
  throw new ReviewInputError("Shared reviews are read-only.", 409);
1848
2287
  }
1849
2288
 
1850
- const result = await store.execute(input);
2289
+ // Who is writing, trusted only from local callers: a person from
2290
+ // Desktop or the canvas, or an agent, perhaps a thread's. The store
2291
+ // keeps each author's rules.
2292
+ const threadId = remoteCaller(context)
2293
+ ? undefined
2294
+ : context.req.header(REVIEW_THREAD_HEADER)?.trim() || undefined;
2295
+
2296
+ const author: Author =
2297
+ !remoteCaller(context) &&
2298
+ context.req.header(REVIEW_PERSON_HEADER) !== undefined
2299
+ ? { kind: "person" }
2300
+ : {
2301
+ kind: "agent",
2302
+ ...(threadId && { threadId }),
2303
+ };
1851
2304
 
1852
- if (
1853
- result.deleted ||
1854
- (result.lifecycle && store.read(result.reviewId).trashedAt)
1855
- )
1856
- ask?.threads.closeDeletedReviews((id) => id === result.reviewId);
2305
+ const result = await store.execute(input, undefined, author);
1857
2306
 
1858
2307
  if (input.operation.type === "edit" && data && !result.deleted) {
1859
2308
  const { quotes, unquoted } = await anchorQuotes(
@@ -1893,7 +2342,6 @@ export function createReviewApi(
1893
2342
  if (result.created !== false)
1894
2343
  hooks.onReviewCreated?.({
1895
2344
  reviewId: result.reviewId,
1896
- kind: input.operation.kind === "scratchpad" ? "scratchpad" : "review",
1897
2345
  blocks: store.read(result.reviewId).document.length,
1898
2346
  ...reviewRequestOrigin(context.req.raw.headers),
1899
2347
  });
@@ -1979,16 +2427,165 @@ async function locateRepositories(
1979
2427
  };
1980
2428
  }
1981
2429
 
2430
+ /** Turns an agent's pullRequest target into what the store takes: a create
2431
+ * from pullRequestUrl, or for set_target the PR's resolved commits. */
2432
+ async function pullRequestTargets(
2433
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Request body boundary: commandSchema parses the result.
2434
+ body: unknown,
2435
+ register: (path: string) => Promise<{ id: string }>,
2436
+ resolve: (
2437
+ url: string,
2438
+ repositoryId: string,
2439
+ ) => Promise<{ target: ReviewTarget }>,
2440
+ board: (id: string) => void,
2441
+ ) {
2442
+ const target = z.strictObject({
2443
+ kind: z.literal("pullRequest"),
2444
+ repositoryPath: z.string().min(1),
2445
+ pullRequest: z.union([z.string().min(1), z.number().int().positive()]),
2446
+ });
2447
+
2448
+ // A set_target is checked whole before anything is registered or fetched.
2449
+ const located = z
2450
+ .looseObject({
2451
+ operation: z.discriminatedUnion("type", [
2452
+ z.looseObject({ type: z.literal("create"), target }),
2453
+ z.strictObject({
2454
+ type: z.literal("set_target"),
2455
+ reviewId: z.string().min(1),
2456
+ target,
2457
+ }),
2458
+ ]),
2459
+ })
2460
+ .safeParse(body);
2461
+
2462
+ if (!located.success) return body;
2463
+ const { target: pullRequest, ...operation } = located.data.operation;
2464
+
2465
+ if (operation.type === "set_target") board(operation.reviewId);
2466
+ const { id: repositoryId } = await register(pullRequest.repositoryPath);
2467
+
2468
+ const url = await pullRequestAt(
2469
+ pullRequest.repositoryPath,
2470
+ pullRequest.pullRequest,
2471
+ );
2472
+
2473
+ if (operation.type === "create")
2474
+ return {
2475
+ ...located.data,
2476
+ operation: { ...operation, pullRequestUrl: url, repositoryId },
2477
+ };
2478
+
2479
+ return {
2480
+ ...located.data,
2481
+ operation: {
2482
+ ...operation,
2483
+ target: (await resolve(url, repositoryId)).target,
2484
+ pullRequestUrl: url,
2485
+ },
2486
+ };
2487
+ }
2488
+
2489
+ /** A PR's URL: as given, or its number on the checkout's GitHub repository. */
2490
+ async function pullRequestAt(
2491
+ repositoryPath: string,
2492
+ pullRequest: string | number,
2493
+ ) {
2494
+ if (isStringValue(pullRequest) && !/^\d+$/.test(pullRequest))
2495
+ return pullRequest;
2496
+
2497
+ const slug = (await resolveRepoContext(repositoryPath))?.githubSlug;
2498
+
2499
+ if (!slug)
2500
+ throw new ReviewInputError(
2501
+ "This checkout has no GitHub remote to find the PR number in; pass the PR's URL.",
2502
+ );
2503
+
2504
+ return `https://github.com/${slug}/pull/${pullRequest}`;
2505
+ }
2506
+
2507
+ /** A pins object as an edit spells it: commits, with no other fields. A
2508
+ * diagram may key its own data `pins`, and that is left alone. */
2509
+ /** Pins as an agent writes them: the board's repository is the host's to
2510
+ * add. */
2511
+ const agentPinsSchema = z.strictObject({
2512
+ head: z.string().min(1),
2513
+ base: z.string().min(1).optional(),
2514
+ });
2515
+
2516
+ /** An object an agent meant as pins: one naming a head, base or repository.
2517
+ * Content may have a field called `pins` that is nothing of the kind. */
2518
+ const meantAsPins = (value: JsonValue): value is JsonObject =>
2519
+ isJsonObject(value) &&
2520
+ ["head", "base", "repositoryId"].some((key) => key in value);
2521
+
2522
+ /** Sets every pins object in an edit to the board's own repository. */
2523
+ function withBoardRepository(
2524
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Request body boundary: commandSchema parses the result.
2525
+ body: unknown,
2526
+ board: (id: string) => Snapshot | undefined,
2527
+ ) {
2528
+ const located = z
2529
+ .looseObject({
2530
+ operation: z.looseObject({
2531
+ type: z.enum(["edit", "lens_edit"]),
2532
+ reviewId: z.string(),
2533
+ edit: z.json(),
2534
+ }),
2535
+ })
2536
+ .safeParse(body);
2537
+
2538
+ if (!located.success) return body;
2539
+ const snapshot = board(located.data.operation.reviewId);
2540
+
2541
+ const repositoryId = snapshot ? boardRepositoryId(snapshot) : undefined;
2542
+
2543
+ const edit = JSON.parse(
2544
+ JSON.stringify(located.data.operation.edit),
2545
+ (key, value: JsonValue) => {
2546
+ if (key !== "pins" || !meantAsPins(value)) return value;
2547
+
2548
+ // Checked as the agent wrote them, so a mistake names head or base,
2549
+ // never the repository the host adds.
2550
+ const { repositoryId: _ignored, ...written } = value;
2551
+ const pins = agentPinsSchema.safeParse(written);
2552
+
2553
+ if (!pins.success)
2554
+ throw new ReviewInputError(
2555
+ `Write pins as {head, base?}, each a commit: ${pins.error.issues
2556
+ .map((issue) => `pins.${issue.path.join(".")}: ${issue.message}`)
2557
+ .join("; ")}.`,
2558
+ );
2559
+
2560
+ if (!repositoryId)
2561
+ throw new ReviewInputError(
2562
+ "This whiteboard has no repository for its pins to name commits of.",
2563
+ );
2564
+
2565
+ return { ...pins.data, repositoryId };
2566
+ },
2567
+ );
2568
+
2569
+ return {
2570
+ ...located.data,
2571
+ operation: { ...located.data.operation, edit },
2572
+ };
2573
+ }
2574
+
1982
2575
  function watch(
1983
2576
  lines: () => string[],
1984
2577
  subscribe: (notify: () => void) => () => void,
1985
2578
  ) {
1986
2579
  let stop = () => {};
1987
2580
 
2581
+ let missed = false;
2582
+
1988
2583
  const encoder = new TextEncoder();
1989
2584
 
1990
2585
  const send = (controller: ReadableStreamDefaultController<Uint8Array>) => {
1991
- if (controller.desiredSize === null || controller.desiredSize <= 0) return;
2586
+ missed = controller.desiredSize === null || controller.desiredSize <= 0;
2587
+
2588
+ if (missed) return;
1992
2589
 
1993
2590
  try {
1994
2591
  const pending = lines();
@@ -2015,7 +2612,9 @@ function watch(
2015
2612
  });
2016
2613
  send(controller);
2017
2614
  },
2018
- pull: send,
2615
+ pull(controller) {
2616
+ if (missed) send(controller);
2617
+ },
2019
2618
  cancel() {
2020
2619
  stop();
2021
2620
  },