@agent-native/core-corpus 0.217.0 → 0.219.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 (81) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/hubspot/SKILL.md +5 -3
  3. package/corpus/templates/analytics/CHANGELOG.md +2 -0
  4. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +38 -167
  5. package/corpus/templates/analytics/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  6. package/corpus/templates/assets/CHANGELOG.md +6 -0
  7. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +51 -117
  8. package/corpus/templates/assets/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  9. package/corpus/templates/brain/app/components/layout/Sidebar.tsx +34 -101
  10. package/corpus/templates/brain/app/routes/sources.tsx +1 -1
  11. package/corpus/templates/brain/vitest.config.ts +7 -0
  12. package/corpus/templates/calendar/CHANGELOG.md +6 -0
  13. package/corpus/templates/calendar/app/pages/BookingLinksPage.tsx +1 -1
  14. package/corpus/templates/calendar/changelog/2026-10-16-booking-links-show-localized-visibility-badges.md +6 -0
  15. package/corpus/templates/chat/app/components/layout/Sidebar.tsx +62 -101
  16. package/corpus/templates/clips/CHANGELOG.md +7 -20
  17. package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +20 -77
  18. package/corpus/templates/clips/app/components/player/share-dialog.tsx +63 -90
  19. package/corpus/templates/clips/app/components/sharing/share-test-support.tsx +180 -0
  20. package/corpus/templates/clips/app/components/sharing/share-ui.tsx +5 -584
  21. package/corpus/templates/clips/changelog/2026-10-16-share-dialogs-make-it-easier-to-see-who-has-access-and-manag.md +6 -0
  22. package/corpus/templates/clips/changelog/2026-10-21-share-panels-describe-general-access-in-a-sentence.md +6 -0
  23. package/corpus/templates/community/qr/package.json +1 -1
  24. package/corpus/templates/content/CHANGELOG.md +7 -0
  25. package/corpus/templates/content/app/components/editor/CommentStack.tsx +301 -0
  26. package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +770 -179
  27. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +75 -27
  28. package/corpus/templates/content/app/components/editor/SuggestionText.tsx +18 -3
  29. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +89 -22
  30. package/corpus/templates/content/app/components/editor/block-field-comment-threads.ts +15 -0
  31. package/corpus/templates/content/app/components/editor/block-field-comments.tsx +5 -9
  32. package/corpus/templates/content/app/components/editor/comment-stacks.ts +143 -0
  33. package/corpus/templates/content/app/components/editor/extensions/SuggestionHighlight.ts +383 -34
  34. package/corpus/templates/content/app/components/editor/suggestions/draft-session.ts +31 -0
  35. package/corpus/templates/content/app/components/editor/suggestions/presentation-rebase.ts +82 -4
  36. package/corpus/templates/content/app/global.css +31 -0
  37. package/corpus/templates/content/app/i18n-data.ts +8 -0
  38. package/corpus/templates/content/changelog/2026-10-16-accepting-a-suggestion-on-a-page-that-changed-since-it-was-s.md +6 -0
  39. package/corpus/templates/content/changelog/2026-10-20-suggested-edits-that-add-remove-split-or-merge-paragraphs-li.md +6 -0
  40. package/corpus/templates/content/changelog/2026-10-22-blocks-with-several-comment-threads-fold-into-one-stack-in-t.md +6 -0
  41. package/corpus/templates/content/changelog/2026-10-22-opening-a-page-no-longer-saves-an-edit-again-after-another-o.md +6 -0
  42. package/corpus/templates/content/docs/product/capabilities/content.comment.page-owned.md +8 -1
  43. package/corpus/templates/content/shared/suggestion-diff.ts +23 -6
  44. package/corpus/templates/content/shared/suggestion-formatting.ts +44 -14
  45. package/corpus/templates/content/shared/suggestion-text.ts +20 -5
  46. package/corpus/templates/design/.generated/bridge/interact-links.generated.ts +8 -8
  47. package/corpus/templates/design/CHANGELOG.md +8 -0
  48. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +18 -1
  49. package/corpus/templates/design/app/components/design/bridge/interact-links.bridge.ts +8 -8
  50. package/corpus/templates/design/app/components/editor/PromptDialog.tsx +15 -13
  51. package/corpus/templates/design/app/i18n/en-US.ts +1 -1
  52. package/corpus/templates/design/app/pages/DesignSystems.tsx +1 -1
  53. package/corpus/templates/design/app/pages/Index.tsx +18 -64
  54. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-modes.ts +15 -6
  55. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-screen-rendering.tsx +29 -5
  56. package/corpus/templates/design/app/pages/design-editor/prototype-navigation-transition.ts +423 -0
  57. package/corpus/templates/design/app/pages/design-editor/view/single-screen-canvas.tsx +35 -9
  58. package/corpus/templates/design/changelog/2026-10-16-design-pages-use-localized-visibility-badges-and-a-shared-ow.md +6 -0
  59. package/corpus/templates/design/changelog/2026-10-20-the-design-full-app-toggle-on-the-home-page-no-longer-hides-.md +6 -0
  60. package/corpus/templates/design/changelog/2026-10-22-prototype-links-can-play-code-authored-screen-transitions.md +6 -0
  61. package/corpus/templates/design/shared/motion-authored-css-import.ts +938 -7
  62. package/corpus/templates/design/shared/motion-html-source.ts +1689 -135
  63. package/corpus/templates/design/shared/motion-prototype-transition.ts +329 -0
  64. package/corpus/templates/design/shared/motion-react-source.ts +2154 -126
  65. package/corpus/templates/design/shared/motion-text-runtime-v1.fixture.txt +221 -0
  66. package/corpus/templates/dispatch/CHANGELOG.md +4 -0
  67. package/corpus/templates/dispatch/app/i18n-data.ts +75 -0
  68. package/corpus/templates/dispatch/changelog/2026-10-21-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  69. package/corpus/templates/factory/app/components/layout/Sidebar.tsx +34 -100
  70. package/corpus/templates/forms/app/pages/FormsListPage.tsx +1 -1
  71. package/corpus/templates/plan/CHANGELOG.md +6 -0
  72. package/corpus/templates/plan/app/components/layout/Sidebar.tsx +34 -109
  73. package/corpus/templates/plan/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  74. package/corpus/templates/slides/CHANGELOG.md +1 -0
  75. package/corpus/templates/slides/app/components/deck/DeckCard.tsx +1 -1
  76. package/corpus/templates/slides/app/components/design-system/DesignSystemCard.tsx +1 -1
  77. package/corpus/templates/slides/app/pages/Index.tsx +7 -2
  78. package/corpus/templates/slides/changelog/2026-10-16-deck-lists-use-the-shared-ownership-filter-and-visibility-ba.md +6 -0
  79. package/package.json +1 -1
  80. package/corpus/templates/chat/app/lib/sidebar-thread-state.ts +0 -29
  81. package/corpus/templates/slides/app/components/deck/DeckFilterMenu.tsx +0 -83
@@ -45,7 +45,11 @@ import {
45
45
  findReplaceSuggestionSummary,
46
46
  suggestionDiffParts,
47
47
  } from "@shared/suggestion-diff";
48
- import type { SuggestionPresentationContext } from "@shared/suggestion-text";
48
+ import {
49
+ suggestionAnchorText,
50
+ suggestionTextForDisplay,
51
+ type SuggestionPresentationContext,
52
+ } from "@shared/suggestion-text";
49
53
 
50
54
  import {
51
55
  DropdownMenu,
@@ -125,6 +129,22 @@ import {
125
129
  startCommentAiSubmission,
126
130
  type CommentAiController,
127
131
  } from "./comment-ai";
132
+ import {
133
+ commentBlockKey,
134
+ commentStackFront,
135
+ commentStackKey,
136
+ groupCommentStacks,
137
+ measureCommentBlocks,
138
+ type CommentBlockPlacement,
139
+ type CommentStack,
140
+ } from "./comment-stacks";
141
+ import {
142
+ CommentStackCard,
143
+ CommentStackGuide,
144
+ CommentStackHeader,
145
+ CommentStackSheets,
146
+ CommentStackShowAll,
147
+ } from "./CommentStack";
128
148
  import {
129
149
  ReviewAddReactionButton,
130
150
  ReviewCommentMenu,
@@ -197,6 +217,20 @@ function isAmbiguousCommentCreateError(error: unknown) {
197
217
  );
198
218
  }
199
219
 
220
+ /** The session's name for the signed-in person's own entries, if it has one. */
221
+ function ownAuthorName(
222
+ authorEmail: string | null | undefined,
223
+ currentUserEmail?: string,
224
+ currentUserName?: string,
225
+ ) {
226
+ const name = currentUserName?.trim();
227
+ return name &&
228
+ authorEmail &&
229
+ authorEmail.trim().toLowerCase() === currentUserEmail?.trim().toLowerCase()
230
+ ? name
231
+ : null;
232
+ }
233
+
200
234
  function cssEscape(value: string) {
201
235
  return globalThis.CSS?.escape
202
236
  ? globalThis.CSS.escape(value)
@@ -324,6 +358,26 @@ export function estimateThreadCardHeight(thread: ThreadLayoutIdentity) {
324
358
  */
325
359
  export const PENDING_COMMENT_LAYOUT_ID = "pending-comment";
326
360
  const PENDING_COMMENT_CARD_ESTIMATE = 112;
361
+ /** An open stack's Collapse row, laid out like a card above its threads. */
362
+ const STACK_HEADER_LAYOUT_ID = "comment-stack-header";
363
+ const STACK_HEADER_HEIGHT = 28;
364
+ /** How long the pointer rests on a highlight before its thread comes to the front. */
365
+ const STACK_HOVER_INTENT_MS = 180;
366
+ const NO_BLOCKS: ReadonlyMap<string, CommentBlockPlacement> = new Map();
367
+ const NO_TOUCHES: ReadonlyMap<string, number> = new Map();
368
+
369
+ function sameBlocks(
370
+ left: ReadonlyMap<string, CommentBlockPlacement>,
371
+ right: ReadonlyMap<string, CommentBlockPlacement>,
372
+ ) {
373
+ if (left.size !== right.size) return false;
374
+ for (const [threadId, placement] of right) {
375
+ const prior = left.get(threadId);
376
+ if (prior?.key !== placement.key || prior.order !== placement.order)
377
+ return false;
378
+ }
379
+ return true;
380
+ }
327
381
 
328
382
  type CommentLayoutItem<T extends ThreadLayoutIdentity> = {
329
383
  thread: T;
@@ -756,7 +810,11 @@ interface CommentsSidebarOptions {
756
810
  onActivateSuggestion?: (id: string) => void;
757
811
  onSelectedThreadChange?: (id: string | null) => void;
758
812
  onHoveredThreadChange?: (id: string | null) => void;
813
+ /** The thread whose highlight the pointer is on. */
814
+ hoveredThreadId?: string | null;
759
815
  currentUserEmail?: string;
816
+ /** The signed-in person's display name, for their own suggestions. */
817
+ currentUserName?: string;
760
818
  currentUserOrgId?: string;
761
819
  canComment?: boolean;
762
820
  canResolve?: boolean;
@@ -840,7 +898,9 @@ export function CommentsSidebar({
840
898
  onActivateSuggestion,
841
899
  onSelectedThreadChange,
842
900
  onHoveredThreadChange,
901
+ hoveredThreadId,
843
902
  currentUserEmail,
903
+ currentUserName,
844
904
  currentUserOrgId,
845
905
  canComment = true,
846
906
  canResolve = false,
@@ -1112,36 +1172,254 @@ export function CommentsSidebar({
1112
1172
  )
1113
1173
  setReplyingThreadId(selectedThreadId);
1114
1174
  }, [canComment, presentation, selectedThreadId, selectedThreadIsOpen]);
1115
- const historyAuthors = useMemo(() => {
1116
- const authors = new Map<string, string>();
1117
- for (const suggestion of suggestions) {
1118
- if (suggestion.authorEmail) {
1119
- authors.set(
1120
- suggestion.authorEmail,
1121
- suggestion.authorEmail.split("@")[0],
1122
- );
1123
- }
1175
+
1176
+ // In the margin, a block with two or more open threads folds into one
1177
+ // card. Opening it lays its threads out as cards; one stack opens at once.
1178
+ const stacksEnabled =
1179
+ presentation === "inline" &&
1180
+ surface === "rail" &&
1181
+ alignToAnchors &&
1182
+ !visibleThreadId;
1183
+ const [threadBlocks, setThreadBlocks] = useState(NO_BLOCKS);
1184
+ // The block a new comment's text is in, once measured; `key` is null for
1185
+ // text in no block, such as a Property.
1186
+ const [pendingBlock, setPendingBlock] = useState<{
1187
+ id: symbol;
1188
+ key: string | null;
1189
+ } | null>(null);
1190
+ // The open stack's threads, as last laid out. Whichever stack still holds
1191
+ // one of them is open, so it outlasts its block key shifting and the thread
1192
+ // it was opened on being resolved.
1193
+ const [openStack, setOpenStack] = useState<ReadonlySet<string> | null>(null);
1194
+ // When each thread was last touched, so a folded stack shows that one.
1195
+ // Thread ids are unique across tabs, so a touch never crosses tabs.
1196
+ const [stackTouches, setStackTouches] = useState(NO_TOUCHES);
1197
+ const touchCountRef = useRef(0);
1198
+ const touchThread = useCallback((threadId: string) => {
1199
+ touchCountRef.current += 1;
1200
+ const touch = touchCountRef.current;
1201
+ setStackTouches((prev) => new Map(prev).set(threadId, touch));
1202
+ }, []);
1203
+ // A thread just posted is selected without moving the open stack.
1204
+ const postedThreadIdRef = useRef<string | null>(null);
1205
+ const focusStackKeyRef = useRef<string | null>(null);
1206
+ const commentStacks = useMemo(
1207
+ () => (stacksEnabled ? groupCommentStacks(openThreads, threadBlocks) : []),
1208
+ [openThreads, stacksEnabled, threadBlocks],
1209
+ );
1210
+ const stackOfThread = useMemo(() => {
1211
+ const stacks = new Map<string, CommentStack<CommentThread>>();
1212
+ for (const stack of commentStacks) {
1213
+ for (const member of stack.threads) stacks.set(member.threadId, stack);
1124
1214
  }
1125
- for (const suggestion of draftSuggestions) {
1126
- if (suggestion.authorEmail) {
1127
- authors.set(
1128
- suggestion.authorEmail,
1129
- suggestion.authorEmail.split("@")[0],
1130
- );
1131
- }
1215
+ return stacks;
1216
+ }, [commentStacks]);
1217
+ const stackOfThreadRef = useRef(stackOfThread);
1218
+ stackOfThreadRef.current = stackOfThread;
1219
+ const pendingStackKey =
1220
+ displayedPendingComment && pendingBlock?.id === displayedPendingComment.id
1221
+ ? pendingBlock.key
1222
+ : undefined;
1223
+ const openedStack = openStack
1224
+ ? (commentStacks.find((stack) =>
1225
+ stack.threads.some((member) => openStack.has(member.threadId)),
1226
+ ) ?? null)
1227
+ : null;
1228
+ // A new comment outside the open stack folds it, as does leaving it with
1229
+ // nothing selected.
1230
+ const pendingFoldsOpenStack =
1231
+ pendingStackKey !== undefined &&
1232
+ pendingStackKey !== (openedStack?.key ?? null);
1233
+ const expandedStack =
1234
+ pendingFoldsOpenStack || (!selectedThreadId && !pendingComment)
1235
+ ? null
1236
+ : openedStack;
1237
+ // A new comment on a folded stack opens on top of it.
1238
+ const composingStack =
1239
+ pendingStackKey == null || pendingStackKey === expandedStack?.key
1240
+ ? null
1241
+ : (commentStacks.find((stack) => stack.key === pendingStackKey) ?? null);
1242
+
1243
+ const openStackOn = useCallback((threadId: string) => {
1244
+ const stack = stackOfThreadRef.current.get(threadId);
1245
+ setOpenStack(
1246
+ new Set(
1247
+ stack ? stack.threads.map((member) => member.threadId) : [threadId],
1248
+ ),
1249
+ );
1250
+ }, []);
1251
+ const previousSelectionRef = useRef<string | null>(null);
1252
+ useEffect(() => {
1253
+ const previous = previousSelectionRef.current;
1254
+ previousSelectionRef.current = selectedThreadId ?? null;
1255
+ if (!stacksEnabled || !selectedThreadId || selectedThreadId === previous)
1256
+ return;
1257
+ touchThread(selectedThreadId);
1258
+ const posted = postedThreadIdRef.current === selectedThreadId;
1259
+ postedThreadIdRef.current = null;
1260
+ if (!posted) openStackOn(selectedThreadId);
1261
+ }, [openStackOn, selectedThreadId, stacksEnabled, touchThread]);
1262
+ useEffect(() => {
1263
+ if (!openStack) return;
1264
+ if ((!selectedThreadId && !pendingComment) || pendingFoldsOpenStack) {
1265
+ setOpenStack(null);
1266
+ } else if (openedStack) {
1267
+ const members = openedStack.threads;
1268
+ if (
1269
+ members.length !== openStack.size ||
1270
+ members.some((member) => !openStack.has(member.threadId))
1271
+ )
1272
+ setOpenStack(new Set(members.map((member) => member.threadId)));
1273
+ } else if (
1274
+ // A thread selected before the margin measured its block may still
1275
+ // turn out to stack. Otherwise none of them does, and keeping them
1276
+ // would open a stack their block forms later by itself.
1277
+ !openThreads.some(
1278
+ (thread) =>
1279
+ !thread.resolved &&
1280
+ openStack.has(thread.threadId) &&
1281
+ !threadBlocks.has(thread.threadId),
1282
+ )
1283
+ ) {
1284
+ setOpenStack(null);
1132
1285
  }
1286
+ }, [
1287
+ openStack,
1288
+ openThreads,
1289
+ openedStack,
1290
+ pendingComment,
1291
+ pendingFoldsOpenStack,
1292
+ selectedThreadId,
1293
+ threadBlocks,
1294
+ ]);
1295
+ // A highlight click opens its stack and touches its thread, even one already
1296
+ // selected, whose selection doesn't change.
1297
+ useEffect(() => {
1298
+ const container = scrollContainerRef?.current;
1299
+ if (!stacksEnabled || !container) return;
1300
+ const openFromHighlight = (event: MouseEvent) => {
1301
+ const threadId = (event.target as Element | null)
1302
+ ?.closest?.("[data-comment-thread]")
1303
+ ?.getAttribute("data-comment-thread");
1304
+ if (!threadId || !stackOfThreadRef.current.has(threadId)) return;
1305
+ touchThread(threadId);
1306
+ openStackOn(threadId);
1307
+ };
1308
+ container.addEventListener("click", openFromHighlight);
1309
+ return () => container.removeEventListener("click", openFromHighlight);
1310
+ }, [openStackOn, scrollContainerRef, stacksEnabled, touchThread]);
1311
+ // Resting on a highlight brings its thread to the front of a folded stack;
1312
+ // passing over one on the way to the margin doesn't.
1313
+ useEffect(() => {
1314
+ if (!hoveredThreadId) return;
1315
+ const stack = stackOfThread.get(hoveredThreadId);
1316
+ if (!stack || stack === expandedStack) return;
1317
+ // Touching the stack's last-touched thread again would only rerun this.
1318
+ const touch = stackTouches.get(hoveredThreadId);
1319
+ if (
1320
+ touch !== undefined &&
1321
+ stack.threads.every(
1322
+ (member) => (stackTouches.get(member.threadId) ?? 0) <= touch,
1323
+ )
1324
+ )
1325
+ return;
1326
+ const timer = setTimeout(
1327
+ () => touchThread(hoveredThreadId),
1328
+ STACK_HOVER_INTENT_MS,
1329
+ );
1330
+ return () => clearTimeout(timer);
1331
+ }, [
1332
+ expandedStack,
1333
+ hoveredThreadId,
1334
+ stackOfThread,
1335
+ stackTouches,
1336
+ touchThread,
1337
+ ]);
1338
+ const expandedStackKey = expandedStack?.key ?? null;
1339
+ const selectedInExpandedStack =
1340
+ !!selectedThreadId &&
1341
+ !!expandedStack?.threads.some(
1342
+ (member) => member.threadId === selectedThreadId,
1343
+ );
1344
+ useLayoutEffect(() => {
1345
+ if (!selectedInExpandedStack || !selectedThreadId) return;
1346
+ const card = sidebarRef.current?.querySelector<HTMLElement>(
1347
+ `[data-thread-card="${cssEscape(selectedThreadId)}"]`,
1348
+ );
1349
+ if (!card) return;
1350
+ // Opening from the folded card removes the button that had focus.
1351
+ const active = document.activeElement;
1352
+ if (!active || active === document.body)
1353
+ card.focus({ preventScroll: true });
1354
+ const timer = setTimeout(
1355
+ () => card.scrollIntoView?.({ block: "nearest", behavior: "smooth" }),
1356
+ 300,
1357
+ );
1358
+ return () => clearTimeout(timer);
1359
+ }, [expandedStackKey, selectedInExpandedStack, selectedThreadId]);
1360
+ const pendingInExpandedStack =
1361
+ !!expandedStack && pendingStackKey === expandedStack.key;
1362
+ useLayoutEffect(() => {
1363
+ if (!pendingInExpandedStack) return;
1364
+ // Show all, under a new comment, removes the button that had focus.
1365
+ const active = document.activeElement;
1366
+ if (active && active !== document.body) return;
1367
+ sidebarRef.current
1368
+ ?.querySelector<HTMLElement>(
1369
+ '[data-comment-pending] [contenteditable="true"]',
1370
+ )
1371
+ ?.focus({ preventScroll: true });
1372
+ }, [pendingInExpandedStack]);
1373
+ useLayoutEffect(() => {
1374
+ const key = focusStackKeyRef.current;
1375
+ if (key === null) return;
1376
+ const stack = [
1377
+ ...(sidebarRef.current?.querySelectorAll<HTMLElement>(
1378
+ "[data-comment-stack]",
1379
+ ) ?? []),
1380
+ ].find((element) => element.dataset.commentStack === key);
1381
+ const target = stack?.querySelector<HTMLElement>(
1382
+ "[data-comment-stack-open], [data-comment-stack-show-all]",
1383
+ );
1384
+ if (!target) return;
1385
+ focusStackKeyRef.current = null;
1386
+ target.focus({ preventScroll: true });
1387
+ });
1388
+ const collapseStack = (stack: CommentStack<CommentThread>) => {
1389
+ focusStackKeyRef.current = stack.key;
1390
+ setOpenStack(null);
1391
+ setReplyingThreadId(null);
1392
+ onSelectedThreadChange?.(null);
1393
+ };
1394
+
1395
+ const historyAuthors = useMemo(() => {
1396
+ const authors = new Map<string, string>();
1397
+ // A name from any record outranks the email's local part from another.
1398
+ const addAuthor = (email: string | null, name?: string | null) => {
1399
+ if (!email) return;
1400
+ const known =
1401
+ name?.trim() || ownAuthorName(email, currentUserEmail, currentUserName);
1402
+ if (known) authors.set(email, known);
1403
+ else if (!authors.has(email)) authors.set(email, email.split("@")[0]);
1404
+ };
1405
+ for (const suggestion of suggestions) addAuthor(suggestion.authorEmail);
1406
+ for (const suggestion of draftSuggestions)
1407
+ addAuthor(suggestion.authorEmail);
1133
1408
  for (const thread of threads) {
1134
1409
  for (const comment of thread.comments) {
1135
- authors.set(
1136
- comment.author_email,
1137
- comment.author_name ?? comment.author_email.split("@")[0],
1138
- );
1410
+ addAuthor(comment.author_email, comment.author_name);
1139
1411
  }
1140
1412
  }
1141
1413
  return [...authors.entries()].sort((left, right) =>
1142
1414
  left[1].localeCompare(right[1]),
1143
1415
  );
1144
- }, [draftSuggestions, suggestions, threads]);
1416
+ }, [
1417
+ currentUserEmail,
1418
+ currentUserName,
1419
+ draftSuggestions,
1420
+ suggestions,
1421
+ threads,
1422
+ ]);
1145
1423
  // Suggestions edit the page body, so they sit in its tab.
1146
1424
  const historyBodyTabShown =
1147
1425
  activeHistoryTab === null || activeHistoryTab === commentTabKey(null);
@@ -1320,6 +1598,7 @@ export function CommentsSidebar({
1320
1598
  pendingDraft.beginSubmission(clientOperationId);
1321
1599
  onPendingChange(id, () => ({ submitting: true }));
1322
1600
  setPendingHandoff({ id, operationId: clientOperationId });
1601
+ touchThread(`optimistic-${clientOperationId}`);
1323
1602
  try {
1324
1603
  const result = await createComment.mutateAsync({
1325
1604
  clientOperationId,
@@ -1343,7 +1622,11 @@ export function CommentsSidebar({
1343
1622
  });
1344
1623
  pendingDraft.finishSubmission(clientOperationId);
1345
1624
  setPendingHandoff(null);
1625
+ // The new thread is its stack's front. Selecting it leaves its stack
1626
+ // as it was, open or folded.
1627
+ touchThread(result.threadId);
1346
1628
  if (pendingCommentRef.current?.id === id) {
1629
+ postedThreadIdRef.current = result.threadId;
1347
1630
  onPendingDone(id, result.threadId);
1348
1631
  }
1349
1632
  return { id: result.id, threadId: result.threadId };
@@ -1465,6 +1748,22 @@ export function CommentsSidebar({
1465
1748
  null,
1466
1749
  );
1467
1750
  const pendingCardRef = useRef<HTMLDivElement>(null);
1751
+ // An inline group remounts under its next card once its leading edit is
1752
+ // decided, so whether it is open has to outlive the group.
1753
+ const [expandedProposalIds, setExpandedProposalIds] = useState<
1754
+ ReadonlySet<string>
1755
+ >(() => new Set());
1756
+ const setProposalExpanded = useCallback(
1757
+ (proposalId: string, expanded: boolean) =>
1758
+ setExpandedProposalIds((current) => {
1759
+ if (current.has(proposalId) === expanded) return current;
1760
+ const next = new Set(current);
1761
+ if (expanded) next.add(proposalId);
1762
+ else next.delete(proposalId);
1763
+ return next;
1764
+ }),
1765
+ [],
1766
+ );
1468
1767
  const inlineProposalLeaders = useMemo(() => {
1469
1768
  const leaders = new Map<string, string>();
1470
1769
  for (const proposalId of inlineProposalMembers.keys()) {
@@ -1487,18 +1786,35 @@ export function CommentsSidebar({
1487
1786
  proposalMemberCounts,
1488
1787
  threadPositions,
1489
1788
  ]);
1789
+ // A folded stack is laid out as its first thread; a new comment opened on
1790
+ // top of it takes its place.
1791
+ const stackHiddenIds = useMemo(() => {
1792
+ const hidden = new Set<string>();
1793
+ for (const stack of commentStacks) {
1794
+ if (stack === expandedStack) continue;
1795
+ const folded =
1796
+ stack === composingStack ? stack.threads : stack.threads.slice(1);
1797
+ for (const member of folded) hidden.add(member.threadId);
1798
+ }
1799
+ return hidden;
1800
+ }, [commentStacks, composingStack, expandedStack]);
1490
1801
  const layoutThreads = useMemo(
1491
1802
  () =>
1492
- inlineThreads.filter(
1493
- (thread) =>
1494
- !("suggestion" in thread) ||
1495
- !("proposalId" in thread.suggestion) ||
1496
- !thread.suggestion.proposalId ||
1497
- (proposalMemberCounts.get(thread.suggestion.proposalId) ?? 0) < 2 ||
1498
- inlineProposalLeaders.get(thread.suggestion.proposalId) ===
1499
- thread.threadId,
1803
+ inlineThreads.filter((thread) =>
1804
+ "suggestion" in thread
1805
+ ? !("proposalId" in thread.suggestion) ||
1806
+ !thread.suggestion.proposalId ||
1807
+ (proposalMemberCounts.get(thread.suggestion.proposalId) ?? 0) < 2 ||
1808
+ inlineProposalLeaders.get(thread.suggestion.proposalId) ===
1809
+ thread.threadId
1810
+ : !stackHiddenIds.has(thread.threadId),
1500
1811
  ),
1501
- [inlineThreads, inlineProposalLeaders, proposalMemberCounts],
1812
+ [
1813
+ inlineThreads,
1814
+ inlineProposalLeaders,
1815
+ proposalMemberCounts,
1816
+ stackHiddenIds,
1817
+ ],
1502
1818
  );
1503
1819
  const openThreadKey = inlineThreads
1504
1820
  .map(
@@ -1520,9 +1836,36 @@ export function CommentsSidebar({
1520
1836
  );
1521
1837
 
1522
1838
  const hasPendingComment = !!displayedPendingComment;
1839
+ const pendingCommentId = displayedPendingComment?.id;
1840
+ const pendingFieldId = displayedPendingComment?.fieldId ?? null;
1841
+ const pendingOnProperty = !!displayedPendingComment?.property;
1523
1842
  const recomputeOffsets = useCallback(() => {
1524
1843
  const container = scrollContainerRef?.current ?? null;
1525
1844
  setThreadPositionsMeasured(!!container);
1845
+ const blocks =
1846
+ stacksEnabled && container
1847
+ ? measureCommentBlocks(container, openThreads)
1848
+ : NO_BLOCKS;
1849
+ setThreadBlocks((prev) => (sameBlocks(prev, blocks) ? prev : blocks));
1850
+ if (stacksEnabled && container && pendingCommentId) {
1851
+ const highlight = pendingOnProperty
1852
+ ? null
1853
+ : container.querySelector(".comment-highlight--pending");
1854
+ const block =
1855
+ highlight && !inHiddenBlockFieldPanel(highlight)
1856
+ ? commentBlockKey(highlight)
1857
+ : null;
1858
+ // Text not drawn yet isn't known to be outside the open stack.
1859
+ if (pendingOnProperty || highlight) {
1860
+ const key =
1861
+ block === null ? null : commentStackKey(pendingFieldId, block);
1862
+ setPendingBlock((prev) =>
1863
+ prev?.id === pendingCommentId && prev.key === key
1864
+ ? prev
1865
+ : { id: pendingCommentId, key },
1866
+ );
1867
+ }
1868
+ }
1526
1869
  const layoutContainer = alignToAnchors ? sidebarRef.current : null;
1527
1870
  const nextPendingPosition =
1528
1871
  hasPendingComment && alignToAnchors
@@ -1577,7 +1920,17 @@ export function CommentsSidebar({
1577
1920
  }
1578
1921
  return positions;
1579
1922
  });
1580
- }, [alignToAnchors, hasPendingComment, inlineThreads, scrollContainerRef]);
1923
+ }, [
1924
+ alignToAnchors,
1925
+ hasPendingComment,
1926
+ inlineThreads,
1927
+ openThreads,
1928
+ pendingCommentId,
1929
+ pendingFieldId,
1930
+ pendingOnProperty,
1931
+ scrollContainerRef,
1932
+ stacksEnabled,
1933
+ ]);
1581
1934
 
1582
1935
  // Measured before paint, so a card under a new id never paints unplaced.
1583
1936
  useLayoutEffect(() => {
@@ -1601,14 +1954,18 @@ export function CommentsSidebar({
1601
1954
  subtree: true,
1602
1955
  characterData: true,
1603
1956
  attributes: true,
1604
- attributeFilter: ["hidden"],
1957
+ attributeFilter: ["hidden", "data-comment-thread"],
1605
1958
  });
1606
1959
  }
1607
1960
  } else {
1961
+ // A posted comment's highlight can reuse the draft's span, gaining only
1962
+ // its thread id, and a stack needs that id to place the thread.
1608
1963
  observer.observe(primaryBlockFieldEditor(container) ?? container, {
1609
1964
  childList: true,
1610
1965
  subtree: true,
1611
1966
  characterData: true,
1967
+ attributes: true,
1968
+ attributeFilter: ["data-comment-thread"],
1612
1969
  });
1613
1970
  }
1614
1971
  const resizeObserver =
@@ -1627,7 +1984,6 @@ export function CommentsSidebar({
1627
1984
  // eslint-disable-next-line react-hooks/exhaustive-deps
1628
1985
  }, [openThreadKey, hasPendingComment, recomputeOffsets]);
1629
1986
 
1630
- const pendingCommentId = displayedPendingComment?.id;
1631
1987
  useLayoutEffect(() => {
1632
1988
  const element = pendingCardRef.current;
1633
1989
  if (!element) {
@@ -1678,6 +2034,10 @@ export function CommentsSidebar({
1678
2034
  const activeSavedSuggestion = inlineSuggestions.find(
1679
2035
  (suggestion) => suggestion.id === activeSuggestionId,
1680
2036
  );
2037
+ // A thread in a folded stack is laid out as the stack's card.
2038
+ const selectedStack = selectedThreadId
2039
+ ? stackOfThread.get(selectedThreadId)
2040
+ : undefined;
1681
2041
  const selectedLayoutThreadId =
1682
2042
  (activeSavedSuggestion?.proposalId
1683
2043
  ? inlineProposalLeaders.get(activeSavedSuggestion.proposalId)
@@ -1685,7 +2045,9 @@ export function CommentsSidebar({
1685
2045
  inlineDraftSuggestions.find(
1686
2046
  (suggestion) => suggestion.id === activeSuggestionId,
1687
2047
  )?.threadId ??
1688
- selectedThreadId;
2048
+ (selectedStack && selectedStack !== expandedStack
2049
+ ? selectedStack.threads[0].threadId
2050
+ : selectedThreadId);
1689
2051
  const restingItems = layoutCommentThreads(
1690
2052
  layoutThreads,
1691
2053
  threadPositions,
@@ -1702,32 +2064,105 @@ export function CommentsSidebar({
1702
2064
  : null;
1703
2065
  const pendingAnchorTop =
1704
2066
  pendingPosition?.layoutTop ?? displayedPendingComment?.offsetTop ?? 0;
1705
- const selectedItems = pendingLayout
1706
- ? layoutCommentThreads<LayoutThread | ThreadLayoutIdentity>(
1707
- [...layoutThreads, pendingLayout],
1708
- new Map(threadPositions).set(PENDING_COMMENT_LAYOUT_ID, {
1709
- documentTop: pendingPosition?.documentTop ?? pendingAnchorTop,
1710
- layoutTop: pendingAnchorTop,
1711
- }),
1712
- new Map(threadCardHeights).set(
1713
- PENDING_COMMENT_LAYOUT_ID,
1714
- pendingCardHeight ?? PENDING_COMMENT_CARD_ESTIMATE,
2067
+ const pendingAnchor: CommentThreadPosition = {
2068
+ documentTop: pendingPosition?.documentTop ?? pendingAnchorTop,
2069
+ layoutTop: pendingAnchorTop,
2070
+ };
2071
+ // An open stack's Collapse row is laid out like a card above its first one.
2072
+ const stackHeaderLayout: ThreadLayoutIdentity | null = expandedStack
2073
+ ? { threadId: STACK_HEADER_LAYOUT_ID, comments: [] }
2074
+ : null;
2075
+ const firstStackAnchor = expandedStack
2076
+ ? [
2077
+ ...expandedStack.threads.map((member) =>
2078
+ threadPositions.get(member.threadId),
1715
2079
  ),
1716
- PENDING_COMMENT_LAYOUT_ID,
2080
+ pendingInExpandedStack ? pendingAnchor : undefined,
2081
+ ].reduce<CommentThreadPosition | null>(
2082
+ (first, position) =>
2083
+ position && (!first || position.documentTop < first.documentTop)
2084
+ ? position
2085
+ : first,
2086
+ null,
1717
2087
  )
1718
- : layoutCommentThreads(
1719
- layoutThreads,
1720
- threadPositions,
1721
- threadCardHeights,
1722
- selectedLayoutThreadId,
1723
- );
2088
+ : null;
2089
+ const layoutPositions = new Map(threadPositions);
2090
+ const layoutHeights = new Map(threadCardHeights);
2091
+ if (pendingLayout) {
2092
+ layoutPositions.set(PENDING_COMMENT_LAYOUT_ID, pendingAnchor);
2093
+ layoutHeights.set(
2094
+ PENDING_COMMENT_LAYOUT_ID,
2095
+ pendingCardHeight ?? PENDING_COMMENT_CARD_ESTIMATE,
2096
+ );
2097
+ }
2098
+ if (stackHeaderLayout) {
2099
+ if (firstStackAnchor) {
2100
+ layoutPositions.set(STACK_HEADER_LAYOUT_ID, {
2101
+ documentTop: firstStackAnchor.documentTop - 0.5,
2102
+ layoutTop:
2103
+ firstStackAnchor.layoutTop === null
2104
+ ? null
2105
+ : firstStackAnchor.layoutTop - STACK_HEADER_HEIGHT - 12,
2106
+ });
2107
+ }
2108
+ layoutHeights.set(STACK_HEADER_LAYOUT_ID, STACK_HEADER_HEIGHT);
2109
+ }
2110
+ const anchoredItems = layoutCommentThreads<
2111
+ LayoutThread | ThreadLayoutIdentity
2112
+ >(
2113
+ [
2114
+ ...layoutThreads,
2115
+ ...(pendingLayout ? [pendingLayout] : []),
2116
+ ...(stackHeaderLayout ? [stackHeaderLayout] : []),
2117
+ ],
2118
+ layoutPositions,
2119
+ layoutHeights,
2120
+ pendingLayout ? PENDING_COMMENT_LAYOUT_ID : selectedLayoutThreadId,
2121
+ );
2122
+ // Keeping the selected card level with its highlight pushes the cards above
2123
+ // it upward. Near the top of a page that would put an open stack's Collapse
2124
+ // row and first cards above the margin, where nothing can scroll to them, so
2125
+ // the stack starts at the top instead.
2126
+ const stackOverflow = Math.max(
2127
+ 0,
2128
+ -(
2129
+ anchoredItems.find((item) => item.thread === stackHeaderLayout)?.top ?? 0
2130
+ ),
2131
+ );
2132
+ const selectedItems = stackOverflow
2133
+ ? anchoredItems.map((item) => ({ ...item, top: item.top + stackOverflow }))
2134
+ : anchoredItems;
1724
2135
  const pendingTop =
1725
2136
  selectedItems.find((item) => item.thread === pendingLayout)?.top ??
1726
2137
  pendingAnchorTop;
2138
+ const stackHeaderTop = selectedItems.find(
2139
+ (item) => item.thread === stackHeaderLayout,
2140
+ )?.top;
1727
2141
  const items = selectedItems.filter(
1728
2142
  (item): item is CommentLayoutItem<LayoutThread> =>
1729
- item.thread !== pendingLayout,
2143
+ item.thread !== pendingLayout && item.thread !== stackHeaderLayout,
1730
2144
  );
2145
+ // The guide beside an open stack spans its cards, and a new comment in it.
2146
+ const stackSpans = expandedStack
2147
+ ? selectedItems
2148
+ .filter((item) =>
2149
+ item.thread === pendingLayout
2150
+ ? pendingInExpandedStack
2151
+ : expandedStack.threads.some(
2152
+ (member) => member.threadId === item.thread.threadId,
2153
+ ),
2154
+ )
2155
+ .map((item) => ({
2156
+ top: item.top,
2157
+ bottom:
2158
+ item.top +
2159
+ (layoutHeights.get(item.thread.threadId) ??
2160
+ estimateThreadCardHeight(item.thread)),
2161
+ }))
2162
+ : [];
2163
+ const stackGuideTop = Math.min(...stackSpans.map((span) => span.top)) + 16;
2164
+ const stackGuideHeight =
2165
+ Math.max(...stackSpans.map((span) => span.bottom)) - 16 - stackGuideTop;
1731
2166
 
1732
2167
  const changeResolution = async (thread: CommentThread, resolved: boolean) => {
1733
2168
  if (
@@ -1773,10 +2208,39 @@ export function CommentsSidebar({
1773
2208
  );
1774
2209
  };
1775
2210
 
2211
+ const renderFoldedStack = (
2212
+ stack: CommentStack<CommentThread>,
2213
+ layoutId: string,
2214
+ ) => {
2215
+ const front = commentStackFront(stack.threads, stackTouches);
2216
+ return (
2217
+ <CommentStackCard
2218
+ stackKey={stack.key}
2219
+ heightId={layoutId}
2220
+ threads={stack.threads}
2221
+ front={front}
2222
+ isActive={stack.threads.some(
2223
+ (member) => member.threadId === activeThreadId,
2224
+ )}
2225
+ onOpen={() => {
2226
+ openStackOn(front.threadId);
2227
+ onActivateThread?.(front.threadId);
2228
+ if (canComment) setReplyingThreadId(front.threadId);
2229
+ }}
2230
+ onHoverChange={(hovered) =>
2231
+ onHoveredThreadChange?.(hovered ? front.threadId : null)
2232
+ }
2233
+ onHeightChange={handleThreadCardHeightChange}
2234
+ t={t}
2235
+ />
2236
+ );
2237
+ };
2238
+
1776
2239
  const renderCommentThread = (
1777
2240
  thread: CommentThread,
1778
2241
  marginTop = 0,
1779
2242
  isActive = false,
2243
+ inStack = false,
1780
2244
  ) => {
1781
2245
  const aiRequest = commentAi
1782
2246
  ? latestCommentAiRequest(commentAi.requests, thread.threadId)
@@ -1984,6 +2448,7 @@ export function CommentsSidebar({
1984
2448
  threadActions={renderThreadActions?.(thread)}
1985
2449
  currentUserEmail={currentUserEmail}
1986
2450
  quote={thread.quotedText}
2451
+ showQuote={inStack}
1987
2452
  propertyTarget={
1988
2453
  thread.property
1989
2454
  ? propertyThreadTarget(thread.property, documentProperties)
@@ -2097,6 +2562,7 @@ export function CommentsSidebar({
2097
2562
  surface={surface}
2098
2563
  onClose={onClose}
2099
2564
  currentUserEmail={currentUserEmail}
2565
+ currentUserName={currentUserName}
2100
2566
  compact={compact}
2101
2567
  replyDrafts={replyDrafts}
2102
2568
  key={suggestion.id}
@@ -2144,6 +2610,8 @@ export function CommentsSidebar({
2144
2610
  (member) =>
2145
2611
  member.id === activeSuggestionId || member.id === focusSuggestionId,
2146
2612
  )}
2613
+ expanded={expandedProposalIds.has(proposalId)}
2614
+ onExpandedChange={(expanded) => setProposalExpanded(proposalId, expanded)}
2147
2615
  deciding={members.some(suggestionBusy)}
2148
2616
  canDecide={canDecideSuggestions && !!onDecideSuggestionProposal}
2149
2617
  onDecide={(decision) =>
@@ -2176,6 +2644,7 @@ export function CommentsSidebar({
2176
2644
  surface={surface}
2177
2645
  onClose={onClose}
2178
2646
  currentUserEmail={currentUserEmail}
2647
+ currentUserName={currentUserName}
2179
2648
  key={suggestion.id}
2180
2649
  marginTop={marginTop}
2181
2650
  onHeightChange={handleThreadCardHeightChange}
@@ -2443,6 +2912,18 @@ export function CommentsSidebar({
2443
2912
  ref={sidebarRef}
2444
2913
  className="relative flow-root w-full min-w-0 shrink-0 pb-16"
2445
2914
  data-comments-sidebar
2915
+ onClick={(event) => {
2916
+ // The margin's empty space is outside every card, so it folds.
2917
+ const target = event.target as HTMLElement;
2918
+ if (
2919
+ expandedStack &&
2920
+ (target === event.currentTarget ||
2921
+ target.hasAttribute("data-comment-layout-thread") ||
2922
+ target.dataset.commentStackState === "open" ||
2923
+ target.hasAttribute("data-comment-stack-guide"))
2924
+ )
2925
+ collapseStack(expandedStack);
2926
+ }}
2446
2927
  >
2447
2928
  {isLoading ? (
2448
2929
  <div className="space-y-3 px-2 pt-3" aria-hidden="true">
@@ -2454,108 +2935,143 @@ export function CommentsSidebar({
2454
2935
  ))}
2455
2936
  </div>
2456
2937
  ) : null}
2938
+ {expandedStack && stackHeaderTop !== undefined ? (
2939
+ <CommentStackHeader
2940
+ stackKey={expandedStack.key}
2941
+ top={stackHeaderTop}
2942
+ onCollapse={() => collapseStack(expandedStack)}
2943
+ t={t}
2944
+ />
2945
+ ) : null}
2946
+ {expandedStack && stackSpans.length > 0 ? (
2947
+ <CommentStackGuide top={stackGuideTop} height={stackGuideHeight} />
2948
+ ) : null}
2457
2949
  {/* Pending new comment — positioned at the selection Y offset */}
2458
2950
  {displayedPendingComment && (
2459
2951
  <div
2460
2952
  ref={pendingCardRef}
2461
2953
  className={cn(
2462
- "overflow-hidden rounded-xl bg-popover ring-1 ring-border/60 shadow-comment-raised",
2463
2954
  alignToAnchors
2464
- ? "absolute left-2 right-4 z-10"
2955
+ ? composingStack
2956
+ ? "absolute inset-x-0 z-10 pb-2"
2957
+ : "absolute left-2 right-4 z-10"
2465
2958
  : surface === "popover"
2466
2959
  ? "relative"
2467
2960
  : "relative mx-2 mt-3",
2468
2961
  )}
2469
2962
  style={alignToAnchors ? { top: pendingTop } : undefined}
2470
2963
  data-comment-pending
2964
+ data-comment-stack={composingStack?.key}
2965
+ data-comment-stack-state={composingStack ? "composing" : undefined}
2471
2966
  >
2472
- {surface === "popover" ? (
2473
- <div className="flex h-12 items-center gap-1 border-b border-border/70 pe-2 ps-4">
2474
- <span className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
2475
- {t("comments.commentTitle")}
2476
- </span>
2477
- {onClose ? (
2478
- <Tooltip>
2479
- <TooltipTrigger asChild>
2480
- <CommentIconButton
2481
- aria-label={t("comments.close")}
2482
- onClick={handlePendingCancel}
2483
- >
2484
- <IconX size={18} />
2485
- </CommentIconButton>
2486
- </TooltipTrigger>
2487
- <TooltipContent>{t("comments.close")}</TooltipContent>
2488
- </Tooltip>
2489
- ) : null}
2490
- </div>
2491
- ) : null}
2492
- <div className="flex items-start gap-2.5 p-3.5">
2493
- <div className="flex h-9.5 shrink-0 items-center">
2494
- <CommentAvatar email={currentUserEmail} name={currentUserEmail} />
2495
- </div>
2496
- <div className="min-w-0 flex-1">
2497
- {displayedPendingComment.property ? (
2498
- <PropertyThreadTargetLabel
2499
- className="mb-2"
2500
- target={propertyThreadTarget(
2501
- displayedPendingComment.property,
2502
- documentProperties,
2503
- )}
2967
+ {composingStack ? <CommentStackSheets /> : null}
2968
+ <div
2969
+ className={cn(
2970
+ "relative overflow-hidden rounded-xl bg-popover ring-1 ring-border/60 shadow-comment-raised",
2971
+ composingStack && "mx-2 mr-4",
2972
+ )}
2973
+ >
2974
+ {surface === "popover" ? (
2975
+ <div className="flex h-12 items-center gap-1 border-b border-border/70 pe-2 ps-4">
2976
+ <span className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
2977
+ {t("comments.commentTitle")}
2978
+ </span>
2979
+ {onClose ? (
2980
+ <Tooltip>
2981
+ <TooltipTrigger asChild>
2982
+ <CommentIconButton
2983
+ aria-label={t("comments.close")}
2984
+ onClick={handlePendingCancel}
2985
+ >
2986
+ <IconX size={18} />
2987
+ </CommentIconButton>
2988
+ </TooltipTrigger>
2989
+ <TooltipContent>{t("comments.close")}</TooltipContent>
2990
+ </Tooltip>
2991
+ ) : null}
2992
+ </div>
2993
+ ) : null}
2994
+ <div className="flex items-start gap-2.5 p-3.5">
2995
+ <div className="flex h-9.5 shrink-0 items-center">
2996
+ <CommentAvatar
2997
+ email={currentUserEmail}
2998
+ name={currentUserEmail}
2504
2999
  />
2505
- ) : null}
2506
- {!pendingTargetValid && (
2507
- <p role="alert" className="mb-2 text-xs text-muted-foreground">
2508
- {t("comments.selectTextToComment")}
2509
- </p>
2510
- )}
2511
- <CommentComposer
2512
- ref={pendingInputRef}
2513
- value={pendingText}
2514
- onChange={(text) =>
2515
- onPendingChange?.(displayedPendingComment.id, () => ({
2516
- text,
2517
- }))
2518
- }
2519
- mentions={pendingMentions}
2520
- onMentionsChange={(mentions) =>
2521
- onPendingChange?.(displayedPendingComment.id, () => ({
2522
- mentions,
2523
- }))
2524
- }
2525
- onSubmit={() => void handlePendingSubmit()}
2526
- onAiSubmit={
2527
- commentAi && !displayedPendingComment.fieldId
2528
- ? (selection) => void handlePendingAiSubmit(selection)
2529
- : undefined
2530
- }
2531
- aiDraft={pendingDraft.draft.aiDraft}
2532
- onAiDraftChange={pendingDraft.setAiDraft}
2533
- aiModelStorageKey={
2534
- commentAi && !displayedPendingComment.fieldId
2535
- ? aiModelStorageKey
2536
- : undefined
2537
- }
2538
- onCancel={handlePendingCancel}
2539
- onEscape={() => {
2540
- if (!pendingText.trim()) handlePendingCancel();
2541
- }}
2542
- onFocus={() => {
2543
- const selection = pendingInputRef.current?.getSelection();
2544
- if (selection && pendingFocus)
2545
- pendingFocus.current = selection;
2546
- }}
2547
- onSelectionChange={(selection) => {
2548
- if (pendingFocus) pendingFocus.current = selection;
2549
- }}
2550
- onBlur={() => {
2551
- if (pendingFocus) pendingFocus.current = null;
2552
- }}
2553
- placeholder={t("comments.add")}
2554
- submitLabel={t("comments.submit")}
2555
- submitDisabled={!pendingTargetValid || ambiguousCreate()}
2556
- disabled={pendingSubmitting}
2557
- />
3000
+ </div>
3001
+ <div className="min-w-0 flex-1">
3002
+ {displayedPendingComment.property ? (
3003
+ <PropertyThreadTargetLabel
3004
+ className="mb-2"
3005
+ target={propertyThreadTarget(
3006
+ displayedPendingComment.property,
3007
+ documentProperties,
3008
+ )}
3009
+ />
3010
+ ) : null}
3011
+ {!pendingTargetValid && (
3012
+ <p
3013
+ role="alert"
3014
+ className="mb-2 text-xs text-muted-foreground"
3015
+ >
3016
+ {t("comments.selectTextToComment")}
3017
+ </p>
3018
+ )}
3019
+ <CommentComposer
3020
+ ref={pendingInputRef}
3021
+ value={pendingText}
3022
+ onChange={(text) =>
3023
+ onPendingChange?.(displayedPendingComment.id, () => ({
3024
+ text,
3025
+ }))
3026
+ }
3027
+ mentions={pendingMentions}
3028
+ onMentionsChange={(mentions) =>
3029
+ onPendingChange?.(displayedPendingComment.id, () => ({
3030
+ mentions,
3031
+ }))
3032
+ }
3033
+ onSubmit={() => void handlePendingSubmit()}
3034
+ onAiSubmit={
3035
+ commentAi && !displayedPendingComment.fieldId
3036
+ ? (selection) => void handlePendingAiSubmit(selection)
3037
+ : undefined
3038
+ }
3039
+ aiDraft={pendingDraft.draft.aiDraft}
3040
+ onAiDraftChange={pendingDraft.setAiDraft}
3041
+ aiModelStorageKey={
3042
+ commentAi && !displayedPendingComment.fieldId
3043
+ ? aiModelStorageKey
3044
+ : undefined
3045
+ }
3046
+ onCancel={handlePendingCancel}
3047
+ onEscape={() => {
3048
+ if (!pendingText.trim()) handlePendingCancel();
3049
+ }}
3050
+ onFocus={() => {
3051
+ const selection = pendingInputRef.current?.getSelection();
3052
+ if (selection && pendingFocus)
3053
+ pendingFocus.current = selection;
3054
+ }}
3055
+ onSelectionChange={(selection) => {
3056
+ if (pendingFocus) pendingFocus.current = selection;
3057
+ }}
3058
+ onBlur={() => {
3059
+ if (pendingFocus) pendingFocus.current = null;
3060
+ }}
3061
+ placeholder={t("comments.add")}
3062
+ submitLabel={t("comments.submit")}
3063
+ submitDisabled={!pendingTargetValid || ambiguousCreate()}
3064
+ disabled={pendingSubmitting}
3065
+ />
3066
+ </div>
2558
3067
  </div>
3068
+ {composingStack ? (
3069
+ <CommentStackShowAll
3070
+ threads={composingStack.threads}
3071
+ onOpen={() => openStackOn(composingStack.threads[0].threadId)}
3072
+ t={t}
3073
+ />
3074
+ ) : null}
2559
3075
  </div>
2560
3076
  </div>
2561
3077
  )}
@@ -2566,26 +3082,33 @@ export function CommentsSidebar({
2566
3082
  const restingItem = restingItemsById.get(thread.threadId);
2567
3083
  const marginTop = restingItem?.marginTop ?? item.marginTop;
2568
3084
  const translateY = top - (restingItem?.top ?? top);
2569
- const card =
3085
+ const stack =
2570
3086
  "suggestion" in thread
2571
- ? "durability" in thread.suggestion
2572
- ? renderDraftSuggestionCard(thread.suggestion)
2573
- : thread.suggestion.proposalId &&
2574
- (proposalMemberCounts.get(thread.suggestion.proposalId) ??
2575
- 0) >= 2
2576
- ? renderProposalGroup(
2577
- thread.suggestion.proposalId,
2578
- inlineProposalMembers.get(thread.suggestion.proposalId) ?? [
2579
- thread.suggestion,
2580
- ],
2581
- thread.threadId,
2582
- )
2583
- : renderSuggestionCard(thread.suggestion)
2584
- : renderCommentThread(
2585
- thread,
2586
- 0,
2587
- activeThreadId === thread.threadId,
2588
- );
3087
+ ? undefined
3088
+ : stackOfThread.get(thread.threadId);
3089
+ const card =
3090
+ stack && stack !== expandedStack
3091
+ ? renderFoldedStack(stack, thread.threadId)
3092
+ : "suggestion" in thread
3093
+ ? "durability" in thread.suggestion
3094
+ ? renderDraftSuggestionCard(thread.suggestion)
3095
+ : thread.suggestion.proposalId &&
3096
+ (proposalMemberCounts.get(thread.suggestion.proposalId) ??
3097
+ 0) >= 2
3098
+ ? renderProposalGroup(
3099
+ thread.suggestion.proposalId,
3100
+ inlineProposalMembers.get(
3101
+ thread.suggestion.proposalId,
3102
+ ) ?? [thread.suggestion],
3103
+ thread.threadId,
3104
+ )
3105
+ : renderSuggestionCard(thread.suggestion)
3106
+ : renderCommentThread(
3107
+ thread,
3108
+ 0,
3109
+ activeThreadId === thread.threadId,
3110
+ !!stack,
3111
+ );
2589
3112
  // A Property thread has no highlight to lose, so it isn't one that
2590
3113
  // went missing.
2591
3114
  const lostHighlight = (candidate: (typeof items)[number]) =>
@@ -2667,6 +3190,8 @@ function ProposalGroup({
2667
3190
  totalCount,
2668
3191
  members,
2669
3192
  active,
3193
+ expanded,
3194
+ onExpandedChange,
2670
3195
  deciding,
2671
3196
  canDecide,
2672
3197
  onDecide,
@@ -2679,6 +3204,8 @@ function ProposalGroup({
2679
3204
  totalCount: number;
2680
3205
  members: ResourceSuggestion[];
2681
3206
  active: boolean;
3207
+ expanded: boolean;
3208
+ onExpandedChange: (expanded: boolean) => void;
2682
3209
  deciding: boolean;
2683
3210
  canDecide: boolean;
2684
3211
  onDecide: (decision: SuggestionDecision) => void;
@@ -2686,11 +3213,10 @@ function ProposalGroup({
2686
3213
  children: ReactNode;
2687
3214
  t: ReturnType<typeof useT>;
2688
3215
  }) {
2689
- const [expanded, setExpanded] = useState(active);
2690
3216
  const groupRef = useRef<HTMLDivElement>(null);
2691
3217
  const detailsId = useId();
2692
- useEffect(() => {
2693
- if (active) setExpanded(true);
3218
+ useLayoutEffect(() => {
3219
+ if (active) onExpandedChange(true);
2694
3220
  }, [active]);
2695
3221
  useLayoutEffect(() => {
2696
3222
  const element = groupRef.current;
@@ -2714,7 +3240,7 @@ function ProposalGroup({
2714
3240
  type="button"
2715
3241
  aria-expanded={expanded}
2716
3242
  aria-controls={detailsId}
2717
- onClick={() => setExpanded((current) => !current)}
3243
+ onClick={() => onExpandedChange(!expanded)}
2718
3244
  className="flex w-full min-w-0 items-start gap-2 px-3 py-2 text-start text-xs hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
2719
3245
  >
2720
3246
  <IconChevronDown
@@ -2733,7 +3259,9 @@ function ProposalGroup({
2733
3259
  {summary}
2734
3260
  </span>
2735
3261
  <span className="shrink-0 text-muted-foreground">
2736
- {t("comments.proposalEditCount", { count: totalCount })}
3262
+ {t("comments.proposalEditCount", {
3263
+ count: pending.length || totalCount,
3264
+ })}
2737
3265
  </span>
2738
3266
  </button>
2739
3267
  {expanded ? (
@@ -2796,6 +3324,19 @@ export function agentSuggestionReason(
2796
3324
  return reason;
2797
3325
  }
2798
3326
 
3327
+ /**
3328
+ * A change that adds or removes a line break, or swaps one run of whitespace
3329
+ * for another, doesn't say which lines it joins or splits, and a break alone
3330
+ * reads as a lone "↵", so what it replaces is shown too.
3331
+ */
3332
+ function changesLayout(removed: string, inserted: string) {
3333
+ const before = suggestionAnchorText(removed);
3334
+ const after = suggestionAnchorText(inserted);
3335
+ return (
3336
+ /\n/.test(before + after) || (/^\s+$/.test(before) && /^\s+$/.test(after))
3337
+ );
3338
+ }
3339
+
2799
3340
  function SuggestionOperationSummary({
2800
3341
  operations,
2801
3342
  expanded,
@@ -2851,6 +3392,18 @@ function SuggestionOperationSummary({
2851
3392
  : null;
2852
3393
  const hasSharedContext = diff?.some((part) => part.type === "equal");
2853
3394
  const richText = /[<>*_`\[\]]/.test(previousText + nextText);
3395
+ const changed = (type: "delete" | "insert", whole: string) =>
3396
+ diff && hasSharedContext
3397
+ ? diff
3398
+ .flatMap((part) => (part.type === type ? [part.text] : []))
3399
+ .join("")
3400
+ : whole;
3401
+ const showReplaced =
3402
+ expanded ||
3403
+ changesLayout(
3404
+ changed("delete", previousText),
3405
+ changed("insert", nextText),
3406
+ );
2854
3407
  const renderDiff = (side: "before" | "after") => {
2855
3408
  let offset = 0;
2856
3409
  const presentation =
@@ -2879,7 +3432,9 @@ function SuggestionOperationSummary({
2879
3432
  to: from + part.text.length,
2880
3433
  },
2881
3434
  )
2882
- : part.text}
3435
+ : part.type !== "equal" && /^\s+$/.test(part.text)
3436
+ ? suggestionTextForDisplay(part.text)
3437
+ : part.text}
2883
3438
  </span>
2884
3439
  );
2885
3440
  });
@@ -2897,7 +3452,7 @@ function SuggestionOperationSummary({
2897
3452
  : renderSuggestionText(nextText, nextPresentation)}
2898
3453
  {"”"}
2899
3454
  </div>
2900
- {expanded ? (
3455
+ {showReplaced ? (
2901
3456
  <div className="text-muted-foreground">
2902
3457
  {t("comments.suggestionReplace")}: {"“"}
2903
3458
  {diff && hasSharedContext
@@ -2947,11 +3502,13 @@ function DraftSuggestionThreadView({
2947
3502
  surface = "rail",
2948
3503
  onClose,
2949
3504
  currentUserEmail,
3505
+ currentUserName,
2950
3506
  t,
2951
3507
  }: {
2952
3508
  surface?: CommentSurface;
2953
3509
  onClose?: () => void;
2954
3510
  currentUserEmail?: string;
3511
+ currentUserName?: string;
2955
3512
  marginTop?: number;
2956
3513
  onHeightChange: (threadId: string, height: number) => void;
2957
3514
  suggestion: DraftSuggestion;
@@ -2984,7 +3541,11 @@ function DraftSuggestionThreadView({
2984
3541
  {
2985
3542
  id: suggestion.id,
2986
3543
  author_email: suggestion.authorEmail ?? "",
2987
- author_name: null,
3544
+ author_name: ownAuthorName(
3545
+ suggestion.authorEmail,
3546
+ currentUserEmail,
3547
+ currentUserName,
3548
+ ),
2988
3549
  created_at: suggestion.createdAt,
2989
3550
  content: "",
2990
3551
  mentions: [],
@@ -3102,11 +3663,13 @@ function SuggestionThreadView({
3102
3663
  surface = "rail",
3103
3664
  onClose,
3104
3665
  currentUserEmail,
3666
+ currentUserName,
3105
3667
  t,
3106
3668
  }: {
3107
3669
  surface?: CommentSurface;
3108
3670
  onClose?: () => void;
3109
3671
  currentUserEmail?: string;
3672
+ currentUserName?: string;
3110
3673
  compact: boolean;
3111
3674
  replyDrafts: ReturnType<typeof useCommentReplyDrafts>;
3112
3675
  marginTop?: number;
@@ -3182,6 +3745,11 @@ function SuggestionThreadView({
3182
3745
  author_email: suggestion.authorEmail ?? "",
3183
3746
  author_name:
3184
3747
  root?.authorName ??
3748
+ ownAuthorName(
3749
+ suggestion.authorEmail,
3750
+ currentUserEmail,
3751
+ currentUserName,
3752
+ ) ??
3185
3753
  (suggestion.authorEmail ? null : suggestion.actorKind),
3186
3754
  created_at: suggestion.createdAt,
3187
3755
  content: "",
@@ -3192,6 +3760,11 @@ function SuggestionThreadView({
3192
3760
  author_email: comment.authorEmail ?? "",
3193
3761
  author_name:
3194
3762
  comment.authorName ??
3763
+ ownAuthorName(
3764
+ comment.authorEmail,
3765
+ currentUserEmail,
3766
+ currentUserName,
3767
+ ) ??
3195
3768
  (comment.authorEmail ? null : comment.createdBy),
3196
3769
  created_at: comment.createdAt,
3197
3770
  content: comment.body,
@@ -3476,6 +4049,7 @@ function ThreadView({
3476
4049
  renderEntry,
3477
4050
  surface = "rail",
3478
4051
  quote,
4052
+ showQuote = false,
3479
4053
  propertyTarget,
3480
4054
  anchorUnavailable = false,
3481
4055
  onClose,
@@ -3522,6 +4096,8 @@ function ThreadView({
3522
4096
  surface?: CommentSurface;
3523
4097
  /** The anchored text, shown as a quote line above the thread in the panel. */
3524
4098
  quote?: string | null;
4099
+ /** Show the quote in the margin too, for a card away from its highlight. */
4100
+ showQuote?: boolean;
3525
4101
  /** The Property a thread is on, shown above it on every surface. */
3526
4102
  propertyTarget?: PropertyThreadTarget | null;
3527
4103
  anchorUnavailable?: boolean;
@@ -3786,18 +4362,33 @@ function ThreadView({
3786
4362
  target={propertyTarget}
3787
4363
  />
3788
4364
  ) : null}
3789
- {isPanel && quote ? (
3790
- <button
3791
- type="button"
3792
- onClick={(event) => {
3793
- event.stopPropagation();
3794
- if (canExpand) onExpand();
3795
- }}
3796
- className="-mb-1 line-clamp-2 border-s-2 border-border ps-2.5 text-start text-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
3797
- data-comment-quote
3798
- >
3799
- {quote}
3800
- </button>
4365
+ {(isPanel || showQuote) && quote ? (
4366
+ canExpand ? (
4367
+ <button
4368
+ type="button"
4369
+ onClick={(event) => {
4370
+ event.stopPropagation();
4371
+ onExpand();
4372
+ }}
4373
+ className={cn(
4374
+ "-mb-1 border-s-2 border-border ps-2.5 text-start text-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
4375
+ isPanel ? "line-clamp-2" : "line-clamp-1",
4376
+ )}
4377
+ data-comment-quote
4378
+ >
4379
+ {quote}
4380
+ </button>
4381
+ ) : (
4382
+ <p
4383
+ className={cn(
4384
+ "-mb-1 border-s-2 border-border ps-2.5 text-sm text-muted-foreground",
4385
+ isPanel ? "line-clamp-2" : "line-clamp-1",
4386
+ )}
4387
+ data-comment-quote
4388
+ >
4389
+ {quote}
4390
+ </p>
4391
+ )
3801
4392
  ) : null}
3802
4393
  {anchorUnavailable ? (
3803
4394
  <span