@agent-native/core 0.120.3 → 0.121.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 (71) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +22 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/use-db-sync.ts +23 -21
  5. package/corpus/core/src/deploy/build.ts +1 -0
  6. package/corpus/core/src/server/core-routes-plugin.ts +3 -0
  7. package/corpus/core/src/server/gateway-access-check.ts +67 -0
  8. package/corpus/core/src/server/poll.ts +295 -9
  9. package/corpus/core/src/server/short-lived-token.ts +122 -0
  10. package/corpus/templates/content/actions/update-document.ts +145 -91
  11. package/corpus/templates/content/app/components/editor/CommentComposer.tsx +4 -1
  12. package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +366 -164
  13. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +51 -28
  14. package/corpus/templates/content/app/components/editor/database/sidebar.tsx +19 -8
  15. package/corpus/templates/content/app/i18n-data.ts +15 -0
  16. package/corpus/templates/content/changelog/2026-07-23-shared-pages-now-keep-durable-content-visible-and-comment-th.md +6 -0
  17. package/corpus/templates/content/changelog/2026-07-24-comment-drafts-now-stay-open-when-saving-fails-and-long-live.md +6 -0
  18. package/corpus/templates/design/actions/update-file.ts +85 -18
  19. package/corpus/templates/design/app/lib/design-save-outbox.ts +39 -0
  20. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +29 -4
  21. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +365 -9
  22. package/corpus/templates/slides/app/components/editor/bullet-editing.ts +350 -0
  23. package/corpus/templates/slides/app/context/DeckContext.tsx +1 -3
  24. package/corpus/templates/slides/app/i18n/en-US.ts +2 -0
  25. package/corpus/templates/slides/app/pages/DeckEditor.tsx +5 -0
  26. package/corpus/templates/slides/changelog/2026-07-23-new-bullet-rows-created-with-enter-now-keep-the-list-item-s-.md +6 -0
  27. package/corpus/templates/slides/changelog/2026-07-23-pressing-enter-in-generated-checkbox-shape-marker-lists-now-.md +6 -0
  28. package/corpus/templates/slides/changelog/2026-07-24-typing-a-markdown-style-dash-space-at-the-start-of-a-text-bl.md +6 -0
  29. package/corpus/toolkit/CHANGELOG.md +6 -0
  30. package/corpus/toolkit/package.json +1 -1
  31. package/corpus/toolkit/src/editor/useCollabReconcile.ts +83 -15
  32. package/dist/client/use-db-sync.d.ts.map +1 -1
  33. package/dist/client/use-db-sync.js +27 -22
  34. package/dist/client/use-db-sync.js.map +1 -1
  35. package/dist/collab/awareness.d.ts +2 -2
  36. package/dist/collab/awareness.d.ts.map +1 -1
  37. package/dist/deploy/build.d.ts.map +1 -1
  38. package/dist/deploy/build.js +1 -0
  39. package/dist/deploy/build.js.map +1 -1
  40. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  41. package/dist/notifications/routes.d.ts +1 -1
  42. package/dist/observability/routes.d.ts +3 -3
  43. package/dist/progress/routes.d.ts +1 -1
  44. package/dist/provider-api/actions/custom-provider-registration.d.ts +4 -4
  45. package/dist/provider-api/actions/provider-api.d.ts +6 -6
  46. package/dist/resources/handlers.d.ts +1 -1
  47. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  48. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  49. package/dist/server/core-routes-plugin.js +3 -0
  50. package/dist/server/core-routes-plugin.js.map +1 -1
  51. package/dist/server/gateway-access-check.d.ts +12 -0
  52. package/dist/server/gateway-access-check.d.ts.map +1 -0
  53. package/dist/server/gateway-access-check.js +49 -0
  54. package/dist/server/gateway-access-check.js.map +1 -0
  55. package/dist/server/poll.d.ts +55 -1
  56. package/dist/server/poll.d.ts.map +1 -1
  57. package/dist/server/poll.js +254 -7
  58. package/dist/server/poll.js.map +1 -1
  59. package/dist/server/realtime-token.d.ts +1 -1
  60. package/dist/server/short-lived-token.d.ts +28 -0
  61. package/dist/server/short-lived-token.d.ts.map +1 -1
  62. package/dist/server/short-lived-token.js +78 -0
  63. package/dist/server/short-lived-token.js.map +1 -1
  64. package/dist/server/transcribe-voice.d.ts +1 -1
  65. package/package.json +2 -2
  66. package/src/client/use-db-sync.ts +23 -21
  67. package/src/deploy/build.ts +1 -0
  68. package/src/server/core-routes-plugin.ts +3 -0
  69. package/src/server/gateway-access-check.ts +67 -0
  70. package/src/server/poll.ts +295 -9
  71. package/src/server/short-lived-token.ts +122 -0
@@ -9,6 +9,7 @@ import {
9
9
  IconChevronDown,
10
10
  } from "@tabler/icons-react";
11
11
  import {
12
+ Fragment,
12
13
  useState,
13
14
  useRef,
14
15
  useEffect,
@@ -16,6 +17,7 @@ import {
16
17
  useCallback,
17
18
  type RefObject,
18
19
  } from "react";
20
+ import { toast } from "sonner";
19
21
 
20
22
  import {
21
23
  Tooltip,
@@ -95,29 +97,35 @@ function cssEscape(value: string) {
95
97
  : value.replace(/["\\]/g, "\\$&");
96
98
  }
97
99
 
98
- /**
99
- * Y offset (relative to the scroll container's visible viewport) of a thread's inline
100
- * highlight. Prefers the real decoration element rendered by the
101
- * CommentHighlight plugin (`[data-comment-thread]`); falls back to a text search
102
- * by quoted text for legacy/unresolved anchors so older comments still line up.
103
- */
104
- export function findThreadOffset(
100
+ export type CommentThreadPosition = {
101
+ documentTop: number;
102
+ layoutTop: number | null;
103
+ };
104
+
105
+ export function findThreadPosition(
105
106
  threadId: string,
106
107
  quotedText: string | null,
107
108
  scrollContainer: HTMLElement | null,
108
- positionContainer: HTMLElement | null = scrollContainer,
109
- ): number | null {
109
+ layoutContainer: HTMLElement | null,
110
+ ): CommentThreadPosition | null {
110
111
  if (!scrollContainer) return null;
111
- const containerRect = (
112
- positionContainer ?? scrollContainer
113
- ).getBoundingClientRect();
112
+ const documentContent =
113
+ (scrollContainer.querySelector(
114
+ "[data-document-scroll-content]",
115
+ ) as HTMLElement | null) ?? scrollContainer;
116
+ const documentRect = documentContent.getBoundingClientRect();
114
117
 
115
118
  const marked = scrollContainer.querySelector(
116
119
  `[data-comment-thread="${cssEscape(threadId)}"]`,
117
120
  ) as HTMLElement | null;
118
121
  if (marked) {
119
122
  const rect = marked.getBoundingClientRect();
120
- return rect.top - containerRect.top;
123
+ return {
124
+ documentTop: rect.top - documentRect.top,
125
+ layoutTop: layoutContainer
126
+ ? rect.top - layoutContainer.getBoundingClientRect().top
127
+ : null,
128
+ };
121
129
  }
122
130
 
123
131
  if (!quotedText) return null;
@@ -135,7 +143,12 @@ export function findThreadOffset(
135
143
  const range = window.document.createRange();
136
144
  range.selectNode(node);
137
145
  const rect = range.getBoundingClientRect();
138
- return rect.top - containerRect.top;
146
+ return {
147
+ documentTop: rect.top - documentRect.top,
148
+ layoutTop: layoutContainer
149
+ ? rect.top - layoutContainer.getBoundingClientRect().top
150
+ : null,
151
+ };
139
152
  }
140
153
  }
141
154
  return null;
@@ -157,18 +170,128 @@ export function findPendingCommentOffset(
157
170
  return rect.top - containerRect.top;
158
171
  }
159
172
 
160
- export function shouldClearSelectedThreadOnScroll(
161
- offset: number | null,
162
- containerHeight: number,
163
- margin = 40,
164
- ) {
165
- return (
166
- offset == null || offset < -margin || offset > containerHeight + margin
173
+ export function estimateThreadCardHeight(thread: CommentThread) {
174
+ return 80 + Math.max(0, thread.comments.length - 1) * 44;
175
+ }
176
+
177
+ type CommentLayoutItem = {
178
+ thread: CommentThread;
179
+ top: number;
180
+ marginTop: number;
181
+ anchorTop: number | null;
182
+ isOrphaned: boolean;
183
+ };
184
+
185
+ export function layoutCommentThreads(
186
+ threads: CommentThread[],
187
+ positions: Map<string, CommentThreadPosition>,
188
+ heights: Map<string, number>,
189
+ selectedThreadId: string | null | undefined,
190
+ gap = 12,
191
+ ): CommentLayoutItem[] {
192
+ const ordered = [...threads].sort((left, right) => {
193
+ const leftTop = positions.get(left.threadId)?.documentTop ?? Infinity;
194
+ const rightTop = positions.get(right.threadId)?.documentTop ?? Infinity;
195
+ return leftTop - rightTop;
196
+ });
197
+ const anchored = ordered.filter(
198
+ (thread) => positions.get(thread.threadId)?.layoutTop != null,
167
199
  );
200
+ const sequential = ordered.filter(
201
+ (thread) => positions.get(thread.threadId)?.layoutTop == null,
202
+ );
203
+ const tops = new Map<string, number>();
204
+ const heightFor = (thread: CommentThread) =>
205
+ heights.get(thread.threadId) ?? estimateThreadCardHeight(thread);
206
+ const selectedIndex = anchored.findIndex(
207
+ (thread) => thread.threadId === selectedThreadId,
208
+ );
209
+
210
+ if (selectedIndex >= 0) {
211
+ const selected = anchored[selectedIndex];
212
+ tops.set(
213
+ selected.threadId,
214
+ Math.max(0, positions.get(selected.threadId)?.layoutTop ?? 0),
215
+ );
216
+ for (let index = selectedIndex - 1; index >= 0; index -= 1) {
217
+ const thread = anchored[index];
218
+ const next = anchored[index + 1];
219
+ const nextTop = tops.get(next.threadId) ?? 0;
220
+ const target = positions.get(thread.threadId)?.layoutTop ?? 0;
221
+ tops.set(
222
+ thread.threadId,
223
+ Math.min(target, nextTop - gap - heightFor(thread)),
224
+ );
225
+ }
226
+ const firstTop = tops.get(anchored[0]?.threadId ?? "") ?? 0;
227
+ if (firstTop < 0) {
228
+ for (let index = 0; index <= selectedIndex; index += 1) {
229
+ const thread = anchored[index];
230
+ tops.set(thread.threadId, (tops.get(thread.threadId) ?? 0) - firstTop);
231
+ }
232
+ }
233
+ for (let index = selectedIndex + 1; index < anchored.length; index += 1) {
234
+ const thread = anchored[index];
235
+ const previous = anchored[index - 1];
236
+ const previousBottom =
237
+ (tops.get(previous.threadId) ?? 0) + heightFor(previous);
238
+ const target = positions.get(thread.threadId)?.layoutTop ?? 0;
239
+ tops.set(thread.threadId, Math.max(target, previousBottom + gap));
240
+ }
241
+ } else {
242
+ let cursor = 0;
243
+ for (const thread of anchored) {
244
+ const target = positions.get(thread.threadId)?.layoutTop ?? 0;
245
+ const top = Math.max(target, cursor === 0 ? 0 : cursor + gap);
246
+ tops.set(thread.threadId, top);
247
+ cursor = top + heightFor(thread);
248
+ }
249
+ }
250
+
251
+ let cursor = anchored.reduce(
252
+ (bottom, thread) =>
253
+ Math.max(bottom, (tops.get(thread.threadId) ?? 0) + heightFor(thread)),
254
+ 0,
255
+ );
256
+ for (const thread of sequential) {
257
+ const sectionGap =
258
+ positions.get(thread.threadId)?.layoutTop != null ? gap : gap + 20;
259
+ const top = cursor === 0 ? 0 : cursor + sectionGap;
260
+ tops.set(thread.threadId, top);
261
+ cursor = top + heightFor(thread);
262
+ }
263
+
264
+ let previousBottom = 0;
265
+ return ordered.map((thread) => {
266
+ const top = tops.get(thread.threadId) ?? previousBottom;
267
+ const position = positions.get(thread.threadId);
268
+ const item = {
269
+ thread,
270
+ top,
271
+ marginTop: Math.max(0, top - previousBottom),
272
+ anchorTop: position?.layoutTop ?? null,
273
+ isOrphaned: !position,
274
+ };
275
+ previousBottom = top + heightFor(thread);
276
+ return item;
277
+ });
168
278
  }
169
279
 
170
- export function estimateThreadCardHeight(thread: CommentThread) {
171
- return 80 + Math.max(0, thread.comments.length - 1) * 44;
280
+ export function scrollToCommentAnchor(
281
+ scrollContainer: HTMLElement | null,
282
+ documentTop: number | null | undefined,
283
+ topPadding = 72,
284
+ ) {
285
+ if (!scrollContainer || documentTop == null) return false;
286
+ const maxScrollTop = Math.max(
287
+ 0,
288
+ scrollContainer.scrollHeight - scrollContainer.clientHeight,
289
+ );
290
+ scrollContainer.scrollTo({
291
+ top: Math.min(maxScrollTop, Math.max(0, documentTop - topPadding)),
292
+ behavior: "smooth",
293
+ });
294
+ return true;
172
295
  }
173
296
 
174
297
  interface CommentsSidebarProps {
@@ -185,9 +308,11 @@ interface CommentsSidebarProps {
185
308
  scrollContainerRef?: RefObject<HTMLDivElement | null>;
186
309
  activeThreadId?: string | null;
187
310
  selectedThreadId?: string | null;
311
+ onActivateThread?: (id: string) => void;
188
312
  onSelectedThreadChange?: (id: string | null) => void;
189
313
  onHoveredThreadChange?: (id: string | null) => void;
190
314
  currentUserEmail?: string;
315
+ alignToAnchors?: boolean;
191
316
  forceVisible?: boolean;
192
317
  }
193
318
 
@@ -200,9 +325,11 @@ export function CommentsSidebar({
200
325
  scrollContainerRef,
201
326
  activeThreadId,
202
327
  selectedThreadId,
328
+ onActivateThread,
203
329
  onSelectedThreadChange,
204
330
  onHoveredThreadChange,
205
331
  currentUserEmail,
332
+ alignToAnchors = true,
206
333
  forceVisible = false,
207
334
  }: CommentsSidebarProps) {
208
335
  const t = useT();
@@ -240,20 +367,31 @@ export function CommentsSidebar({
240
367
  }, [pendingComment]);
241
368
 
242
369
  const handlePendingSubmit = () => {
243
- if (!pendingText.trim()) return;
244
- createComment.mutate({
245
- documentId,
246
- content: pendingText.trim(),
247
- quotedText: pendingComment?.quotedText,
248
- anchorPrefix: pendingComment?.anchor?.prefix,
249
- anchorSuffix: pendingComment?.anchor?.suffix,
250
- anchorStartOffset: pendingComment?.anchor?.startOffset,
251
- authorName,
252
- mentions: mentionsJsonFor(pendingText, pendingMentions),
253
- });
254
- setPendingText("");
255
- setPendingMentions([]);
256
- onPendingDone?.();
370
+ if (!pendingText.trim() || createComment.isPending) return;
371
+ createComment.mutate(
372
+ {
373
+ documentId,
374
+ content: pendingText.trim(),
375
+ quotedText: pendingComment?.quotedText,
376
+ anchorPrefix: pendingComment?.anchor?.prefix,
377
+ anchorSuffix: pendingComment?.anchor?.suffix,
378
+ anchorStartOffset: pendingComment?.anchor?.startOffset,
379
+ authorName,
380
+ mentions: mentionsJsonFor(pendingText, pendingMentions),
381
+ },
382
+ {
383
+ onSuccess: () => {
384
+ setPendingText("");
385
+ setPendingMentions([]);
386
+ onPendingDone?.();
387
+ },
388
+ onError: (error) => {
389
+ toast.error(t("empty.genericError"), {
390
+ description: error.message,
391
+ });
392
+ },
393
+ },
394
+ );
257
395
  };
258
396
 
259
397
  const handlePendingCancel = () => {
@@ -263,19 +401,30 @@ export function CommentsSidebar({
263
401
  };
264
402
 
265
403
  const handleReply = (threadId: string) => {
266
- if (!replyText.trim()) return;
404
+ if (!replyText.trim() || createComment.isPending) return;
267
405
  const thread = threads?.find((t) => t.threadId === threadId);
268
- createComment.mutate({
269
- documentId,
270
- content: replyText.trim(),
271
- threadId,
272
- parentId: thread?.comments[0]?.id,
273
- authorName,
274
- mentions: mentionsJsonFor(replyText, replyMentions),
275
- });
276
- setReplyText("");
277
- setReplyMentions([]);
278
- setReplyingThreadId(null);
406
+ createComment.mutate(
407
+ {
408
+ documentId,
409
+ content: replyText.trim(),
410
+ threadId,
411
+ parentId: thread?.comments[0]?.id,
412
+ authorName,
413
+ mentions: mentionsJsonFor(replyText, replyMentions),
414
+ },
415
+ {
416
+ onSuccess: () => {
417
+ setReplyText("");
418
+ setReplyMentions([]);
419
+ setReplyingThreadId(null);
420
+ },
421
+ onError: (error) => {
422
+ toast.error(t("empty.genericError"), {
423
+ description: error.message,
424
+ });
425
+ },
426
+ },
427
+ );
279
428
  };
280
429
 
281
430
  const handleSendToAI = (thread: CommentThread) => {
@@ -290,12 +439,9 @@ export function CommentsSidebar({
290
439
  });
291
440
  };
292
441
 
293
- // Y positions for each open thread, derived from the inline highlight rects.
294
- // Recomputed when the thread set changes, when a highlight is (de)emphasized,
295
- // and whenever the editor DOM mutates so cards track edits live.
296
- const [threadOffsets, setThreadOffsets] = useState<Map<string, number>>(
297
- new Map(),
298
- );
442
+ const [threadPositions, setThreadPositions] = useState<
443
+ Map<string, CommentThreadPosition>
444
+ >(new Map());
299
445
  const [threadCardHeights, setThreadCardHeights] = useState<
300
446
  Map<string, number>
301
447
  >(new Map());
@@ -319,60 +465,50 @@ export function CommentsSidebar({
319
465
  const recomputeOffsets = useCallback(() => {
320
466
  const container = scrollContainerRef?.current ?? null;
321
467
  if (!container || openThreads.length === 0) {
322
- setThreadOffsets((prev) => (prev.size === 0 ? prev : new Map()));
468
+ setThreadPositions((prev) => (prev.size === 0 ? prev : new Map()));
323
469
  setPendingOffset((prev) => {
324
- const next = pendingComment
325
- ? findPendingCommentOffset(container, sidebarRef.current ?? container)
326
- : null;
470
+ const next =
471
+ pendingComment && alignToAnchors
472
+ ? findPendingCommentOffset(container, sidebarRef.current)
473
+ : null;
327
474
  return prev === next ? prev : next;
328
475
  });
329
476
  return;
330
477
  }
331
- const positionContainer = sidebarRef.current ?? container;
332
- const offsets = new Map<string, number>();
478
+ const layoutContainer = alignToAnchors ? sidebarRef.current : null;
479
+ const positions = new Map<string, CommentThreadPosition>();
333
480
  for (const thread of openThreads) {
334
- const offset = findThreadOffset(
481
+ const position = findThreadPosition(
335
482
  thread.threadId,
336
483
  thread.quotedText,
337
484
  container,
338
- positionContainer,
485
+ layoutContainer,
339
486
  );
340
- if (offset != null) offsets.set(thread.threadId, offset);
487
+ if (position) positions.set(thread.threadId, position);
341
488
  }
342
- const nextPendingOffset = pendingComment
343
- ? findPendingCommentOffset(container, positionContainer)
344
- : null;
345
- setThreadOffsets((prev) => {
489
+ const nextPendingOffset =
490
+ pendingComment && alignToAnchors
491
+ ? findPendingCommentOffset(container, layoutContainer)
492
+ : null;
493
+ setThreadPositions((prev) => {
346
494
  if (
347
- prev.size === offsets.size &&
348
- [...offsets].every(([k, v]) => prev.get(k) === v)
495
+ prev.size === positions.size &&
496
+ [...positions].every(([key, value]) => {
497
+ const prior = prev.get(key);
498
+ return (
499
+ prior?.documentTop === value.documentTop &&
500
+ prior?.layoutTop === value.layoutTop
501
+ );
502
+ })
349
503
  ) {
350
504
  return prev;
351
505
  }
352
- return offsets;
506
+ return positions;
353
507
  });
354
508
  setPendingOffset((prev) =>
355
509
  prev === nextPendingOffset ? prev : nextPendingOffset,
356
510
  );
357
- if (
358
- selectedThreadId &&
359
- shouldClearSelectedThreadOnScroll(
360
- offsets.get(selectedThreadId) ?? null,
361
- container.clientHeight,
362
- )
363
- ) {
364
- onSelectedThreadChange?.(null);
365
- setReplyingThreadId(null);
366
- setReplyText("");
367
- setReplyMentions([]);
368
- }
369
- }, [
370
- openThreads,
371
- onSelectedThreadChange,
372
- pendingComment,
373
- scrollContainerRef,
374
- selectedThreadId,
375
- ]);
511
+ }, [alignToAnchors, openThreads, pendingComment, scrollContainerRef]);
376
512
 
377
513
  useEffect(() => {
378
514
  const container = scrollContainerRef?.current ?? null;
@@ -392,13 +528,17 @@ export function CommentsSidebar({
392
528
  subtree: true,
393
529
  characterData: true,
394
530
  });
395
- container.addEventListener("scroll", schedule, { passive: true });
531
+ const resizeObserver =
532
+ typeof ResizeObserver === "undefined"
533
+ ? null
534
+ : new ResizeObserver(schedule);
535
+ resizeObserver?.observe(container);
396
536
  window.addEventListener("resize", schedule);
397
537
 
398
538
  return () => {
399
539
  cancelAnimationFrame(raf);
400
540
  observer.disconnect();
401
- container.removeEventListener("scroll", schedule);
541
+ resizeObserver?.disconnect();
402
542
  window.removeEventListener("resize", schedule);
403
543
  };
404
544
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -419,48 +559,27 @@ export function CommentsSidebar({
419
559
  }, [openThreads]);
420
560
 
421
561
  useEffect(() => {
422
- if (!selectedThreadId) return;
423
- if (!openThreads.some((t) => t.threadId === selectedThreadId)) {
562
+ if (
563
+ selectedThreadId &&
564
+ !openThreads.some((thread) => thread.threadId === selectedThreadId)
565
+ ) {
424
566
  onSelectedThreadChange?.(null);
425
567
  setReplyingThreadId(null);
426
568
  setReplyText("");
427
569
  setReplyMentions([]);
428
- return;
429
570
  }
430
- const card = window.document.querySelector(
431
- `[data-thread-card="${cssEscape(selectedThreadId)}"]`,
432
- );
433
- card?.scrollIntoView({ block: "nearest", behavior: "smooth" });
434
571
  }, [onSelectedThreadChange, selectedThreadId, openThreads]);
435
572
 
436
573
  const hasContent =
437
574
  openThreads.length > 0 || !!pendingComment || resolvedThreads.length > 0;
438
575
  if (!hasContent && !isLoading && !forceVisible) return null;
439
576
 
440
- // Sort open threads by their position in the document.
441
- const sortedThreads = [...openThreads].sort((a, b) => {
442
- const aOff = threadOffsets.get(a.threadId) ?? Infinity;
443
- const bOff = threadOffsets.get(b.threadId) ?? Infinity;
444
- return aOff - bOff;
445
- });
446
-
447
- // Stack each card to align with its referenced text without overlapping.
448
- const items: { thread: CommentThread; marginTop: number }[] = [];
449
- let cursor = 0;
450
- for (const thread of sortedThreads) {
451
- const targetTop = threadOffsets.get(thread.threadId);
452
- const marginTop =
453
- targetTop != null
454
- ? Math.max(0, targetTop - cursor)
455
- : cursor === 0
456
- ? 0
457
- : 12;
458
- items.push({ thread, marginTop });
459
- cursor +=
460
- marginTop +
461
- (threadCardHeights.get(thread.threadId) ??
462
- estimateThreadCardHeight(thread));
463
- }
577
+ const items = layoutCommentThreads(
578
+ openThreads,
579
+ threadPositions,
580
+ threadCardHeights,
581
+ selectedThreadId,
582
+ );
464
583
 
465
584
  const handleResolve = (thread: CommentThread) => {
466
585
  resolveComment.mutate({
@@ -487,7 +606,7 @@ export function CommentsSidebar({
487
606
  return (
488
607
  <div
489
608
  ref={sidebarRef}
490
- className="relative w-80 shrink-0 pb-16"
609
+ className="relative w-full min-w-0 shrink-0 pb-16"
491
610
  data-comments-sidebar
492
611
  >
493
612
  {!hasContent && !isLoading ? (
@@ -498,8 +617,16 @@ export function CommentsSidebar({
498
617
  {/* Pending new comment — positioned at the selection Y offset */}
499
618
  {pendingComment && (
500
619
  <div
501
- className="absolute left-2 right-4 rounded-lg bg-popover p-3 shadow-md ring-1 ring-border/50 z-10"
502
- style={{ top: pendingOffset ?? pendingComment.offsetTop }}
620
+ className={
621
+ alignToAnchors
622
+ ? "absolute left-2 right-4 z-10 rounded-lg bg-popover p-3 shadow-md ring-1 ring-border/50"
623
+ : "relative mx-2 mt-3 rounded-lg bg-popover p-3 shadow-md ring-1 ring-border/50"
624
+ }
625
+ style={
626
+ alignToAnchors
627
+ ? { top: pendingOffset ?? pendingComment.offsetTop }
628
+ : undefined
629
+ }
503
630
  >
504
631
  <CommentComposer
505
632
  ref={pendingInputRef}
@@ -513,17 +640,19 @@ export function CommentsSidebar({
513
640
  members={members}
514
641
  placeholder={t("comments.add")}
515
642
  autoFocus
643
+ disabled={createComment.isPending}
516
644
  />
517
645
  <div className="flex justify-end gap-1 mt-1.5">
518
646
  <button
519
647
  onClick={handlePendingCancel}
648
+ disabled={createComment.isPending}
520
649
  className="px-2.5 py-1 text-xs rounded-md text-muted-foreground hover:bg-accent"
521
650
  >
522
651
  {t("comments.cancel")}
523
652
  </button>
524
653
  <button
525
654
  onClick={handlePendingSubmit}
526
- disabled={!pendingText.trim()}
655
+ disabled={!pendingText.trim() || createComment.isPending}
527
656
  className="px-2.5 py-1 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40"
528
657
  >
529
658
  {t("comments.submit")}
@@ -533,41 +662,76 @@ export function CommentsSidebar({
533
662
  )}
534
663
 
535
664
  {/* Open thread cards — positioned to align with their referenced text */}
536
- {items.map(({ thread, marginTop }) => (
537
- <ThreadView
538
- key={thread.threadId}
539
- thread={thread}
540
- marginTop={marginTop}
541
- isActive={activeThreadId === thread.threadId}
542
- isExpanded={replyingThreadId === thread.threadId}
543
- replyText={replyingThreadId === thread.threadId ? replyText : ""}
544
- onHoverChange={(hovered) =>
545
- onHoveredThreadChange?.(hovered ? thread.threadId : null)
546
- }
547
- onExpand={() => {
548
- onSelectedThreadChange?.(thread.threadId);
549
- setReplyingThreadId((current) =>
550
- current === thread.threadId ? null : thread.threadId,
551
- );
552
- setReplyText("");
553
- setReplyMentions([]);
554
- }}
555
- onCollapse={() => {
556
- setReplyingThreadId(null);
557
- onSelectedThreadChange?.(null);
558
- setReplyText("");
559
- setReplyMentions([]);
560
- }}
561
- onReplyChange={setReplyText}
562
- onReplyMentionAdd={(m) => setReplyMentions((prev) => [...prev, m])}
563
- onHeightChange={handleThreadCardHeightChange}
564
- members={members}
565
- onSubmitReply={() => handleReply(thread.threadId)}
566
- onResolve={() => handleResolve(thread)}
567
- onSendToAI={() => handleSendToAI(thread)}
568
- t={t}
569
- />
570
- ))}
665
+ {items.map((item, index) => {
666
+ const { thread, marginTop, top, anchorTop, isOrphaned } = item;
667
+ const isActive = activeThreadId === thread.threadId;
668
+ const startsOrphanedSection =
669
+ isOrphaned &&
670
+ !items.slice(0, index).some((prior) => prior.isOrphaned);
671
+ return (
672
+ <Fragment key={thread.threadId}>
673
+ {alignToAnchors && anchorTop != null ? (
674
+ <CommentConnector
675
+ anchorTop={anchorTop}
676
+ cardTop={top}
677
+ active={isActive}
678
+ />
679
+ ) : null}
680
+ {startsOrphanedSection ? (
681
+ <div
682
+ className="absolute inset-x-2 flex items-center gap-2 text-[11px] text-muted-foreground"
683
+ style={{ top: Math.max(0, top - 20) }}
684
+ data-unanchored-comments
685
+ >
686
+ <span className="h-px flex-1 bg-border" />
687
+ <span>{t("comments.unanchored")}</span>
688
+ <span className="h-px flex-1 bg-border" />
689
+ </div>
690
+ ) : null}
691
+ <ThreadView
692
+ thread={thread}
693
+ marginTop={marginTop}
694
+ isActive={isActive}
695
+ isExpanded={replyingThreadId === thread.threadId}
696
+ isSubmitting={createComment.isPending}
697
+ replyText={replyingThreadId === thread.threadId ? replyText : ""}
698
+ onHoverChange={(hovered) =>
699
+ onHoveredThreadChange?.(hovered ? thread.threadId : null)
700
+ }
701
+ onExpand={() => {
702
+ if (createComment.isPending) return;
703
+ onActivateThread?.(thread.threadId);
704
+ scrollToCommentAnchor(
705
+ scrollContainerRef?.current ?? null,
706
+ threadPositions.get(thread.threadId)?.documentTop,
707
+ );
708
+ setReplyingThreadId((current) =>
709
+ current === thread.threadId ? null : thread.threadId,
710
+ );
711
+ setReplyText("");
712
+ setReplyMentions([]);
713
+ }}
714
+ onCollapse={() => {
715
+ if (createComment.isPending) return;
716
+ setReplyingThreadId(null);
717
+ onSelectedThreadChange?.(null);
718
+ setReplyText("");
719
+ setReplyMentions([]);
720
+ }}
721
+ onReplyChange={setReplyText}
722
+ onReplyMentionAdd={(mention) =>
723
+ setReplyMentions((prev) => [...prev, mention])
724
+ }
725
+ onHeightChange={handleThreadCardHeightChange}
726
+ members={members}
727
+ onSubmitReply={() => handleReply(thread.threadId)}
728
+ onResolve={() => handleResolve(thread)}
729
+ onSendToAI={() => handleSendToAI(thread)}
730
+ t={t}
731
+ />
732
+ </Fragment>
733
+ );
734
+ })}
571
735
 
572
736
  {/* Resolved comments — collapsible, reopenable */}
573
737
  {resolvedThreads.length > 0 && (
@@ -600,11 +764,45 @@ export function CommentsSidebar({
600
764
  );
601
765
  }
602
766
 
767
+ function CommentConnector({
768
+ anchorTop,
769
+ cardTop,
770
+ active,
771
+ }: {
772
+ anchorTop: number;
773
+ cardTop: number;
774
+ active: boolean;
775
+ }) {
776
+ const cardPoint = cardTop + 20;
777
+ if (Math.abs(anchorTop - cardPoint) < 6) return null;
778
+ const top = Math.min(anchorTop, cardPoint);
779
+ const height = Math.abs(anchorTop - cardPoint);
780
+ const colorClass = active ? "border-primary/60" : "border-border";
781
+
782
+ return (
783
+ <div aria-hidden data-comment-connector={active ? "active" : "idle"}>
784
+ <span
785
+ className={`pointer-events-none absolute left-1 w-2 border-t ${colorClass}`}
786
+ style={{ top: anchorTop }}
787
+ />
788
+ <span
789
+ className={`pointer-events-none absolute left-1 border-s ${colorClass}`}
790
+ style={{ top, height }}
791
+ />
792
+ <span
793
+ className={`pointer-events-none absolute left-1 w-2 border-t ${colorClass}`}
794
+ style={{ top: cardPoint }}
795
+ />
796
+ </div>
797
+ );
798
+ }
799
+
603
800
  function ThreadView({
604
801
  thread,
605
802
  marginTop,
606
803
  isActive,
607
804
  isExpanded,
805
+ isSubmitting,
608
806
  replyText,
609
807
  members,
610
808
  onHoverChange,
@@ -622,6 +820,7 @@ function ThreadView({
622
820
  marginTop: number;
623
821
  isActive: boolean;
624
822
  isExpanded: boolean;
823
+ isSubmitting: boolean;
625
824
  replyText: string;
626
825
  members: MentionMember[];
627
826
  onHoverChange: (hovered: boolean) => void;
@@ -667,7 +866,9 @@ function ThreadView({
667
866
  : "ring-1 ring-border/50 hover:ring-border"
668
867
  }`}
669
868
  style={{ marginTop }}
670
- onClick={onExpand}
869
+ onClick={() => {
870
+ if (!isSubmitting) onExpand();
871
+ }}
671
872
  onMouseEnter={() => onHoverChange(true)}
672
873
  onMouseLeave={() => onHoverChange(false)}
673
874
  >
@@ -754,13 +955,14 @@ function ThreadView({
754
955
  onEscape={onCollapse}
755
956
  members={members}
756
957
  placeholder={t("comments.reply")}
958
+ disabled={isSubmitting}
757
959
  rows={1}
758
960
  className="w-full resize-none bg-transparent text-sm placeholder:text-muted-foreground/50 focus:outline-none pr-16"
759
961
  />
760
962
  <div className="absolute right-1 bottom-0.5 flex items-center gap-0.5">
761
963
  <button
762
964
  onClick={onSubmitReply}
763
- disabled={!replyText.trim()}
965
+ disabled={!replyText.trim() || isSubmitting}
764
966
  className="p-1 rounded-full text-muted-foreground/40 hover:text-foreground disabled:opacity-30"
765
967
  >
766
968
  <IconArrowUp size={16} />