@agent-native/core 0.120.3 → 0.120.4
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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +8 -0
- package/corpus/core/package.json +1 -1
- package/corpus/templates/content/actions/update-document.ts +145 -91
- package/corpus/templates/content/app/components/editor/CommentComposer.tsx +4 -1
- package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +366 -164
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +51 -28
- package/corpus/templates/content/app/components/editor/database/sidebar.tsx +19 -8
- package/corpus/templates/content/app/i18n-data.ts +15 -0
- package/corpus/templates/content/changelog/2026-07-23-shared-pages-now-keep-durable-content-visible-and-comment-th.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-comment-drafts-now-stay-open-when-saving-fails-and-long-live.md +6 -0
- package/corpus/templates/design/actions/update-file.ts +85 -18
- package/corpus/templates/design/app/lib/design-save-outbox.ts +39 -0
- package/corpus/toolkit/CHANGELOG.md +6 -0
- package/corpus/toolkit/package.json +1 -1
- package/corpus/toolkit/src/editor/useCollabReconcile.ts +83 -15
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/package.json +2 -2
|
@@ -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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
109
|
-
):
|
|
109
|
+
layoutContainer: HTMLElement | null,
|
|
110
|
+
): CommentThreadPosition | null {
|
|
110
111
|
if (!scrollContainer) return null;
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
171
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
468
|
+
setThreadPositions((prev) => (prev.size === 0 ? prev : new Map()));
|
|
323
469
|
setPendingOffset((prev) => {
|
|
324
|
-
const next =
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
332
|
-
const
|
|
478
|
+
const layoutContainer = alignToAnchors ? sidebarRef.current : null;
|
|
479
|
+
const positions = new Map<string, CommentThreadPosition>();
|
|
333
480
|
for (const thread of openThreads) {
|
|
334
|
-
const
|
|
481
|
+
const position = findThreadPosition(
|
|
335
482
|
thread.threadId,
|
|
336
483
|
thread.quotedText,
|
|
337
484
|
container,
|
|
338
|
-
|
|
485
|
+
layoutContainer,
|
|
339
486
|
);
|
|
340
|
-
if (
|
|
487
|
+
if (position) positions.set(thread.threadId, position);
|
|
341
488
|
}
|
|
342
|
-
const nextPendingOffset =
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
489
|
+
const nextPendingOffset =
|
|
490
|
+
pendingComment && alignToAnchors
|
|
491
|
+
? findPendingCommentOffset(container, layoutContainer)
|
|
492
|
+
: null;
|
|
493
|
+
setThreadPositions((prev) => {
|
|
346
494
|
if (
|
|
347
|
-
prev.size ===
|
|
348
|
-
[...
|
|
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
|
|
506
|
+
return positions;
|
|
353
507
|
});
|
|
354
508
|
setPendingOffset((prev) =>
|
|
355
509
|
prev === nextPendingOffset ? prev : nextPendingOffset,
|
|
356
510
|
);
|
|
357
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
423
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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-
|
|
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=
|
|
502
|
-
|
|
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((
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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={
|
|
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} />
|