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

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 (115) hide show
  1. package/dist/{account-alias-xOdmMnDH.js → account-alias-ClRtWF_-.js} +8 -6
  2. package/dist/{agent-cli-DT7MdaqL.js → agent-cli-C6AM9dml.js} +15 -10
  3. package/dist/agent-cli-DKpWo-4F.js +2 -0
  4. package/dist/{agent-client-BRBzv3AV.js → agent-client-G4KxB8zB.js} +13 -3
  5. package/dist/{authoring-tools-DGSEag8y.js → authoring-tools-Dfo0uOSj.js} +34 -27
  6. package/dist/{background-server-WKzI_a98.js → background-server-BF_lLFxY.js} +5 -5
  7. package/dist/{background-server-C_REWFkG.js → background-server-DJfYWRoN.js} +1 -1
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-BBSDi_dX.js → cli-CqOiiTW_.js} +1 -1
  10. package/dist/{cli-runner-DshTBske.js → cli-runner-D8CsXff4.js} +26 -26
  11. package/dist/cli.js +2 -2
  12. package/dist/{client-BN_FDxFr.js → client-lKVF_Elo.js} +253 -47
  13. package/dist/{fuzzy-match-bFX6wm8j.js → fuzzy-match-DH9lZivo.js} +42 -14
  14. package/dist/{headless-host-CS4Gv0c5.js → headless-host-C4bhVPZx.js} +513 -140
  15. package/dist/{headless-host-CzvYpKOX.js → headless-host-CeNnXxiL.js} +1 -1
  16. package/dist/lens-selection-CyJapcsl.js +214 -0
  17. package/dist/{local-data-DtQTIzHX.js → local-data-BdqqCYUD.js} +2434 -328
  18. package/dist/{mcp-DpKnnmHH.js → mcp-B2mvDEhC.js} +5 -9
  19. package/dist/{package-paths-jMg6JxDP.js → package-paths-Cm1tR8c4.js} +1 -1
  20. package/dist/{profile-DaqmODhc.js → profile-DcigfSms.js} +20 -8
  21. package/dist/profile-DthRnZ7x.js +2 -0
  22. package/dist/{remote-attach-DCngg8cl.js → remote-attach-DGwVRmY5.js} +4 -4
  23. package/dist/{remote-extensions-BCAbQZTv.js → remote-extensions-CferWwM9.js} +3 -3
  24. package/dist/{remote-extensions-Cf3L-ZGG.js → remote-extensions-D8W2IgKH.js} +1 -1
  25. package/dist/{remote-language-server-UWQgpoh-.js → remote-language-server-By-rEosC.js} +5 -5
  26. package/dist/{remote-uninstall-CcDOcGyi.js → remote-uninstall-DgHnDAFH.js} +6 -6
  27. package/dist/{request-origin-Zcn_CjX4.js → request-origin-DDmfnytX.js} +38 -10
  28. package/dist/{review-app-launcher-tqSQPlbu.js → review-app-launcher-CG3TFZEx.js} +2 -2
  29. package/dist/{review-home-paths-Lc02WFuX.js → review-home-paths-D1JVitqN.js} +19 -17
  30. package/dist/{review-telemetry-BHWJsnoD.js → review-telemetry-Myzm7o6-.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-DtJFWC0G.js} +1 -1
  35. package/dist/{s3-config-DDWWMh5x.js → s3-config-BTO12tCC.js} +1 -1
  36. package/dist/{s3-config-0xuCh-F_.js → s3-config-CSy3fx8H.js} +1 -1
  37. package/dist/server/desktop-host.js +29 -76
  38. package/dist/{server-discovery-BVqrE1_-.js → server-discovery-Bwa_6Tsj.js} +3 -3
  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-C9J3r7KD.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-runner.ts +4 -1
  55. package/src/diff-selection-migration.ts +48 -34
  56. package/src/heading-anchors.ts +42 -0
  57. package/src/lens-selection.ts +38 -2
  58. package/src/review-api/README.md +52 -13
  59. package/src/review-api/activity.ts +1 -4
  60. package/src/review-api/agent-cli.ts +9 -1
  61. package/src/review-api/anchor-quotes.ts +14 -7
  62. package/src/review-api/ask-history.ts +137 -51
  63. package/src/review-api/authoring-tools.ts +45 -30
  64. package/src/review-api/blocks/definition.ts +1 -1
  65. package/src/review-api/blocks/markdown.ts +2 -2
  66. package/src/review-api/board-order.ts +144 -0
  67. package/src/review-api/checkout-groups.ts +560 -0
  68. package/src/review-api/document-headings.ts +58 -27
  69. package/src/review-api/document-text.ts +31 -2
  70. package/src/review-api/document.ts +281 -34
  71. package/src/review-api/foreign-references.ts +328 -0
  72. package/src/review-api/github-provider.ts +51 -13
  73. package/src/review-api/http.ts +702 -123
  74. package/src/review-api/instructions.ts +0 -19
  75. package/src/review-api/link-preview.ts +113 -0
  76. package/src/review-api/local-data.ts +310 -46
  77. package/src/review-api/mcp.ts +3 -11
  78. package/src/review-api/onboarding.ts +1 -1
  79. package/src/review-api/profile.ts +37 -6
  80. package/src/review-api/public-tools.ts +24 -2
  81. package/src/review-api/pull-request.ts +82 -49
  82. package/src/review-api/request-origin.ts +10 -0
  83. package/src/review-api/review-progress.ts +61 -27
  84. package/src/review-api/scratchpad-plan-upgrade.ts +250 -0
  85. package/src/review-api/store-migrations.ts +357 -0
  86. package/src/review-api/store-schema.ts +38 -29
  87. package/src/review-api/store.ts +2144 -480
  88. package/src/review-api/workspaces.ts +52 -13
  89. package/src/review-api/worktree-events.ts +3 -5
  90. package/src/review-api/worktree-source.ts +3 -7
  91. package/src/review-api/worktree-structural.integration.ts +72 -0
  92. package/src/review-stack.ts +15 -23
  93. package/src/runtime.ts +4 -30
  94. package/src/server/desktop-host.ts +6 -1
  95. package/src/server/desktop-server.ts +1 -31
  96. package/src/server/headless-host.ts +0 -2
  97. package/src/server/hono-http.ts +2 -1
  98. package/src/server/legacy-tutorial-cleanup.ts +2 -3
  99. package/src/server/review-gateway-list.ts +3 -5
  100. package/src/server/review-gateway-memory.ts +1 -1
  101. package/src/server/review-lifecycle-telemetry.ts +2 -2
  102. package/src/server/review-server-core.ts +1 -5
  103. package/src/server/structural-comparisons.ts +47 -0
  104. package/src/sharing/export.ts +36 -7
  105. package/src/sharing/host.ts +34 -14
  106. package/src/sharing/import.ts +156 -24
  107. package/src/sharing/repository.ts +128 -20
  108. package/src/startup-trace.ts +3 -3
  109. package/src/stored-document-migration.ts +110 -19
  110. package/src/ui-telemetry-events.ts +25 -9
  111. package/dist/agent-cli-DeCrUC2s.js +0 -2
  112. package/dist/profile-Cz7L781D.js +0 -2
  113. package/dist/runtime-CKiWhGrf.js +0 -52
  114. package/instructions/scratchpad.md +0 -23
  115. package/src/review-preferences.ts +0 -88
@@ -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, resolveRepoContextSync } 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);
818
+ const tracePins = async (id: string, version?: number) => {
819
+ const snapshot = readReview(id, version);
689
820
 
690
- if (!pins)
691
- throw new ReviewInputError(
692
- "This document has no source pins of its own.",
693
- 409,
694
- );
695
-
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
 
@@ -1085,7 +1238,7 @@ export function createReviewApi(
1085
1238
  );
1086
1239
 
1087
1240
  return [
1088
- `Whiteboard: ${snapshot.title}`,
1241
+ `Whiteboard: ${boardTitle(snapshot.title)}`,
1089
1242
  `Session ID: ${snapshot.reviewId}`,
1090
1243
  `Version: ${snapshot.version}`,
1091
1244
  ...(selection.apiSource?.commit
@@ -1093,7 +1246,6 @@ export function createReviewApi(
1093
1246
  : []),
1094
1247
  ...(selection.apiSource?.pins
1095
1248
  ? [
1096
- `Selected repository ID: ${selection.apiSource.pins.repositoryId}`,
1097
1249
  ...(selection.apiSource.pins.base
1098
1250
  ? [`Selected base: ${selection.apiSource.pins.base}`]
1099
1251
  : []),
@@ -1102,7 +1254,6 @@ export function createReviewApi(
1102
1254
  : []),
1103
1255
  ...(snapshot.pins
1104
1256
  ? [
1105
- `Repository ID: ${snapshot.pins.repositoryId}`,
1106
1257
  `Session base: ${snapshot.pins.base}`,
1107
1258
  `Session head: ${snapshot.pins.head}`,
1108
1259
  ]
@@ -1116,10 +1267,15 @@ export function createReviewApi(
1116
1267
  }
1117
1268
 
1118
1269
  if (ask && data) {
1270
+ const projectOf = (reviewId: string) =>
1271
+ isShared(reviewId) || !store.has(reviewId)
1272
+ ? undefined
1273
+ : store.projectOf(reviewId);
1274
+
1119
1275
  const readThread = (reviewId: string, threadId: string) => {
1120
1276
  const thread = ask.threads.get(threadId);
1121
1277
 
1122
- if (!thread || thread.reviewId !== reviewId)
1278
+ if (!thread)
1123
1279
  throw new ReviewInputError("This conversation has ended.", 404);
1124
1280
 
1125
1281
  return thread;
@@ -1198,7 +1354,7 @@ export function createReviewApi(
1198
1354
  const { reviewId } = snapshot;
1199
1355
 
1200
1356
  return [
1201
- `A reviewer is reading "${snapshot.title}" in Whiteboard and has a question about ${selection.target.kind === "review" ? "it" : "a selection"}.`,
1357
+ `A reviewer is reading "${boardTitle(snapshot.title)}" in Whiteboard and has a question about ${selection.target.kind === "review" ? "it" : "a selection"}.`,
1202
1358
  checkout.live
1203
1359
  ? "Your working directory is the repository the review describes."
1204
1360
  : `Your working directory is a checkout of the review's head commit, ${checkout.head}. Answer from this code, not from other branches.`,
@@ -1326,6 +1482,9 @@ export function createReviewApi(
1326
1482
 
1327
1483
  assertWritable(reviewId);
1328
1484
 
1485
+ // Asked on screen: seen from the start.
1486
+ store.askHistory.markViewed(id, createdAt);
1487
+
1329
1488
  const thread = ask.threads.open({
1330
1489
  id,
1331
1490
  reviewId,
@@ -1334,7 +1493,10 @@ export function createReviewApi(
1334
1493
  // replaces a session the agent could not reopen.
1335
1494
  ...historyHooks({
1336
1495
  id,
1337
- reviewId,
1496
+ anchor:
1497
+ target.kind === "text" && target.anchor
1498
+ ? { board: reviewId, block: target.anchor.blockId }
1499
+ : { board: reviewId },
1338
1500
  agent: input.agent,
1339
1501
  version: snapshot.version,
1340
1502
  head: checkout.head,
@@ -1360,12 +1522,109 @@ export function createReviewApi(
1360
1522
  return context.json({ threadId: thread.id });
1361
1523
  });
1362
1524
 
1525
+ /** Every project's threads for the sidebar: who runs them, where they
1526
+ * started and live, and which boards they changed. */
1527
+ const projectThreads = (reviewId: string): ReviewProjectThreads => {
1528
+ const entries = store.askHistory.listAll();
1529
+
1530
+ const ids = entries.map((entry) => entry.id);
1531
+ const edited = store.boardsChangedByThreads(ids);
1532
+ const viewed = store.askHistory.viewedAt(ids);
1533
+
1534
+ const boards = new Map<string, ReviewThreadBoard>();
1535
+
1536
+ const board = (id: string) => {
1537
+ let found = boards.get(id);
1538
+
1539
+ if (!found) {
1540
+ found = store.has(id)
1541
+ ? store.boardFace(id)
1542
+ : { reviewId: id, icon: boardIconKind(undefined) };
1543
+ boards.set(id, found);
1544
+ }
1545
+
1546
+ return found;
1547
+ };
1548
+
1549
+ const project = projectOf(reviewId);
1550
+
1551
+ return {
1552
+ board: reviewId,
1553
+ ...(project && { project }),
1554
+ threads: entries.map((entry) => {
1555
+ const anchor = entry.anchor ?? { board: reviewId };
1556
+ const status = ask.threads.get(entry.id)?.status;
1557
+ const viewedAt = viewed.get(entry.id);
1558
+
1559
+ // A turn finished since the reviewer last saw the thread.
1560
+ const unread =
1561
+ status !== "starting" &&
1562
+ status !== "running" &&
1563
+ status !== "waiting" &&
1564
+ viewedAt !== undefined &&
1565
+ entry.updatedAt > viewedAt;
1566
+
1567
+ return {
1568
+ id: entry.id,
1569
+ ...(entry.projectId && { projectId: entry.projectId }),
1570
+ ...(store.has(anchor.board) && {
1571
+ place: store.boardPlace(anchor.board),
1572
+ }),
1573
+ agent: entry.agent,
1574
+ agentName: askAgents[entry.agent].name,
1575
+ opening: entry.question ?? entry.title,
1576
+ pinned:
1577
+ entry.selection.target.kind === "text" &&
1578
+ entry.selection.target.anchor !== undefined,
1579
+ ...(status && { status }),
1580
+ ...(unread && { unread }),
1581
+ updatedAt: entry.updatedAt,
1582
+ anchor: {
1583
+ board: board(anchor.board),
1584
+ ...(anchor.block && { block: anchor.block }),
1585
+ },
1586
+ edited: edited.get(entry.id) ?? [],
1587
+ };
1588
+ }),
1589
+ };
1590
+ };
1591
+
1363
1592
  app.get("/:id/ask/threads", (context) => {
1364
1593
  const reviewId = context.req.param("id");
1365
1594
 
1366
1595
  readReview(reviewId);
1367
1596
 
1368
- return context.json({ threads: store.askHistory.list(reviewId) });
1597
+ if (context.req.query("watch") !== undefined) {
1598
+ // Sent again only when it reads differently.
1599
+ let sent = "";
1600
+
1601
+ return watch(
1602
+ () => {
1603
+ const line = JSON.stringify(projectThreads(reviewId));
1604
+
1605
+ if (line === sent) return [];
1606
+ sent = line;
1607
+
1608
+ return [line];
1609
+ },
1610
+ (notify) => {
1611
+ const stops = [
1612
+ store.askHistory.subscribe(notify),
1613
+ ask.threads.subscribeStatus(notify),
1614
+ // An edit, title or target of any board a thread may show; a
1615
+ // deleted board may have been a thread's anchor.
1616
+ store.subscribe(() => notify()),
1617
+ ];
1618
+
1619
+ return () => stops.forEach((stop) => stop());
1620
+ },
1621
+ );
1622
+ }
1623
+
1624
+ return context.json({
1625
+ threads: store.askHistory.list(reviewId, projectOf(reviewId)),
1626
+ board: reviewId,
1627
+ });
1369
1628
  });
1370
1629
 
1371
1630
  // The threads of every open Ask in a review, over one connection. Before
@@ -1384,20 +1643,32 @@ export function createReviewApi(
1384
1643
  threads.map((threadId) => {
1385
1644
  const thread = ask.threads.get(threadId);
1386
1645
 
1387
- return [
1388
- threadId,
1389
- thread?.reviewId === reviewId ? thread : undefined,
1390
- ];
1646
+ return [threadId, thread];
1391
1647
  }),
1392
1648
  ),
1393
1649
  );
1394
1650
  });
1395
1651
 
1652
+ // The boards a thread changed: titles from any project, so only for a
1653
+ // local caller.
1654
+ app.get("/:id/ask/:threadId/boards", (context) => {
1655
+ const threadId = context.req.param("threadId");
1656
+ const record = store.askHistory.get(threadId);
1657
+
1658
+ const anchor =
1659
+ record?.anchor.board ?? ask.threads.get(threadId)?.reviewId;
1660
+
1661
+ if (remoteCaller(context) || !anchor)
1662
+ throw new ReviewInputError("This conversation was not found.", 404);
1663
+
1664
+ return context.json({ boards: store.boardsChangedBy(threadId) });
1665
+ });
1666
+
1396
1667
  // A saved conversation, without starting its agent.
1397
1668
  app.get("/:id/ask/:threadId", (context) => {
1398
1669
  const record = store.askHistory.get(context.req.param("threadId"));
1399
1670
 
1400
- if (record?.reviewId !== context.req.param("id"))
1671
+ if (!record)
1401
1672
  throw new ReviewInputError("This conversation was not found.", 404);
1402
1673
  const target = record.selection.target;
1403
1674
 
@@ -1429,17 +1700,19 @@ export function createReviewApi(
1429
1700
  const threadId = context.req.param("threadId");
1430
1701
  const live = ask.threads.get(threadId);
1431
1702
 
1432
- if (live?.reviewId === reviewId) return context.json({ threadId });
1703
+ if (live) return context.json({ threadId });
1433
1704
  const record = store.askHistory.get(threadId);
1434
1705
 
1435
1706
  const { picks } = askOpenSchema.parse(
1436
1707
  await readBoundedRequestJson(context.req.raw, undefined, {}),
1437
1708
  );
1438
1709
 
1439
- if (record?.reviewId !== reviewId)
1710
+ if (!record)
1440
1711
  throw new ReviewInputError("This conversation was not found.", 404);
1441
1712
 
1442
- const snapshot = readReview(reviewId, record.version);
1713
+ // The thread runs in the checkout of the board it started on.
1714
+ const anchor = record.anchor.board;
1715
+ const snapshot = readReview(anchor, record.version);
1443
1716
  const checkout = await data.agentCheckout(snapshot);
1444
1717
  const target = record.selection.target;
1445
1718
 
@@ -1455,7 +1728,7 @@ export function createReviewApi(
1455
1728
  assertWritable(reviewId);
1456
1729
  ask.threads.open({
1457
1730
  id: record.id,
1458
- reviewId,
1731
+ reviewId: anchor,
1459
1732
  agent: record.agent,
1460
1733
  cwd: checkout.rootPath,
1461
1734
  head: checkout.head,
@@ -1647,6 +1920,16 @@ export function createReviewApi(
1647
1920
  },
1648
1921
  );
1649
1922
 
1923
+ app.post("/:id/ask/:threadId/viewed", (context) => {
1924
+ const threadId = context.req.param("threadId");
1925
+
1926
+ if (!ask.threads.get(threadId) && !store.askHistory.get(threadId))
1927
+ throw new ReviewInputError("This conversation was not found.", 404);
1928
+ store.askHistory.markViewed(threadId);
1929
+
1930
+ return context.json({ ok: true });
1931
+ });
1932
+
1650
1933
  // Closing an Ask ends its agent; the conversation stays saved. A tab
1651
1934
  // switched away from or a reload leaves it running, to come back to.
1652
1935
  app.post("/:id/ask/:threadId/close", reviewAccess("read"), (context) => {
@@ -1664,17 +1947,29 @@ export function createReviewApi(
1664
1947
  const reviewId = context.req.param("id");
1665
1948
  const threadId = context.req.param("threadId");
1666
1949
 
1667
- if (store.askHistory.get(threadId)?.reviewId !== reviewId)
1950
+ const record = store.askHistory.get(threadId);
1951
+
1952
+ if (!record)
1668
1953
  throw new ReviewInputError("This conversation was not found.", 404);
1669
1954
 
1670
- if (ask.threads.get(threadId)?.reviewId === reviewId)
1671
- ask.threads.close(threadId);
1955
+ if (ask.threads.get(threadId)) ask.threads.close(threadId);
1672
1956
  store.askHistory.delete(threadId);
1673
1957
 
1674
1958
  return context.json({ ok: true });
1675
1959
  });
1676
1960
  }
1677
1961
 
1962
+ app.get("/:id/place", (context) => {
1963
+ const id = context.req.param("id");
1964
+
1965
+ if (!isShared(id)) store.assertExists(id);
1966
+
1967
+ return context.json(placeOf(id));
1968
+ });
1969
+
1970
+ // The board's branch stack: its GitHub stack, else the reviews chained
1971
+ // through the bases they name, bottom first. Apart from its place among
1972
+ // boards.
1678
1973
  app.get("/:id/stack", async (context) => {
1679
1974
  const query = readQuerySchemas.get.parse(context.req.query());
1680
1975
  const id = context.req.param("id");
@@ -1682,14 +1977,94 @@ export function createReviewApi(
1682
1977
 
1683
1978
  if (isShared(id) || !snapshot.pins) return context.json({ layers: [] });
1684
1979
 
1980
+ const repositoryPath = store
1981
+ .repositories()
1982
+ .find(
1983
+ (repository) => repository.id === snapshot.pins!.repositoryId,
1984
+ )?.path;
1985
+
1685
1986
  const layers = await resolveReviewStackLayers(snapshot, store.list(), {
1686
- repositoryPath: store
1687
- .repositories()
1688
- .find((repository) => repository.id === snapshot.pins!.repositoryId)
1689
- ?.path,
1987
+ repositoryPath,
1988
+ });
1989
+
1990
+ // The branch the stack sits on: the repository's default branch.
1991
+ const trunk =
1992
+ layers.length > 1 && repositoryPath
1993
+ ? await defaultBranch(repositoryPath).catch(() => null)
1994
+ : null;
1995
+
1996
+ return context.json({
1997
+ layers: layers.map((layer) => {
1998
+ const state = data?.pullRequestState(layer.pullRequestUrl ?? undefined);
1999
+
2000
+ return state ? { ...layer, pullRequestState: state } : layer;
2001
+ }),
2002
+ ...(trunk && { base: trunk.ref.replace(/^origin\//, "") }),
1690
2003
  });
2004
+ });
2005
+
2006
+ // The board's project and its whiteboards, for the board's sidebar.
2007
+ app.get("/:id/project", (context) => {
2008
+ const id = context.req.param("id");
2009
+
2010
+ if (isShared(id)) return context.json({ project: null, boards: [] });
2011
+
2012
+ const project = store.summary(id)?.project ?? null;
2013
+
2014
+ return context.json({
2015
+ project,
2016
+ boards: project
2017
+ ? store
2018
+ .list()
2019
+ .filter(
2020
+ (board) => board.project?.id === project.id && !board.trashedAt,
2021
+ )
2022
+ .map(withPullRequestState)
2023
+ : [],
2024
+ });
2025
+ });
2026
+ app.get("/:id/links", (context) => {
2027
+ const id = context.req.param("id");
2028
+
2029
+ // A shared board's links name boards on the sender's machine: shown,
2030
+ // but not followed.
2031
+ if (isShared(id))
2032
+ return context.json({
2033
+ outgoing: boardLinks(id, readReview(id).document).map(
2034
+ ({ from, to }) => ({ from, to, missing: true, elsewhere: true }),
2035
+ ),
2036
+ backlinks: [],
2037
+ });
2038
+
2039
+ const { version } = readQuerySchemas.get
2040
+ .pick({ version: true })
2041
+ .parse({ version: context.req.query("version") });
2042
+
2043
+ const { outgoing, backlinks } = store.links(id, version);
2044
+
2045
+ return context.json({
2046
+ outgoing: outgoing.map(withPullRequestState),
2047
+ backlinks: backlinks.map(withPullRequestState),
2048
+ });
2049
+ });
2050
+ // A link's hover card, read when it opens.
2051
+ app.get("/:id/links/peek", (context) => {
2052
+ const id = context.req.param("id");
1691
2053
 
1692
- return context.json({ layers });
2054
+ if (isShared(id)) return context.json({});
2055
+
2056
+ const { version } = readQuerySchemas.get
2057
+ .pick({ version: true })
2058
+ .parse({ version: context.req.query("version") });
2059
+
2060
+ const board = context.req.query("board");
2061
+ const block = context.req.query("block");
2062
+
2063
+ if (!board) throw new ReviewInputError("Name the linked board.", 400);
2064
+
2065
+ return context.json(
2066
+ store.linkPeek(id, { board, ...(block && { block }) }, version),
2067
+ );
1693
2068
  });
1694
2069
 
1695
2070
  app.get("/:id/history", (context) => {
@@ -1703,9 +2078,44 @@ export function createReviewApi(
1703
2078
  version: snapshot.version,
1704
2079
  title: snapshot.title,
1705
2080
  createdAt: snapshot.createdAt,
2081
+ author: "agent",
1706
2082
  },
1707
2083
  ]);
1708
2084
  });
2085
+
2086
+ /** A board's relations as session_list reports them: only those it has. */
2087
+ const listedRelations = ({
2088
+ parentId,
2089
+ children,
2090
+ dependsOn,
2091
+ }: ReturnType<typeof store.relations>) => {
2092
+ const listed: Partial<ReturnType<typeof store.relations>> = {};
2093
+
2094
+ if (parentId) listed.parentId = parentId;
2095
+
2096
+ if (children.length) listed.children = children;
2097
+
2098
+ if (dependsOn.length) listed.dependsOn = dependsOn;
2099
+
2100
+ return listed;
2101
+ };
2102
+
2103
+ /** A board's relations, each board named by its title. */
2104
+ const relatedBoards = (id: string) => {
2105
+ const { parentId, children, dependsOn } = store.relations(id);
2106
+
2107
+ const named = (board: string) => ({
2108
+ id: board,
2109
+ title: store.read(board).title,
2110
+ });
2111
+
2112
+ return {
2113
+ ...(parentId && { parent: named(parentId) }),
2114
+ children: children.map(named),
2115
+ dependsOn: dependsOn.map(named),
2116
+ };
2117
+ };
2118
+
1709
2119
  app.get("/:id/inspect", (context) => {
1710
2120
  const query = inspectQuerySchema.parse(context.req.query());
1711
2121
  const id = context.req.param("id");
@@ -1713,12 +2123,19 @@ export function createReviewApi(
1713
2123
 
1714
2124
  return context.json(
1715
2125
  query.format === "text"
1716
- ? documentText(snapshot, query.targetId, Boolean(query.full))
2126
+ ? documentText(
2127
+ snapshot,
2128
+ query.targetId,
2129
+ Boolean(query.full),
2130
+ isShared(id) ? undefined : relatedBoards(id),
2131
+ )
1717
2132
  : query.targetId !== undefined
1718
2133
  ? inspectSnapshot(snapshot, query.targetId)
1719
- : query.full
1720
- ? snapshot
1721
- : inspectSnapshot(snapshot),
2134
+ : {
2135
+ ...(query.full ? snapshot : inspectSnapshot(snapshot)),
2136
+ // Its place among the project's boards, as session_list has it.
2137
+ ...(!isShared(id) && listedRelations(store.relations(id))),
2138
+ },
1722
2139
  );
1723
2140
  });
1724
2141
  app.get("/:id", async (context) => {
@@ -1778,27 +2195,32 @@ export function createReviewApi(
1778
2195
  await readBoundedRequestJson(context.req.raw),
1779
2196
  );
1780
2197
 
1781
- const request = await locateRepositories(body, (path) => {
1782
- if (!data) throw new ReviewInputError("Repositories are unavailable.");
2198
+ // Without local data (a bare store), a path is recorded as it is.
2199
+ const register = async (path: string) =>
2200
+ data ? data.register(path) : store.registerRepository(path);
1783
2201
 
1784
- return data.register(path);
1785
- });
2202
+ const request = withBoardRepository(
2203
+ await locateRepositories(
2204
+ await pullRequestTargets(
2205
+ body,
2206
+ register,
2207
+ (url, repositoryId) => {
2208
+ if (!data)
2209
+ throw new ReviewInputError("Repositories are unavailable.");
2210
+
2211
+ return data.resolvePullRequest(url, { id: repositoryId });
2212
+ },
2213
+ (id) => store.read(id),
2214
+ ),
2215
+ register,
2216
+ ),
2217
+ (id) => (store.has(id) ? store.read(id) : undefined),
2218
+ );
1786
2219
 
1787
2220
  const input = commandSchema.parse(request);
1788
2221
 
1789
2222
  const command = sharedCommandSchema.safeParse(input);
1790
2223
 
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
2224
  if (
1803
2225
  command.success &&
1804
2226
  command.data.operation.reviewId?.startsWith("shared-")
@@ -1847,7 +2269,23 @@ export function createReviewApi(
1847
2269
  throw new ReviewInputError("Shared reviews are read-only.", 409);
1848
2270
  }
1849
2271
 
1850
- const result = await store.execute(input);
2272
+ // Who is writing, trusted only from local callers: a person from
2273
+ // Desktop or the canvas, or an agent, perhaps a thread's. The store
2274
+ // keeps each author's rules.
2275
+ const threadId = remoteCaller(context)
2276
+ ? undefined
2277
+ : context.req.header(REVIEW_THREAD_HEADER)?.trim() || undefined;
2278
+
2279
+ const author: Author =
2280
+ !remoteCaller(context) &&
2281
+ context.req.header(REVIEW_PERSON_HEADER) !== undefined
2282
+ ? { kind: "person" }
2283
+ : {
2284
+ kind: "agent",
2285
+ ...(threadId && { threadId }),
2286
+ };
2287
+
2288
+ const result = await store.execute(input, undefined, author);
1851
2289
 
1852
2290
  if (
1853
2291
  result.deleted ||
@@ -1893,7 +2331,6 @@ export function createReviewApi(
1893
2331
  if (result.created !== false)
1894
2332
  hooks.onReviewCreated?.({
1895
2333
  reviewId: result.reviewId,
1896
- kind: input.operation.kind === "scratchpad" ? "scratchpad" : "review",
1897
2334
  blocks: store.read(result.reviewId).document.length,
1898
2335
  ...reviewRequestOrigin(context.req.raw.headers),
1899
2336
  });
@@ -1979,6 +2416,148 @@ async function locateRepositories(
1979
2416
  };
1980
2417
  }
1981
2418
 
2419
+ /** Turns an agent's pullRequest target into what the store takes: a create
2420
+ * from pullRequestUrl, or for set_target the PR's resolved commits. */
2421
+ async function pullRequestTargets(
2422
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Request body boundary: commandSchema parses the result.
2423
+ body: unknown,
2424
+ register: (path: string) => Promise<{ id: string }>,
2425
+ resolve: (
2426
+ url: string,
2427
+ repositoryId: string,
2428
+ ) => Promise<{ target: ReviewTarget }>,
2429
+ board: (id: string) => void,
2430
+ ) {
2431
+ const target = z.strictObject({
2432
+ kind: z.literal("pullRequest"),
2433
+ repositoryPath: z.string().min(1),
2434
+ pullRequest: z.union([z.string().min(1), z.number().int().positive()]),
2435
+ });
2436
+
2437
+ // A set_target is checked whole before anything is registered or fetched.
2438
+ const located = z
2439
+ .looseObject({
2440
+ operation: z.discriminatedUnion("type", [
2441
+ z.looseObject({ type: z.literal("create"), target }),
2442
+ z.strictObject({
2443
+ type: z.literal("set_target"),
2444
+ reviewId: z.string().min(1),
2445
+ target,
2446
+ }),
2447
+ ]),
2448
+ })
2449
+ .safeParse(body);
2450
+
2451
+ if (!located.success) return body;
2452
+ const { target: pullRequest, ...operation } = located.data.operation;
2453
+
2454
+ if (operation.type === "set_target") board(operation.reviewId);
2455
+ const { id: repositoryId } = await register(pullRequest.repositoryPath);
2456
+
2457
+ const url = pullRequestAt(
2458
+ pullRequest.repositoryPath,
2459
+ pullRequest.pullRequest,
2460
+ );
2461
+
2462
+ if (operation.type === "create")
2463
+ return {
2464
+ ...located.data,
2465
+ operation: { ...operation, pullRequestUrl: url, repositoryId },
2466
+ };
2467
+
2468
+ return {
2469
+ ...located.data,
2470
+ operation: {
2471
+ ...operation,
2472
+ target: (await resolve(url, repositoryId)).target,
2473
+ pullRequestUrl: url,
2474
+ },
2475
+ };
2476
+ }
2477
+
2478
+ /** A PR's URL: as given, or its number on the checkout's GitHub repository. */
2479
+ function pullRequestAt(repositoryPath: string, pullRequest: string | number) {
2480
+ if (isStringValue(pullRequest) && !/^\d+$/.test(pullRequest))
2481
+ return pullRequest;
2482
+
2483
+ const slug = resolveRepoContextSync(repositoryPath)?.githubSlug;
2484
+
2485
+ if (!slug)
2486
+ throw new ReviewInputError(
2487
+ "This checkout has no GitHub remote to find the PR number in; pass the PR's URL.",
2488
+ );
2489
+
2490
+ return `https://github.com/${slug}/pull/${pullRequest}`;
2491
+ }
2492
+
2493
+ /** A pins object as an edit spells it: commits, with no other fields. A
2494
+ * diagram may key its own data `pins`, and that is left alone. */
2495
+ /** Pins as an agent writes them: the board's repository is the host's to
2496
+ * add. */
2497
+ const agentPinsSchema = z.strictObject({
2498
+ head: z.string().min(1),
2499
+ base: z.string().min(1).optional(),
2500
+ });
2501
+
2502
+ /** An object an agent meant as pins: one naming a head, base or repository.
2503
+ * Content may have a field called `pins` that is nothing of the kind. */
2504
+ const meantAsPins = (value: JsonValue): value is JsonObject =>
2505
+ isJsonObject(value) &&
2506
+ ["head", "base", "repositoryId"].some((key) => key in value);
2507
+
2508
+ /** Sets every pins object in an edit to the board's own repository. */
2509
+ function withBoardRepository(
2510
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Request body boundary: commandSchema parses the result.
2511
+ body: unknown,
2512
+ board: (id: string) => Snapshot | undefined,
2513
+ ) {
2514
+ const located = z
2515
+ .looseObject({
2516
+ operation: z.looseObject({
2517
+ type: z.enum(["edit", "lens_edit"]),
2518
+ reviewId: z.string(),
2519
+ edit: z.json(),
2520
+ }),
2521
+ })
2522
+ .safeParse(body);
2523
+
2524
+ if (!located.success) return body;
2525
+ const snapshot = board(located.data.operation.reviewId);
2526
+
2527
+ const repositoryId = snapshot ? boardRepositoryId(snapshot) : undefined;
2528
+
2529
+ const edit = JSON.parse(
2530
+ JSON.stringify(located.data.operation.edit),
2531
+ (key, value: JsonValue) => {
2532
+ if (key !== "pins" || !meantAsPins(value)) return value;
2533
+
2534
+ // Checked as the agent wrote them, so a mistake names head or base,
2535
+ // never the repository the host adds.
2536
+ const { repositoryId: _ignored, ...written } = value;
2537
+ const pins = agentPinsSchema.safeParse(written);
2538
+
2539
+ if (!pins.success)
2540
+ throw new ReviewInputError(
2541
+ `Write pins as {head, base?}, each a commit: ${pins.error.issues
2542
+ .map((issue) => `pins.${issue.path.join(".")}: ${issue.message}`)
2543
+ .join("; ")}.`,
2544
+ );
2545
+
2546
+ if (!repositoryId)
2547
+ throw new ReviewInputError(
2548
+ "This whiteboard has no repository for its pins to name commits of.",
2549
+ );
2550
+
2551
+ return { ...pins.data, repositoryId };
2552
+ },
2553
+ );
2554
+
2555
+ return {
2556
+ ...located.data,
2557
+ operation: { ...located.data.operation, edit },
2558
+ };
2559
+ }
2560
+
1982
2561
  function watch(
1983
2562
  lines: () => string[],
1984
2563
  subscribe: (notify: () => void) => () => void,