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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/dist/{account-alias-xOdmMnDH.js → account-alias-B5-HYi1D.js} +16 -9
  2. package/dist/{agent-cli-DT7MdaqL.js → agent-cli-CohHf6Ua.js} +15 -10
  3. package/dist/agent-cli-_YyMIMRY.js +2 -0
  4. package/dist/{agent-client-BRBzv3AV.js → agent-client-Dtc-Kn_w.js} +13 -3
  5. package/dist/{authoring-tools-DGSEag8y.js → authoring-tools-B7KU04f7.js} +34 -27
  6. package/dist/{background-server-WKzI_a98.js → background-server-C03RKxKS.js} +5 -5
  7. package/dist/{background-server-C_REWFkG.js → background-server-CB9C4uoc.js} +1 -1
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-BBSDi_dX.js → cli-CJaEb7zC.js} +1 -1
  10. package/dist/{cli-runner-DshTBske.js → cli-runner-DGDIqh3V.js} +26 -26
  11. package/dist/cli.js +4 -3
  12. package/dist/{client-BN_FDxFr.js → client-lKVF_Elo.js} +253 -47
  13. package/dist/{fuzzy-match-bFX6wm8j.js → fuzzy-match-BmVJJm9I.js} +42 -14
  14. package/dist/{headless-host-CzvYpKOX.js → headless-host-DRtjf9WA.js} +1 -1
  15. package/dist/{headless-host-CS4Gv0c5.js → headless-host-XhL1X4vX.js} +523 -146
  16. package/dist/lens-selection-CyJapcsl.js +214 -0
  17. package/dist/{local-data-DtQTIzHX.js → local-data-Cpl4ENbJ.js} +2434 -328
  18. package/dist/{mcp-DpKnnmHH.js → mcp-ovmiUE_x.js} +5 -9
  19. package/dist/{package-paths-jMg6JxDP.js → package-paths-BWxO-UEL.js} +1 -1
  20. package/dist/{profile-DaqmODhc.js → profile-BadSkCkI.js} +20 -8
  21. package/dist/profile-DJvkBJNQ.js +2 -0
  22. package/dist/{remote-attach-DCngg8cl.js → remote-attach-Dd-YobM6.js} +4 -4
  23. package/dist/{remote-extensions-BCAbQZTv.js → remote-extensions-CNXwqK0D.js} +3 -3
  24. package/dist/{remote-extensions-Cf3L-ZGG.js → remote-extensions-VP-kfq0C.js} +1 -1
  25. package/dist/{remote-language-server-UWQgpoh-.js → remote-language-server-BXk87KCr.js} +5 -5
  26. package/dist/{remote-uninstall-CcDOcGyi.js → remote-uninstall-DuOzsvaD.js} +6 -6
  27. package/dist/{request-origin-Zcn_CjX4.js → request-origin-B9qCPiVB.js} +45 -12
  28. package/dist/{review-app-launcher-tqSQPlbu.js → review-app-launcher-C7el2Uoy.js} +2 -2
  29. package/dist/{review-home-paths-Lc02WFuX.js → review-home-paths-D8AoCdd2.js} +19 -17
  30. package/dist/{review-telemetry-BHWJsnoD.js → review-telemetry-BsoRMC2w.js} +5 -5
  31. package/dist/runtime-Bj2tD33R.js +27 -0
  32. package/dist/runtime.d.ts +1 -1
  33. package/dist/runtime.js +1 -1
  34. package/dist/{s3-77-qkEhs.js → s3-CsG2EWED.js} +1 -1
  35. package/dist/{s3-config-DDWWMh5x.js → s3-config-CjcLyFV5.js} +5 -3
  36. package/dist/{s3-config-0xuCh-F_.js → s3-config-zz4GWh2T.js} +1 -1
  37. package/dist/server/desktop-host.js +29 -76
  38. package/dist/{server-discovery-BVqrE1_-.js → server-discovery-CKjw3-6h.js} +6 -5
  39. package/dist/sharing/index.d.ts +486 -70
  40. package/dist/sharing/index.js +1 -1
  41. package/dist/{src-DsM3jO8G.js → src-BiWNbcF_.js} +89 -12
  42. package/dist/{src-BMPuxWv4.js → src-WPaQn75m.js} +208 -73
  43. package/dist/{stored-document-migration-CChxtFAy.js → stored-document-migration-dFbewRTG.js} +270 -269
  44. package/dist/{thread-state-5pYr6_zM.js → thread-state-Do5LX10I.js} +7 -2
  45. package/dist/{tool-failure-BFt2xj9i.js → tool-failure-DvI-gys2.js} +10 -3
  46. package/instructions/authoring.md +3 -2
  47. package/package.json +4 -4
  48. package/src/agent-connect.ts +8 -2
  49. package/src/ask/agents.ts +51 -9
  50. package/src/ask/checkout-files.ts +2 -5
  51. package/src/ask/thread-state.ts +4 -0
  52. package/src/ask/thread.ts +16 -0
  53. package/src/ask/threads.ts +24 -3
  54. package/src/cli-install.ts +13 -2
  55. package/src/cli-runner.ts +4 -1
  56. package/src/cli.ts +4 -1
  57. package/src/diff-selection-migration.ts +48 -34
  58. package/src/heading-anchors.ts +42 -0
  59. package/src/lens-selection.ts +38 -2
  60. package/src/review-api/README.md +52 -13
  61. package/src/review-api/activity.ts +1 -4
  62. package/src/review-api/agent-cli.ts +9 -1
  63. package/src/review-api/anchor-quotes.ts +14 -7
  64. package/src/review-api/ask-history.ts +137 -51
  65. package/src/review-api/authoring-tools.ts +45 -30
  66. package/src/review-api/blocks/definition.ts +1 -1
  67. package/src/review-api/blocks/markdown.ts +2 -2
  68. package/src/review-api/board-order.ts +144 -0
  69. package/src/review-api/checkout-groups.ts +560 -0
  70. package/src/review-api/document-headings.ts +58 -27
  71. package/src/review-api/document-text.ts +31 -2
  72. package/src/review-api/document.ts +281 -34
  73. package/src/review-api/foreign-references.ts +328 -0
  74. package/src/review-api/github-provider.ts +51 -13
  75. package/src/review-api/http.ts +702 -123
  76. package/src/review-api/instructions.ts +0 -19
  77. package/src/review-api/link-preview.ts +113 -0
  78. package/src/review-api/local-data.ts +310 -46
  79. package/src/review-api/mcp.ts +3 -11
  80. package/src/review-api/onboarding.ts +1 -1
  81. package/src/review-api/profile.ts +37 -6
  82. package/src/review-api/public-tools.ts +24 -2
  83. package/src/review-api/pull-request.ts +82 -49
  84. package/src/review-api/request-origin.ts +10 -0
  85. package/src/review-api/review-progress.ts +61 -27
  86. package/src/review-api/scratchpad-plan-upgrade.ts +250 -0
  87. package/src/review-api/store-migrations.ts +357 -0
  88. package/src/review-api/store-schema.ts +38 -29
  89. package/src/review-api/store.ts +2144 -480
  90. package/src/review-api/workspaces.ts +52 -13
  91. package/src/review-api/worktree-events.ts +3 -5
  92. package/src/review-api/worktree-source.ts +3 -7
  93. package/src/review-api/worktree-structural.integration.ts +72 -0
  94. package/src/review-stack.ts +15 -23
  95. package/src/runtime.ts +4 -30
  96. package/src/server/desktop-host.ts +6 -1
  97. package/src/server/desktop-server.ts +1 -31
  98. package/src/server/headless-host.ts +21 -14
  99. package/src/server/hono-http.ts +2 -1
  100. package/src/server/legacy-tutorial-cleanup.ts +2 -3
  101. package/src/server/review-gateway-list.ts +3 -5
  102. package/src/server/review-gateway-memory.ts +1 -1
  103. package/src/server/review-lifecycle-telemetry.ts +2 -2
  104. package/src/server/review-server-core.ts +1 -5
  105. package/src/server/structural-comparisons.ts +47 -0
  106. package/src/server-discovery.ts +4 -2
  107. package/src/sharing/export.ts +36 -7
  108. package/src/sharing/host.ts +34 -14
  109. package/src/sharing/import.ts +156 -24
  110. package/src/sharing/repository.ts +128 -20
  111. package/src/startup-trace.ts +3 -3
  112. package/src/stored-document-migration.ts +110 -19
  113. package/src/ui-telemetry-events.ts +33 -11
  114. package/dist/agent-cli-DeCrUC2s.js +0 -2
  115. package/dist/profile-Cz7L781D.js +0 -2
  116. package/dist/runtime-CKiWhGrf.js +0 -52
  117. package/instructions/scratchpad.md +0 -23
  118. package/src/review-preferences.ts +0 -88
@@ -24,8 +24,23 @@ const sameSideAnchor = /^(head|base)\/(.+)#L(\d+)(?:-L(\d+))?$/;
24
24
 
25
25
  const crossSideAnchor = /^diff\/(.+)#([LR])(\d+)-([LR])(\d+)$/;
26
26
 
27
+ const commitLinkAnchor = /^([0-9a-f]{7,40})\/(.+#L\d+(?:-L\d+)?)$/i;
28
+
29
+ /** A source link's commit form, "<sha>/path#L10-L24" (without its
30
+ * `whiteboard:source/` prefix): lines of that commit of the board's own
31
+ * repository, read as their head side. Undefined for any other form. */
32
+ export function commitLinkTarget(
33
+ target: string,
34
+ ): { commit: string; anchor: Anchor } | undefined {
35
+ const match = commitLinkAnchor.exec(target);
36
+
37
+ return match
38
+ ? { commit: match[1]!.toLowerCase(), anchor: `head/${match[2]!}` }
39
+ : undefined;
40
+ }
41
+
27
42
  /** An anchor's selection, as markdown source links write it too (without
28
- * their `review-source:` scheme). Undefined when it isn't one. */
43
+ * their `whiteboard:source/` prefix). Undefined when it isn't one. */
29
44
  export function parseAnchor(text: string): DiffSelection | undefined {
30
45
  const same = sameSideAnchor.exec(text);
31
46
 
@@ -195,6 +210,26 @@ export function sourceAnchor(source: LensSource): FileLineRange {
195
210
  return sourceAnchors(source)[0]!;
196
211
  }
197
212
 
213
+ /** Why a selection cannot be resolved at its pins, as telemetry reports it. */
214
+ export const SELECTION_UNAVAILABLE_REASONS = [
215
+ "missing_file",
216
+ "no_alignment",
217
+ "outside_lines",
218
+ "other",
219
+ ] as const;
220
+
221
+ export type SelectionUnavailableReason =
222
+ (typeof SELECTION_UNAVAILABLE_REASONS)[number];
223
+
224
+ export class SelectionUnavailableError extends Error {
225
+ constructor(
226
+ readonly reason: SelectionUnavailableReason,
227
+ message: string,
228
+ ) {
229
+ super(message);
230
+ }
231
+ }
232
+
198
233
  /** Resolve once against the renderer's rows. No matching or context expansion. */
199
234
  export function resolveDiffSelection(
200
235
  source: LensSource,
@@ -210,7 +245,8 @@ export function resolveDiffSelection(
210
245
  const end = locate(source.end);
211
246
 
212
247
  if (start < 0 || end < start)
213
- throw new Error(
248
+ throw new SelectionUnavailableError(
249
+ "outside_lines",
214
250
  "Lens endpoints do not identify an ordered interval in this diff.",
215
251
  );
216
252
  const selected = rows.slice(start, end + 1);
@@ -17,6 +17,7 @@ catalog. Tests inject the native store and source-data provider.
17
17
  - `repositories`: server-only local paths; clients receive an ID and display name.
18
18
  - `resources`: immutable image, trace and software-map bytes, scoped to a repository.
19
19
  - `review_attention`: viewed/trashed timestamps, separate from document history.
20
+ - `board_relations`: where a board sits in its project, outside its versions: a child's `parent` (one per board) and a board's `depends_on` edges.
20
21
 
21
22
  One host-owned store serializes writes, including asynchronous validation.
22
23
  Presences show who is working across database connections and never block a
@@ -51,11 +52,14 @@ All paths below are relative to `/reviews-api`.
51
52
  | `GET /:id?targetId=step-3` | Full block, sequence step, flow node or flow edge |
52
53
  | `GET /:id?full=true` | Full snapshot |
53
54
  | `GET /:id?version=2&full=true` | Historical snapshot |
54
- | `GET /:id/history` | Saved versions with titles and timestamps |
55
+ | `GET /:id/history` | Saved versions with titles, timestamps and `author`: `"you"` for a person's save, else `"agent"` with the Ask `threadId` when one made it |
55
56
  | `GET /:id/inspect` | Agent reading view: nested text outline with IDs; `targetId` reads one component completely, `full=true` includes all content, `version` selects history. `format=json` returns raw data instead. |
56
57
  | `POST /:id/open` | Open the review in the attached Desktop; report an error when none is attached |
57
58
  | `POST /commands` | Apply one command; return review ID, version, and for an edit the target ID, its `type`, and — after an insert or replace — `children`: its first-level children as `{id,type}` (a container's blocks; a diagram's steps, or nodes then edges), so new components are addressable without a read. A `create` with `pullRequestUrl` returns the newest existing review for that PR instead (owner/repository matched case-insensitively) unless `operation.reuseExisting` is `false`: `created:false`, a `note`, its stored `target`, `headMoved`, and `working`/`otherReviewIds` when they apply; its target is never moved. A new review reports `created:true`. Either way the result carries `review`, the review's `GET /` catalog entry (target, origin, repository name and path). An interactive `create` also opens the new review in an attached Desktop unless `operation.open` is `false`, and reports `opened` with the open result or an `openError`; the review is saved either way |
58
59
  | `POST /commands {operation:{type:"lens_edit",reviewId,edit}}` | Write one file lens, credited to `activityId` when given: `insert {title,targets,afterId?}` (host id `lens-N`), `update {targetId,title?,targets?}` or `remove {targetId}`. Returns `{targetId, type:"lens", uncategorized}`, where `uncategorized` lists changed files and ranges no lens covers yet (at most 50 files) |
60
+ | `POST /:id/person-edit {baseVersion,title,document}` | Save a person's whole board as their version; return `{reviewId,version,ids}`. See Person edits |
61
+ | `POST /:id/focus {clientId,blockId}` | Lease the board open on a canvas, and the block a person is in (`blockId`, or null), for 15 s; `open:false` lets go. Returns `{ok:true}` |
62
+ | `POST /:id/discard` | Delete the board only if it has no title (it reads "Untitled") and no blocks and no child boards, no canvas holds it open and no agent is working on it; waits up to 15 s for a closing canvas to let go. Returns `{deleted}` |
59
63
  | `GET /:id/lenses` | The current version's file lenses with each one's file count, plus the same `uncategorized` report |
60
64
  | `POST /repositories {path}` | Register a local Git/jj repository; return ID/name |
61
65
  | `POST /resources` | Upload an image, trace, or map; return resource ID/kind/MIME type |
@@ -89,10 +93,17 @@ The canvas continues to use the JSON snapshot routes above.
89
93
  }
90
94
  ```
91
95
 
92
- Commands: `create {title,target,pullRequestUrl?,reuseExisting?}` or `create {pullRequestUrl,title?,repositoryId?,reuseExisting?}`, `set_target {reviewId,target,pullRequestUrl?}`, `edit {reviewId,edit}`, `rename {reviewId,title}`,
93
- `restore {reviewId,version}`. A review's pins, `{repositoryId,base,head}`, are its target resolved to immutable commits.
94
- Agents name the checkout by `repositoryPath` in a target, or on a create from a PR alone; `/commands` registers the path and passes the store its id. Retargeting preserves content and component IDs. Restore restores title, target, PR identity, and content. Live targets still read the current checkout.
95
- PR URLs must be canonical `https://github.com/owner/repository/pull/123` URLs. The PR number is derived from the URL; identity is metadata alongside immutable pins, not a moving source reference. A `create` with a target keeps that target and treats the URL as identity only. A `create` with the URL and no target resolves the PR when accepted: it reads the PR with `gh pr view` (falling back to the public GitHub API), picks the registered checkout named by `repositoryId`, else the checkout of the PR's existing review, else the first registered checkout with a remote whose configured URL is `github.com/owner/repository`, and fetches `refs/pull/N/head` and the base branch from that remote into `refs/review/github/owner/repository/pull/N/*`, never moving branches or bookmarks (a jj repository indexes the commits through a transient tag). It pins that head and its merge base with the base branch; for a PR absorbed by a merge commit it uses the merge base with GitHub's frozen base commit instead. The title defaults to the PR title. A repeat resolves the PR again, so `headMoved` compares the existing review with the PR's current head. `set_target` preserves the document and component IDs, including when source commits change. Its response reports retained source ranges to verify, those in files the new commits changed, and resources that no longer match the pins; agents repair these with `edit`. Existing versions keep their original pins and content. It preserves omitted PR identity within one repository, clears it when switching repositories, and accepts an explicit URL or null.
96
+ Commands: `create {title?,target,reuseExisting?,open?}`, `set_target {reviewId,target}`, `edit {reviewId,edit}`, `rename {reviewId,title}`,
97
+ `restore {reviewId,version}`. A target names its checkout by `repositoryPath` (see Review targets). A PR is a `pullRequest` target. A review's pins are its target resolved to immutable commits. Agents write pins as `{head,base?}`, and the host adds the board's repository.
98
+ `/commands` registers the path and passes its id to the store. Agent tools never show repository ids, and `/commands` sets every pins object in an edit to the board's own repository. Retargeting preserves content and component IDs. Restore restores title, target, PR identity, and content. Live targets still read the current checkout.
99
+ PR URLs must be canonical `https://github.com/owner/repository/pull/123` URLs. The PR number is derived from the URL; identity is metadata alongside immutable pins, not a moving source reference. A `pullRequest` target names the PR by URL or by number (a number is looked up on the checkout's GitHub remote) and resolves it when accepted: it reads the PR with `gh pr view` (falling back to the public GitHub API) and fetches `refs/pull/N/head` and the base branch from the checkout named by `repositoryPath` into `refs/review/github/owner/repository/pull/N/*`, never moving branches or bookmarks (a jj repository indexes the commits through a transient tag). It pins that head and its merge base with the base branch; for a PR absorbed by a merge commit it uses the merge base with GitHub's frozen base commit instead. The title defaults to the PR title. A repeat resolves the PR again, so `headMoved` compares the existing review with the PR's current head. `set_target` preserves the document and component IDs, including when source commits change. Its response reports retained source ranges to verify, those in files the new commits changed, and resources that no longer match the pins; agents repair these with `edit`. Existing versions keep their original pins and content. It preserves omitted PR identity within one repository, clears it when switching repositories, and accepts an explicit URL or null.
100
+
101
+ `set_parent {reviewId,parentId}` nests a board one level under a top-level
102
+ board of its project; `null` makes it top-level. `set_dependencies
103
+ {reviewId,dependsOn}` replaces what it builds on, and refuses a cycle.
104
+ `create` takes both. Neither saves a version. Summaries list `parentId`,
105
+ `children` (in sibling order) and `dependsOn`. Deleting a parent moves its
106
+ children to top level.
96
107
 
97
108
  `attention {reviewId,action:"view"}` records viewing without changing lifecycle or document history.
98
109
  `lifecycle {reviewId,state:"active"|"trash"}` moves a whiteboard to Trash or restores it.
@@ -110,8 +121,7 @@ the same version and side through the API, without a client-side checkout path.
110
121
 
111
122
  Edits: `insert {content,parentId?,afterId?}`, `update {targetId,changes}`,
112
123
  `move {targetId,parentId?,afterId?}`, `remove {targetId}`,
113
- `replace {targetId,content}`. Omitted placement appends to the root; on the
114
- scratchpad it prepends instead, so the pad reads newest first. Diagram
124
+ `replace {targetId,content}`. Omitted placement appends to the root. Diagram
115
125
  units (a `step` in a sequence, a `flow_node` or `flow_edge` in a flow diagram)
116
126
  require their diagram as the parent and can move within it, but not between
117
127
  diagrams; removing a flow node removes the edges that touched it. A new
@@ -138,10 +148,35 @@ There is no expected-version parameter. Later same-field edits win. A command
138
148
  whose response was lost may or may not have applied; read the review before
139
149
  retrying.
140
150
 
151
+ ## Person edits
152
+
153
+ The canvas saves a person's edits with `POST /:id/person-edit`, never through
154
+ an agent tool. The body is the whole board: `title`, and `document` with each
155
+ block as stored. A new block has no `id` and may carry a `clientKey`; the host
156
+ gives it a fresh id and returns `ids: {clientKey: id}`. Ids must be unique; an id
157
+ from an earlier version may come back. The save is checked like an agent edit.
158
+ If `baseVersion` is not the current version, nothing is saved and the request
159
+ fails with 409: "The board changed since version N; it is now at version M.
160
+ Merge the latest version and save again." A save that changes nothing returns
161
+ the current version and saves none. The version carries `personEdit: {changed}`,
162
+ the ids of blocks added, removed or changed, and no `lastEdit`, so no courier
163
+ draws it.
164
+
165
+ While a person edits a block, the canvas posts `POST /:id/focus` about every
166
+ 5 s. Each lease expires 15 s after its last post. An agent `edit` that adds,
167
+ removes or changes a leased block fails with 409: "The user is editing this
168
+ block (ID). Edit another block, or try again after they move on." Nothing is
169
+ saved. A section or callout changes only when its own fields do, and a block
170
+ moved unchanged does not change. A lease never refuses a person's edit or
171
+ restore, only an agent's. A board in Trash refuses every change, a person's
172
+ included, until it is restored.
173
+
141
174
  ## Validation and remaining work
142
175
 
143
- Markdown source links use `[label](review-source:head/src/save.ts#L10-L24)`
144
- (or `base`, or `#L10` for one line). Paths are repository-relative and URL-encoded
176
+ Markdown source links use `[label](whiteboard:source/head/src/save.ts#L10-L24)`
177
+ (or `base`, or `#L10` for one line), or `whiteboard:source/<commit>/src/save.ts#L10-L24`
178
+ to quote any commit of the board's own repository; a short commit is saved in full,
179
+ and one the repository lacks rejects the edit. Paths are repository-relative and URL-encoded
145
180
  where needed. Inline and reference-style links open the existing native side peek.
146
181
  The same Markdown parser feeds the host's source checks and the renderer, so code
147
182
  examples and unused definitions do not become source requests. Invalid paths or
@@ -163,9 +198,10 @@ working-copy files. It checks code ranges and resource ownership before saving.
163
198
  Images are fully decoded to PNG; traces retain supplied text with an explicit
164
199
  client-supplied provenance label. Map uploads use the existing nested map format,
165
200
  with a JSON shape check followed by the existing relationship/coverage validator
166
- and pinned source-range checks. Uploads take `{id,repositoryId,kind,...}` with
201
+ and pinned source-range checks. Uploads take `{id,repositoryPath,kind,...}` with
167
202
  `base64` for images, `trace:{label,events:[{id,role,text}]}` for traces, or
168
- `pins,side,model` for maps. Reusing an upload ID requires identical content.
203
+ `pins,side,model` for maps, whose pins still name `{repositoryId,head,base}`
204
+ (no agent tool uploads maps). Reusing an upload ID requires identical content.
169
205
 
170
206
  The canvas preserves React identities during updates. The stream coalesces
171
207
  updates when a reader falls behind; reconnecting starts with the current saved
@@ -277,8 +313,11 @@ commands are local and are never authored into review documents.
277
313
 
278
314
  ## Review targets
279
315
 
280
- `target` is either `{kind:"worktree",repositoryId,base?}` or
281
- `{kind:"commits",repositoryId,head,base?}`. Commit revisions resolve on acceptance.
316
+ `target` names its checkout by path and is one of `{kind:"project",repositoryPath}`
317
+ (a plan in that repository's project, with no change), `{kind:"worktree",repositoryPath,base?}`,
318
+ `{kind:"commits",repositoryPath,head,base?}` or
319
+ `{kind:"pullRequest",repositoryPath,pullRequest}` (stored as commits plus the PR
320
+ origin). Commit revisions resolve on acceptance.
282
321
  Omitted commit base means source at head with no diff, exactly as base=head;
283
322
  supply its parent to review the changes introduced by a single commit.
284
323
 
@@ -68,12 +68,9 @@ export class ReviewActivity {
68
68
  private readonly db: DatabaseSync,
69
69
  private readonly assertReview?: (reviewId: string) => void,
70
70
  ) {
71
- // Presences replace the per-scope leases; both last minutes, so the old
72
- // table is dropped rather than migrated.
73
71
  db.exec(`CREATE TABLE IF NOT EXISTS authoring_presences(
74
72
  activity_id TEXT PRIMARY KEY, review_id TEXT NOT NULL, slot INTEGER NOT NULL,
75
- started_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, focus TEXT, surface TEXT);
76
- DROP TABLE IF EXISTS authoring_sessions;`);
73
+ started_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, focus TEXT, surface TEXT);`);
77
74
 
78
75
  for (const row of db
79
76
  .prepare(
@@ -20,7 +20,12 @@ import { type ReviewApiClient, ReviewApiError } from "./client.js";
20
20
  import { exitOnInstallUpdate } from "./install-update.js";
21
21
  import { callPublicTool, publicTool } from "./public-tools.js";
22
22
  import { RECOVERY } from "./recovery.js";
23
- import { REVIEW_AGENT_HEADER, REVIEW_VIA_HEADER } from "./request-origin.js";
23
+ import {
24
+ ASK_THREAD_ENV,
25
+ REVIEW_AGENT_HEADER,
26
+ REVIEW_THREAD_HEADER,
27
+ REVIEW_VIA_HEADER,
28
+ } from "./request-origin.js";
24
29
  import { toolFailure } from "./tool-failure.js";
25
30
 
26
31
  interface AgentCliInput {
@@ -64,9 +69,12 @@ export async function runReviewAgentCli(input: AgentCliInput): Promise<number> {
64
69
  if (extra.length || (mode === "mcp" && name))
65
70
  throw new Error("Unexpected arguments. Use whiteboard api --help.");
66
71
 
72
+ const thread = env[ASK_THREAD_ENV]?.trim();
73
+
67
74
  const headers = Object.fromEntries([
68
75
  [REVIEW_VIA_HEADER, mode === "mcp" ? "mcp" : "api"],
69
76
  [REVIEW_AGENT_HEADER, reviewSessionAgent(env)],
77
+ [REVIEW_THREAD_HEADER, thread ?? ""],
70
78
  ]);
71
79
 
72
80
  if (mode === "mcp") {
@@ -4,7 +4,12 @@ import {
4
4
  selectionKey,
5
5
  } from "@review/lens-selection.js";
6
6
 
7
- import { type Pins, anchorPins, selectionReferences } from "./document.js";
7
+ import {
8
+ type Pins,
9
+ anchorPins,
10
+ boardRepositoryId,
11
+ selectionReferences,
12
+ } from "./document.js";
8
13
  import type { Snapshot } from "./store.js";
9
14
 
10
15
  export interface AnchorQuote {
@@ -40,15 +45,17 @@ export async function anchorQuotes(
40
45
 
41
46
  const known = new Set(
42
47
  before
43
- ? selectionReferences(before.document, { tolerant: true }).map(
44
- ({ id, source }) => key(id, source),
45
- )
48
+ ? selectionReferences(before.document, {
49
+ tolerant: true,
50
+ repositoryId: boardRepositoryId(before),
51
+ }).map(({ id, source }) => key(id, source))
46
52
  : [],
47
53
  );
48
54
 
49
- const added = selectionReferences(after.document, { tolerant: true }).filter(
50
- ({ id, source }) => !known.has(key(id, source)),
51
- );
55
+ const added = selectionReferences(after.document, {
56
+ tolerant: true,
57
+ repositoryId: boardRepositoryId(after),
58
+ }).filter(({ id, source }) => !known.has(key(id, source)));
52
59
 
53
60
  const quotes = await Promise.all(
54
61
  added
@@ -17,7 +17,11 @@ import { ReviewInputError } from "./document.js";
17
17
 
18
18
  /** A saved Ask conversation: its history entry, and how to reopen it. */
19
19
  export const askRecordSchema = askHistoryEntrySchema.extend({
20
- reviewId: z.string(),
20
+ /** The board the conversation started on, and the block if any. */
21
+ anchor: z.object({ board: z.string(), block: z.string().optional() }),
22
+ /** The project of the board it started on, read from that board, or its
23
+ * tombstone once deleted; absent for a shared board. */
24
+ projectId: z.string().optional(),
21
25
  /** The ACP session the agent keeps the transcript under. */
22
26
  sessionId: z.string(),
23
27
  /** The review version asked about; its pins recreate the checkout. */
@@ -34,7 +38,9 @@ export type AskRecord = z.infer<typeof askRecordSchema>;
34
38
  const rowSchema = z
35
39
  .object({
36
40
  id: z.string(),
37
- review_id: z.string(),
41
+ anchor_board: z.string(),
42
+ anchor_block: z.string().nullable(),
43
+ project_id: z.string().nullable(),
38
44
  agent: z.string(),
39
45
  session_id: z.string(),
40
46
  version: z.number(),
@@ -51,7 +57,11 @@ const rowSchema = z
51
57
  .transform((row) =>
52
58
  askRecordSchema.parse({
53
59
  id: row.id,
54
- reviewId: row.review_id,
60
+ anchor:
61
+ row.anchor_block === null
62
+ ? { board: row.anchor_board }
63
+ : { board: row.anchor_board, block: row.anchor_block },
64
+ projectId: row.project_id ?? undefined,
55
65
  agent: row.agent,
56
66
  sessionId: row.session_id,
57
67
  version: row.version,
@@ -68,17 +78,36 @@ const rowSchema = z
68
78
  }),
69
79
  );
70
80
 
71
- /** Saved Ask conversations, per review. Shared reviews are not in
72
- * `reviews`, so there is no foreign key; deleting a review deletes its rows. */
81
+ /** A thread's project: its anchor board's, or a deleted board's from its
82
+ * tombstone. The one place a thread's project comes from, so it can never
83
+ * disagree with its board's. */
84
+ const THREAD_PROJECT_SQL = `COALESCE(
85
+ (SELECT project_id FROM reviews WHERE reviews.id=ask_conversations.anchor_board),
86
+ (SELECT project_id FROM deleted_boards WHERE deleted_boards.id=ask_conversations.anchor_board))`;
87
+
88
+ /** Every column of a thread, with its project. */
89
+ const THREAD_COLUMNS = `ask_conversations.*, ${THREAD_PROJECT_SQL} AS project_id`;
90
+
91
+ /** Saved Ask conversations by project, each anchored on the board it started
92
+ * on. No foreign key on the anchor: shared reviews are not in `reviews`.
93
+ * So deleting a board removes its threads by hand (removeReview). */
73
94
  export class AskHistory {
95
+ private readonly listeners = new Set<() => void>();
96
+
74
97
  private externalWriteGuard?: (reviewId: string) => void;
75
98
  setExternalWriteGuard(guard: (reviewId: string) => void) {
76
99
  this.externalWriteGuard = guard;
77
100
  }
78
- constructor(private readonly db: DatabaseSync) {
101
+ constructor(
102
+ private readonly db: DatabaseSync,
103
+ /** The store's rule for writing about a board: one in Trash is
104
+ * read-only. */
105
+ private readonly writable: (reviewId: string) => void = () => {},
106
+ ) {
79
107
  db.exec(`CREATE TABLE IF NOT EXISTS ask_conversations(
80
108
  id TEXT PRIMARY KEY,
81
- review_id TEXT NOT NULL,
109
+ anchor_board TEXT NOT NULL,
110
+ anchor_block TEXT,
82
111
  agent TEXT NOT NULL,
83
112
  session_id TEXT NOT NULL,
84
113
  version INTEGER NOT NULL,
@@ -91,25 +120,12 @@ export class AskHistory {
91
120
  entries TEXT,
92
121
  bypass INTEGER NOT NULL DEFAULT 0,
93
122
  permissions TEXT,
94
- UNIQUE(agent, session_id));
95
- CREATE INDEX IF NOT EXISTS ask_conversations_review ON ask_conversations(review_id, updated_at);`);
96
-
97
- // Conversations saved before bypassing permissions, or before auto mode,
98
- // lack its column.
99
- const columns = new Set(
100
- db
101
- .prepare("PRAGMA table_info(ask_conversations)")
102
- .all()
103
- .map((column) => String(column.name)),
104
- );
123
+ viewed_at TEXT,
124
+ UNIQUE(agent, session_id));`);
105
125
 
106
- if (!columns.has("bypass"))
107
- db.exec(
108
- "ALTER TABLE ask_conversations ADD COLUMN bypass INTEGER NOT NULL DEFAULT 0",
109
- );
110
-
111
- if (!columns.has("permissions"))
112
- db.exec("ALTER TABLE ask_conversations ADD COLUMN permissions TEXT");
126
+ db.exec(
127
+ "CREATE INDEX IF NOT EXISTS ask_conversations_review ON ask_conversations(anchor_board, updated_at)",
128
+ );
113
129
 
114
130
  // What each agent offered last, so a new question can pick a model and
115
131
  // effort, and a command, before its agent starts.
@@ -122,34 +138,46 @@ export class AskHistory {
122
138
  );
123
139
  }
124
140
 
125
- private assertWritable(reviewId: string) {
126
- if (reviewId.startsWith("shared-")) this.externalWriteGuard?.(reviewId);
141
+ /** Calls back when a conversation is saved, renamed, used or deleted. */
142
+ subscribe(listener: () => void) {
143
+ this.listeners.add(listener);
127
144
 
128
- const row = this.db
129
- .prepare("SELECT deleted_at FROM review_attention WHERE review_id=?")
130
- .get(reviewId);
145
+ return () => {
146
+ this.listeners.delete(listener);
147
+ };
148
+ }
131
149
 
132
- if (row?.deleted_at)
133
- throw new ReviewInputError(
134
- `This whiteboard was moved to Trash on ${new Date(String(row.deleted_at)).toLocaleDateString("en-US", { month: "short", day: "numeric" })}. Restore it with session_lifecycle({"sessionId":"${reviewId}","state":"active"}) to continue.`,
135
- 409,
136
- );
150
+ private changed() {
151
+ for (const listener of this.listeners) listener();
152
+ }
153
+
154
+ private assertWritable(reviewId: string) {
155
+ if (reviewId.startsWith("shared-")) this.externalWriteGuard?.(reviewId);
156
+ else this.writable(reviewId);
137
157
  }
158
+
138
159
  private assertThreadWritable(id: string) {
139
160
  const record = this.get(id);
140
161
 
141
- if (record) this.assertWritable(record.reviewId);
162
+ if (record) this.assertWritable(record.anchor.board);
142
163
  }
143
164
  save(record: AskRecord) {
144
- this.assertWritable(record.reviewId);
165
+ this.assertWritable(record.anchor.board);
145
166
  this.db
146
167
  .prepare(
147
- `INSERT OR REPLACE INTO ask_conversations(id,review_id,agent,session_id,version,head,cwd,selection,title,created_at,updated_at,bypass,permissions)
148
- VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
168
+ // Its columns, not the row: when it was last seen, and what the
169
+ // panel showed, stay.
170
+ `INSERT INTO ask_conversations(id,anchor_board,anchor_block,agent,session_id,version,head,cwd,selection,title,created_at,updated_at,bypass,permissions)
171
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
172
+ ON CONFLICT(id) DO UPDATE SET anchor_board=excluded.anchor_board, anchor_block=excluded.anchor_block,
173
+ agent=excluded.agent, session_id=excluded.session_id, version=excluded.version, head=excluded.head,
174
+ cwd=excluded.cwd, selection=excluded.selection, title=excluded.title, created_at=excluded.created_at,
175
+ updated_at=excluded.updated_at, bypass=excluded.bypass, permissions=excluded.permissions`,
149
176
  )
150
177
  .run(
151
178
  record.id,
152
- record.reviewId,
179
+ record.anchor.board,
180
+ record.anchor.block ?? null,
153
181
  record.agent,
154
182
  record.sessionId,
155
183
  record.version,
@@ -162,6 +190,7 @@ export class AskHistory {
162
190
  record.permissions === "bypass" ? 1 : 0,
163
191
  record.permissions ?? null,
164
192
  );
193
+ this.changed();
165
194
  }
166
195
 
167
196
  /** What the agent may do without asking when the conversation reopens. */
@@ -189,6 +218,7 @@ export class AskHistory {
189
218
  this.db
190
219
  .prepare("UPDATE ask_conversations SET entries=? WHERE id=?")
191
220
  .run(JSON.stringify(entries), id);
221
+ this.changed();
192
222
  }
193
223
 
194
224
  /** The agent's name for the conversation, which the list shows. */
@@ -197,6 +227,7 @@ export class AskHistory {
197
227
  this.db
198
228
  .prepare("UPDATE ask_conversations SET title=? WHERE id=?")
199
229
  .run(title.slice(0, 200), id);
230
+ this.changed();
200
231
  }
201
232
 
202
233
  saveOffer(agent: AskAgentId, offer: AskOffer) {
@@ -245,50 +276,105 @@ export class AskHistory {
245
276
  this.db
246
277
  .prepare("UPDATE ask_conversations SET updated_at=? WHERE id=?")
247
278
  .run(at, id);
279
+ this.changed();
280
+ }
281
+
282
+ /** Conversations in a board's project, or started on the board when it
283
+ * has none. Newest first. */
284
+ list(board: string, projectId?: string) {
285
+ // The project's boards by their index on project_id, then their
286
+ // threads by ask_conversations_review.
287
+ return this.entries(
288
+ "anchor_board=? OR anchor_board IN (SELECT id FROM reviews WHERE project_id=? UNION ALL SELECT id FROM deleted_boards WHERE project_id=?)",
289
+ board,
290
+ projectId ?? null,
291
+ projectId ?? null,
292
+ );
248
293
  }
249
294
 
250
- /** Newest first. */
251
- list(reviewId: string): AskHistoryEntry[] {
295
+ /** Every project's conversations, newest first. */
296
+ listAll() {
297
+ return this.entries("1");
298
+ }
299
+
300
+ private entries(
301
+ where: string,
302
+ ...params: (string | null)[]
303
+ ): (AskHistoryEntry & { projectId?: string })[] {
304
+ // A transcript can be long: SQLite finds its first question, and the
305
+ // rest of it is left unread.
252
306
  return this.db
253
307
  .prepare(
254
- "SELECT * FROM ask_conversations WHERE review_id=? ORDER BY updated_at DESC",
308
+ `SELECT id, anchor_board, anchor_block, ${THREAD_PROJECT_SQL} AS project_id, agent, session_id, version, head, cwd, selection, title,
309
+ created_at, updated_at, NULL AS entries, bypass, permissions,
310
+ (SELECT json_extract(value,'$.text') FROM json_each(entries)
311
+ WHERE json_extract(value,'$.kind')='user' LIMIT 1) AS question
312
+ FROM ask_conversations WHERE ${where} ORDER BY updated_at DESC`,
255
313
  )
256
- .all(reviewId)
314
+ .all(...params)
257
315
  .map((row) => {
258
316
  const {
259
- reviewId: _reviewId,
317
+ anchor,
260
318
  sessionId: _sessionId,
261
319
  version: _version,
262
320
  cwd: _cwd,
263
- entries,
321
+ entries: _entries,
264
322
  permissions: _permissions,
265
323
  ...entry
266
324
  } = rowSchema.parse(row);
267
325
 
268
- const question = entries?.find((entry) => entry.kind === "user")?.text;
326
+ const question = z.string().nullable().parse(row.question) ?? "";
269
327
 
270
- if (question?.trim()) entry.question = question;
328
+ if (question.trim()) entry.question = question;
271
329
 
272
- return entry;
330
+ return { ...entry, anchor };
273
331
  });
274
332
  }
275
333
 
276
334
  get(id: string): AskRecord | undefined {
277
335
  const row = this.db
278
- .prepare("SELECT * FROM ask_conversations WHERE id=?")
336
+ .prepare(`SELECT ${THREAD_COLUMNS} FROM ask_conversations WHERE id=?`)
279
337
  .get(id);
280
338
 
281
339
  return row ? rowSchema.parse(row) : undefined;
282
340
  }
283
341
 
342
+ /** The reviewer saw the thread, on screen with the app focused. Only
343
+ * when it was seen changes: not its order, nor when it was used. */
344
+ markViewed(id: string, at = new Date().toISOString()) {
345
+ this.db
346
+ .prepare("UPDATE ask_conversations SET viewed_at=? WHERE id=?")
347
+ .run(at, id);
348
+ this.changed();
349
+ }
350
+
351
+ /** When each of these threads was last viewed, where it ever was.
352
+ * Threads from before views were kept have none, and none is unread. */
353
+ viewedAt(ids: readonly string[]): Map<string, string> {
354
+ const select = this.db.prepare(
355
+ "SELECT viewed_at FROM ask_conversations WHERE id=? AND viewed_at IS NOT NULL",
356
+ );
357
+
358
+ return new Map(
359
+ ids.flatMap((id) => {
360
+ const row = select.get(id);
361
+
362
+ return row ? [[id, String(row.viewed_at)] as const] : [];
363
+ }),
364
+ );
365
+ }
366
+
367
+ /** Forgets the threads that started on a board gone for good. */
284
368
  removeReview(reviewId: string) {
285
369
  this.db
286
- .prepare("DELETE FROM ask_conversations WHERE review_id=?")
370
+ .prepare("DELETE FROM ask_conversations WHERE anchor_board=?")
287
371
  .run(reviewId);
372
+ this.changed();
288
373
  }
289
374
 
290
375
  delete(id: string) {
291
376
  this.assertThreadWritable(id);
292
377
  this.db.prepare("DELETE FROM ask_conversations WHERE id=?").run(id);
378
+ this.changed();
293
379
  }
294
380
  }