@erdemtuna/doc-review 0.12.0 → 0.13.0

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 (49) hide show
  1. package/README.md +23 -25
  2. package/lib/SKILL.md +65 -202
  3. package/lib/agent-handoff.js +24 -0
  4. package/lib/agent-output.js +287 -0
  5. package/lib/anchor-text.js +31 -19
  6. package/lib/chrome-api.js +23 -4
  7. package/lib/chrome.html +0 -25
  8. package/lib/cli.js +282 -104
  9. package/lib/comment-target.js +4 -0
  10. package/lib/contracts/agent.js +122 -0
  11. package/lib/contracts/feedback.js +369 -1
  12. package/lib/contracts/frame.js +81 -0
  13. package/lib/contracts/history.js +189 -1
  14. package/lib/contracts/index.js +7 -2
  15. package/lib/contracts/page-boundary.js +187 -0
  16. package/lib/contracts/validation.js +200 -0
  17. package/lib/conversation-anchor-controller.js +84 -0
  18. package/lib/conversation-capture.js +81 -0
  19. package/lib/conversation-controller.js +798 -0
  20. package/lib/conversation-save.js +175 -0
  21. package/lib/conversation-server.js +184 -0
  22. package/lib/conversation-shell.js +1013 -0
  23. package/lib/conversation-store.js +809 -0
  24. package/lib/frame-controller.js +8 -8
  25. package/lib/frame-policy.js +1 -0
  26. package/lib/history-policy.js +4 -15
  27. package/lib/history-server.js +42 -326
  28. package/lib/html-transform.js +19 -4
  29. package/lib/icons.js +323 -0
  30. package/lib/new-message-target.js +27 -0
  31. package/lib/paths.js +2 -2
  32. package/lib/poll-transport.js +109 -26
  33. package/lib/positioning.js +51 -0
  34. package/lib/references/context-and-recovery.md +130 -0
  35. package/lib/references/response-contract.md +108 -0
  36. package/lib/references/source-edits.md +47 -0
  37. package/lib/revision-store.js +1 -1
  38. package/lib/save-controller.js +28 -14
  39. package/lib/sdk.js +357 -35
  40. package/lib/server.js +73 -595
  41. package/lib/setup.js +17 -48
  42. package/lib/state.js +21 -7
  43. package/lib/thread-anchor-controller.js +56 -0
  44. package/lib/toolbar-controller.js +3 -3
  45. package/lib/ui/THIRD_PARTY_NOTICES.md +127 -8
  46. package/lib/ui/chrome.css +1079 -2782
  47. package/lib/ui/chrome.js +81 -17
  48. package/package.json +2 -2
  49. package/lib/chrome-client.js +0 -1706
package/lib/icons.js CHANGED
@@ -30,6 +30,203 @@ export const ICON_NODES = Object.freeze({
30
30
  }
31
31
  ]
32
32
  ],
33
+ "filePenLine": [
34
+ [
35
+ "path",
36
+ {
37
+ "d": "M14.364 13.634a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506l4.013-4.009a1 1 0 0 0-3.004-3.004z"
38
+ }
39
+ ],
40
+ [
41
+ "path",
42
+ {
43
+ "d": "M14.487 7.858A1 1 0 0 1 14 7V2"
44
+ }
45
+ ],
46
+ [
47
+ "path",
48
+ {
49
+ "d": "M20 19.645V20a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l2.516 2.516"
50
+ }
51
+ ],
52
+ [
53
+ "path",
54
+ {
55
+ "d": "M8 18h1"
56
+ }
57
+ ]
58
+ ],
59
+ "arrowRight": [
60
+ [
61
+ "path",
62
+ {
63
+ "d": "M5 12h14"
64
+ }
65
+ ],
66
+ [
67
+ "path",
68
+ {
69
+ "d": "m12 5 7 7-7 7"
70
+ }
71
+ ]
72
+ ],
73
+ "messageSquareDiff": [
74
+ [
75
+ "path",
76
+ {
77
+ "d": "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"
78
+ }
79
+ ],
80
+ [
81
+ "path",
82
+ {
83
+ "d": "M10 15h4"
84
+ }
85
+ ],
86
+ [
87
+ "path",
88
+ {
89
+ "d": "M10 9h4"
90
+ }
91
+ ],
92
+ [
93
+ "path",
94
+ {
95
+ "d": "M12 7v4"
96
+ }
97
+ ]
98
+ ],
99
+ "messageSquareCheck": [
100
+ [
101
+ "path",
102
+ {
103
+ "d": "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.7.7 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"
104
+ }
105
+ ],
106
+ [
107
+ "path",
108
+ {
109
+ "d": "m9 11 2 2 4-4"
110
+ }
111
+ ]
112
+ ],
113
+ "messageCircleQuestion": [
114
+ [
115
+ "path",
116
+ {
117
+ "d": "M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"
118
+ }
119
+ ],
120
+ [
121
+ "path",
122
+ {
123
+ "d": "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"
124
+ }
125
+ ],
126
+ [
127
+ "path",
128
+ {
129
+ "d": "M12 17h.01"
130
+ }
131
+ ]
132
+ ],
133
+ "circleDashed": [
134
+ [
135
+ "path",
136
+ {
137
+ "d": "M10.1 2.182a10 10 0 0 1 3.8 0"
138
+ }
139
+ ],
140
+ [
141
+ "path",
142
+ {
143
+ "d": "M13.9 21.818a10 10 0 0 1-3.8 0"
144
+ }
145
+ ],
146
+ [
147
+ "path",
148
+ {
149
+ "d": "M17.609 3.721a10 10 0 0 1 2.69 2.7"
150
+ }
151
+ ],
152
+ [
153
+ "path",
154
+ {
155
+ "d": "M2.182 13.9a10 10 0 0 1 0-3.8"
156
+ }
157
+ ],
158
+ [
159
+ "path",
160
+ {
161
+ "d": "M20.279 17.609a10 10 0 0 1-2.7 2.69"
162
+ }
163
+ ],
164
+ [
165
+ "path",
166
+ {
167
+ "d": "M21.818 10.1a10 10 0 0 1 0 3.8"
168
+ }
169
+ ],
170
+ [
171
+ "path",
172
+ {
173
+ "d": "M3.721 6.391a10 10 0 0 1 2.7-2.69"
174
+ }
175
+ ],
176
+ [
177
+ "path",
178
+ {
179
+ "d": "M6.391 20.279a10 10 0 0 1-2.69-2.7"
180
+ }
181
+ ]
182
+ ],
183
+ "circlePause": [
184
+ [
185
+ "circle",
186
+ {
187
+ "cx": "12",
188
+ "cy": "12",
189
+ "r": "10"
190
+ }
191
+ ],
192
+ [
193
+ "line",
194
+ {
195
+ "x1": "10",
196
+ "x2": "10",
197
+ "y1": "15",
198
+ "y2": "9"
199
+ }
200
+ ],
201
+ [
202
+ "line",
203
+ {
204
+ "x1": "14",
205
+ "x2": "14",
206
+ "y1": "15",
207
+ "y2": "9"
208
+ }
209
+ ]
210
+ ],
211
+ "copy": [
212
+ [
213
+ "rect",
214
+ {
215
+ "width": "14",
216
+ "height": "14",
217
+ "x": "8",
218
+ "y": "8",
219
+ "rx": "2",
220
+ "ry": "2"
221
+ }
222
+ ],
223
+ [
224
+ "path",
225
+ {
226
+ "d": "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
227
+ }
228
+ ]
229
+ ],
33
230
  "plus": [
34
231
  [
35
232
  "path",
@@ -106,6 +303,14 @@ export const ICON_NODES = Object.freeze({
106
303
  }
107
304
  ]
108
305
  ],
306
+ "chevronLeft": [
307
+ [
308
+ "path",
309
+ {
310
+ "d": "m15 18-6-6 6-6"
311
+ }
312
+ ]
313
+ ],
109
314
  "chevronRight": [
110
315
  [
111
316
  "path",
@@ -242,6 +447,124 @@ export const ICON_NODES = Object.freeze({
242
447
  }
243
448
  ]
244
449
  ],
450
+ "checkCheck": [
451
+ [
452
+ "path",
453
+ {
454
+ "d": "M18 6 7 17l-5-5"
455
+ }
456
+ ],
457
+ [
458
+ "path",
459
+ {
460
+ "d": "m22 10-7.5 7.5L13 16"
461
+ }
462
+ ]
463
+ ],
464
+ "circleCheck": [
465
+ [
466
+ "circle",
467
+ {
468
+ "cx": "12",
469
+ "cy": "12",
470
+ "r": "10"
471
+ }
472
+ ],
473
+ [
474
+ "path",
475
+ {
476
+ "d": "m16 9-5.5 5.5L8 12"
477
+ }
478
+ ]
479
+ ],
480
+ "rotateCcw": [
481
+ [
482
+ "path",
483
+ {
484
+ "d": "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"
485
+ }
486
+ ],
487
+ [
488
+ "path",
489
+ {
490
+ "d": "M3 3v5h5"
491
+ }
492
+ ]
493
+ ],
494
+ "clock": [
495
+ [
496
+ "circle",
497
+ {
498
+ "cx": "12",
499
+ "cy": "12",
500
+ "r": "10"
501
+ }
502
+ ],
503
+ [
504
+ "path",
505
+ {
506
+ "d": "M12 6v6l4 2"
507
+ }
508
+ ]
509
+ ],
510
+ "inbox": [
511
+ [
512
+ "polyline",
513
+ {
514
+ "points": "22 12 16 12 14 15 10 15 8 12 2 12"
515
+ }
516
+ ],
517
+ [
518
+ "path",
519
+ {
520
+ "d": "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"
521
+ }
522
+ ]
523
+ ],
524
+ "circleX": [
525
+ [
526
+ "circle",
527
+ {
528
+ "cx": "12",
529
+ "cy": "12",
530
+ "r": "10"
531
+ }
532
+ ],
533
+ [
534
+ "path",
535
+ {
536
+ "d": "m15 9-6 6"
537
+ }
538
+ ],
539
+ [
540
+ "path",
541
+ {
542
+ "d": "m9 9 6 6"
543
+ }
544
+ ]
545
+ ],
546
+ "circleHelp": [
547
+ [
548
+ "circle",
549
+ {
550
+ "cx": "12",
551
+ "cy": "12",
552
+ "r": "10"
553
+ }
554
+ ],
555
+ [
556
+ "path",
557
+ {
558
+ "d": "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"
559
+ }
560
+ ],
561
+ [
562
+ "path",
563
+ {
564
+ "d": "M12 17h.01"
565
+ }
566
+ ]
567
+ ],
245
568
  "sun": [
246
569
  [
247
570
  "circle",
@@ -0,0 +1,27 @@
1
+ import { conversationTargetSchema } from "./contracts/feedback.js";
2
+ import { reject } from "./contracts/validation.js";
3
+ import { sanitizeClientRects, sanitizeClipRect } from "./comment-target.js";
4
+ /** Existing SDK payload: anchors are required to save; geometry is optional presentation. */
5
+ export function readNewMessageTarget(input) {
6
+ const target = conversationTargetSchema.parse({ kind: input.kind, anchor: input.anchor });
7
+ if (!Number.isSafeInteger(input.targetGeneration) || Number(input.targetGeneration) < 1) {
8
+ reject("INVALID_INPUT", "Invalid new-comment target generation.");
9
+ }
10
+ const viewport = input.viewport;
11
+ const relation = input.relation;
12
+ const clip = viewport && Number.isFinite(viewport.width) && viewport.width > 0 &&
13
+ Number.isFinite(viewport.height) && viewport.height > 0 ? sanitizeClipRect(input.clip, viewport) : null;
14
+ const rects = clip ? sanitizeClientRects(input.rects, viewport).flatMap((rect) => {
15
+ if (!rect)
16
+ return [];
17
+ const left = Math.max(rect.left, clip.left), top = Math.max(rect.top, clip.top);
18
+ const right = Math.min(rect.right, clip.right), bottom = Math.min(rect.bottom, clip.bottom);
19
+ return right > left && bottom > top ? [{ left, top, right, bottom, width: right - left, height: bottom - top }] : [];
20
+ }) : [];
21
+ const geometry = clip && ["visible", "above", "below", "unavailable"].includes(String(relation)) &&
22
+ (relation !== "visible" || rects.length) ? {
23
+ rects, clip, viewport: viewport, relation: relation,
24
+ horizontal: typeof input.horizontal === "number" && Number.isFinite(input.horizontal) ? input.horizontal : null,
25
+ } : null;
26
+ return { target, generation: Number(input.targetGeneration), geometry };
27
+ }
package/lib/paths.js CHANGED
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import fs from "node:fs";
5
5
  // Bump for incompatible CLI/server or shell/SDK contracts, including readiness.
6
6
  // A new CLI must not silently reuse an older background server.
7
- export const SERVER_PROTOCOL = 18;
7
+ export const SERVER_PROTOCOL = 25;
8
8
  export function serverProtocolMatches(protocol) {
9
9
  return Number(protocol) === SERVER_PROTOCOL;
10
10
  }
@@ -13,7 +13,7 @@ export function stateDir() {
13
13
  return override ? path.resolve(override) : path.join(homedir(), ".doc-review");
14
14
  }
15
15
  export function statePath() {
16
- return path.join(stateDir(), "state.json");
16
+ return path.join(stateDir(), "conversation-state.json");
17
17
  }
18
18
  export function serverPath() {
19
19
  return path.join(stateDir(), "server.json");
@@ -1,5 +1,8 @@
1
1
  import http from "node:http";
2
2
  import { performance } from "node:perf_hooks";
3
+ import { acceptedMutationSchema } from "./contracts/feedback.js";
4
+ import { pollResponseSchema } from "./contracts/page-boundary.js";
5
+ import { ContractError, failureSchema } from "./contracts/validation.js";
3
6
  export const DEFAULT_POLL_SECONDS = 12 * 60 * 60;
4
7
  const MAX_TIMER_MS = 2 ** 31 - 1;
5
8
  const clock = {
@@ -162,48 +165,62 @@ export function parseServerResponse(response) {
162
165
  throw codedError("Malformed response from the doc-review server. End the review and restart the server.", "SERVER_RESPONSE_INVALID");
163
166
  }
164
167
  }
165
- export async function pollOnce(server, target, ackId, deadline) {
168
+ export async function conversationOnce(server, body, decoder, deadline, route = "/api/conversation") {
166
169
  deadline.check();
167
- const query = `target=${encodeURIComponent(target)}${ackId ? `&ack=${encodeURIComponent(ackId)}` : ""}`;
168
170
  const response = await requestRaw(server, {
169
- method: "GET",
170
- path: `/api/poll?${query}`,
171
- }, undefined, {
172
- timeoutMs: deadline.remaining(),
171
+ method: "POST", path: route, headers: { "content-type": "application/json" },
172
+ }, body, {
173
+ timeoutMs: Math.min(15000, deadline.remaining()),
173
174
  time: deadline.time,
174
- timeoutCode: "POLL_DEADLINE",
175
175
  });
176
- // Graceful server disposal ends the space heartbeats without a JSON payload.
177
- // HTTP is complete, but this poll was interrupted just like a dropped socket.
178
- if (response.status === 200 && /^ +$/.test(response.raw)) {
179
- throw codedError("The server ended the poll before sending feedback.", "ERR_STREAM_PREMATURE_CLOSE");
176
+ let parsed;
177
+ try {
178
+ parsed = JSON.parse(response.raw);
179
+ }
180
+ catch (error) {
181
+ if (!(error instanceof SyntaxError))
182
+ throw error;
183
+ throw codedError("Malformed conversation response.", "SERVER_RESPONSE_INVALID");
184
+ }
185
+ if (response.status !== 200) {
186
+ let failure;
187
+ try {
188
+ failure = failureSchema.parse(parsed);
189
+ }
190
+ catch {
191
+ throw codedError(`Invalid error response (HTTP ${response.status}).`, "SERVER_RESPONSE_INVALID");
192
+ }
193
+ if (failure.error.status !== response.status)
194
+ throw codedError("HTTP and error status disagree.", "SERVER_RESPONSE_INVALID");
195
+ throw new ContractError(failure.error.code, failure.error.message);
180
196
  }
181
- const batch = parseServerResponse(response);
182
- if (!["feedback", "closed", "timeout"].includes(batch.status) ||
183
- (batch.status === "feedback" && (typeof batch.batch_id !== "string" || !batch.batch_id || !Array.isArray(batch.pages)))) {
184
- throw codedError("Malformed polling response from the doc-review server.", "SERVER_RESPONSE_INVALID");
197
+ try {
198
+ return decoder.parse(parsed);
199
+ }
200
+ catch {
201
+ throw codedError("Conversation response does not match its schema.", "SERVER_RESPONSE_INVALID");
185
202
  }
186
- return batch;
187
203
  }
188
- export async function pollUntilDeadline({ target, ackId = "", deadline = createDeadline(), discover, poll = pollOnce, diagnostic = () => { }, }) {
204
+ export const pollOnce = (server, reference, deadline) => conversationOnce(server, { operation: "poll", ...reference }, pollResponseSchema, deadline);
205
+ export async function pollUntilDeadline({ reference, deadline = createDeadline(), discover, poll = pollOnce, diagnostic = () => { }, }) {
189
206
  let failures = 0;
190
207
  for (;;) {
191
208
  try {
192
209
  deadline.check();
193
210
  const server = await discover(deadline);
194
211
  deadline.check();
195
- // An uncertain send must repeat the supplied receipt, never a newer ID.
196
- const batch = await poll(server, target, ackId, deadline);
197
- deadline.check();
198
- return batch;
212
+ const result = await poll(server, reference, deadline);
213
+ if (result.review.reviewId !== reference.reviewId || result.review.entryKey !== reference.entryKey) {
214
+ throw codedError("Polling response belongs to another review.", "SERVER_RESPONSE_INVALID");
215
+ }
216
+ if (result.state !== "waiting")
217
+ return result;
218
+ failures = 0;
219
+ await deadline.sleep(500);
199
220
  }
200
221
  catch (err) {
201
222
  if (err.code === "POLL_DEADLINE" || deadline.remaining() <= 0) {
202
- return {
203
- status: "timeout",
204
- waited_seconds: deadline.seconds,
205
- next_step: "No feedback yet. Run the same poll command again to keep waiting, or `doc-review status <target>` to check without blocking.",
206
- };
223
+ return { state: "timeout", ...reference };
207
224
  }
208
225
  if (!isRecoverableTransportError(err))
209
226
  throw err;
@@ -218,3 +235,69 @@ export async function pollUntilDeadline({ target, ackId = "", deadline = createD
218
235
  }
219
236
  }
220
237
  }
238
+ /** Retry transport only. This function never opens or edits a reviewed source. */
239
+ export async function mutationUntilDeadline({ body, deadline = createDeadline(60), discover, send = conversationOnce, diagnostic = () => { }, }) {
240
+ const request = structuredClone(body);
241
+ let failures = 0;
242
+ let reason = "unavailable";
243
+ let attempted = false;
244
+ const unknown = (detail = "") => Object.assign(new Error(`Acceptance is unknown. Reuse the identical request; do not repeat source edits.${detail ? ` ${detail}` : ""}`), { code: "TRANSPORT_UNKNOWN", outcome: { state: "unknown", requestId: request.requestId, reason } });
245
+ for (;;) {
246
+ try {
247
+ deadline.check();
248
+ const server = await discover(deadline);
249
+ deadline.check();
250
+ attempted = true;
251
+ const accepted = await send(server, request, acceptedMutationSchema, deadline);
252
+ const receipt = acceptedMutationSchema.parse(accepted).receipt;
253
+ if (receipt.requestId !== request.requestId || receipt.operation !== request.operation ||
254
+ (request.reviewId !== undefined && receipt.reviewId !== request.reviewId) ||
255
+ (request.entryKey !== undefined && receipt.entryKey !== request.entryKey) ||
256
+ (request.submissionId !== undefined && receipt.value.submissionId !== request.submissionId)) {
257
+ throw codedError("Receipt does not match the submitted request.", "SERVER_RESPONSE_INVALID");
258
+ }
259
+ return accepted;
260
+ }
261
+ catch (error) {
262
+ if (error instanceof ContractError && !["STATE_PERSIST_FAILED", "INTERNAL_ERROR"].includes(error.code))
263
+ throw error;
264
+ const recoverable = isRecoverableTransportError(error) || ["SERVER_RESPONSE_INVALID", "STATE_PERSIST_FAILED"].includes(error.code);
265
+ if (!recoverable && error.code !== "POLL_DEADLINE") {
266
+ if (attempted)
267
+ throw unknown(error.message);
268
+ throw error;
269
+ }
270
+ if (error.code !== "POLL_DEADLINE")
271
+ reason = error.code === "SERVER_RESPONSE_INVALID" ? "invalid-response" :
272
+ error.code === "ETIMEDOUT" ? "timeout" : error.code === "STATE_PERSIST_FAILED" ? "unavailable" : "disconnected";
273
+ if (error.code === "POLL_DEADLINE" || deadline.remaining() <= 0) {
274
+ throw unknown();
275
+ }
276
+ diagnostic(`Response uncertain (${error.message}); retrying the identical request.\n`);
277
+ try {
278
+ await deadline.sleep(Math.min(250 * 2 ** Math.min(failures++, 5), 5000));
279
+ }
280
+ catch (sleepError) {
281
+ if (sleepError.code !== "POLL_DEADLINE")
282
+ throw sleepError;
283
+ }
284
+ }
285
+ }
286
+ }
287
+ export async function readUntilDeadline({ body, decoder, deadline = createDeadline(60), discover, diagnostic = () => { }, route }) {
288
+ let failures = 0;
289
+ for (;;) {
290
+ try {
291
+ deadline.check();
292
+ return await conversationOnce(await discover(deadline), body, decoder, deadline, route);
293
+ }
294
+ catch (error) {
295
+ if (error.code === "POLL_DEADLINE" || deadline.remaining() <= 0)
296
+ throw error;
297
+ if (!isRecoverableTransportError(error))
298
+ throw error;
299
+ diagnostic(`Read interrupted (${error.message}); retrying.\n`);
300
+ await deadline.sleep(Math.min(250 * 2 ** Math.min(failures++, 5), 5000));
301
+ }
302
+ }
303
+ }
@@ -95,3 +95,54 @@ export function alignedCardPosition(rects, { frameRect, viewport, width = 300, h
95
95
  top: Math.max(viewport.top + 12, Math.min(viewport.top + viewport.height - height - 12, top)),
96
96
  };
97
97
  }
98
+ /** Popovers may cover unselected prose, never any visible part of their target. */
99
+ function placeLocalSurface(geometry, { frameRect, viewport, surfaceWidth, surfaceHeight, minHeight = surfaceHeight, toolbarHeight = 48 }) {
100
+ if (!geometry || geometry.relation === "unavailable")
101
+ return null;
102
+ const bounds = { left: viewport.left + GAP, right: viewport.left + viewport.width - GAP,
103
+ top: viewport.top + toolbarHeight + GAP, bottom: viewport.top + viewport.height - GAP };
104
+ const width = Math.min(surfaceWidth, bounds.right - bounds.left);
105
+ if (width < 240 || bounds.bottom - bounds.top < minHeight)
106
+ return null;
107
+ const height = Math.min(surfaceHeight, bounds.bottom - bounds.top);
108
+ const clip = geometry.clip ? frameRectToChrome(geometry.clip, frameRect) : bounds;
109
+ const rects = geometry.rects.map(rect => frameRectToChrome(rect, frameRect)).map(rect => ({
110
+ left: Math.max(rect.left, clip.left, bounds.left - GAP), right: Math.min(rect.right, clip.right, bounds.right + GAP),
111
+ top: Math.max(rect.top, clip.top, bounds.top - GAP), bottom: Math.min(rect.bottom, clip.bottom, bounds.bottom + GAP),
112
+ })).filter(rect => rect.right > rect.left && rect.bottom > rect.top);
113
+ if (!rects.length) {
114
+ if (!["above", "below"].includes(geometry.relation))
115
+ return null;
116
+ const horizontal = Number.isFinite(geometry.horizontal) ? frameRect.left + geometry.horizontal : clip.left;
117
+ return { kind: geometry.relation === "above" ? "edge-top" : "edge-bottom",
118
+ left: clamp(horizontal, bounds.left, bounds.right - width),
119
+ top: clamp(geometry.relation === "above" ? clip.top + GAP : clip.bottom - height - GAP, bounds.top, bounds.bottom - height),
120
+ width, height };
121
+ }
122
+ const target = { left: Math.min(...rects.map(rect => rect.left)), right: Math.max(...rects.map(rect => rect.right)),
123
+ top: Math.min(...rects.map(rect => rect.top)), bottom: Math.max(...rects.map(rect => rect.bottom)) };
124
+ const x = clamp(target.left, bounds.left, bounds.right - width);
125
+ const y = clamp(target.top, bounds.top, bounds.bottom - height);
126
+ const below = Math.min(height, bounds.bottom - target.bottom - GAP);
127
+ const above = Math.min(height, target.top - GAP - bounds.top);
128
+ const candidates = [
129
+ { left: target.right + GAP, top: y, height },
130
+ { left: target.left - width - GAP, top: y, height },
131
+ { left: x, top: target.bottom + GAP, height: below },
132
+ { left: x, top: target.top - GAP - above, height: above },
133
+ ];
134
+ const fit = candidates.find(candidate => candidate.height >= minHeight &&
135
+ candidate.left >= bounds.left && candidate.left + width <= bounds.right &&
136
+ candidate.top >= bounds.top && candidate.top + candidate.height <= bounds.bottom &&
137
+ rects.every(rect => candidate.left >= rect.right + GAP || candidate.left + width <= rect.left - GAP ||
138
+ candidate.top >= rect.bottom + GAP || candidate.top + candidate.height <= rect.top - GAP));
139
+ return fit ? { kind: "attached", ...fit, width } : null;
140
+ }
141
+ export function placeNewMessageSurface(geometry, options) {
142
+ return placeLocalSurface(geometry, options);
143
+ }
144
+ export function placeConversationSurface(state, { frameRect, viewport, toolbarHeight = 48, width = 360, height = 260, minHeight = 160 }) {
145
+ if (state?.state !== "found")
146
+ return null;
147
+ return placeLocalSurface(state, { frameRect, viewport, toolbarHeight, surfaceWidth: width, surfaceHeight: height, minHeight });
148
+ }