@agent-native/core-corpus 0.218.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 (32) 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/community/qr/package.json +1 -1
  4. package/corpus/templates/content/CHANGELOG.md +5 -0
  5. package/corpus/templates/content/app/components/editor/CommentStack.tsx +301 -0
  6. package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +651 -149
  7. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +19 -14
  8. package/corpus/templates/content/app/components/editor/block-field-comment-threads.ts +15 -0
  9. package/corpus/templates/content/app/components/editor/block-field-comments.tsx +5 -9
  10. package/corpus/templates/content/app/components/editor/comment-stacks.ts +143 -0
  11. package/corpus/templates/content/app/i18n-data.ts +8 -0
  12. package/corpus/templates/content/changelog/2026-10-22-blocks-with-several-comment-threads-fold-into-one-stack-in-t.md +6 -0
  13. package/corpus/templates/content/changelog/2026-10-22-opening-a-page-no-longer-saves-an-edit-again-after-another-o.md +6 -0
  14. package/corpus/templates/content/docs/product/capabilities/content.comment.page-owned.md +8 -1
  15. package/corpus/templates/design/.generated/bridge/interact-links.generated.ts +8 -8
  16. package/corpus/templates/design/CHANGELOG.md +5 -0
  17. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +18 -1
  18. package/corpus/templates/design/app/components/design/bridge/interact-links.bridge.ts +8 -8
  19. package/corpus/templates/design/app/components/editor/PromptDialog.tsx +15 -13
  20. package/corpus/templates/design/app/i18n/en-US.ts +1 -1
  21. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-modes.ts +15 -6
  22. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-screen-rendering.tsx +29 -5
  23. package/corpus/templates/design/app/pages/design-editor/prototype-navigation-transition.ts +423 -0
  24. package/corpus/templates/design/app/pages/design-editor/view/single-screen-canvas.tsx +35 -9
  25. package/corpus/templates/design/changelog/2026-10-20-the-design-full-app-toggle-on-the-home-page-no-longer-hides-.md +6 -0
  26. package/corpus/templates/design/changelog/2026-10-22-prototype-links-can-play-code-authored-screen-transitions.md +6 -0
  27. package/corpus/templates/design/shared/motion-authored-css-import.ts +938 -7
  28. package/corpus/templates/design/shared/motion-html-source.ts +1689 -135
  29. package/corpus/templates/design/shared/motion-prototype-transition.ts +329 -0
  30. package/corpus/templates/design/shared/motion-react-source.ts +2154 -126
  31. package/corpus/templates/design/shared/motion-text-runtime-v1.fixture.txt +221 -0
  32. package/package.json +1 -1
@@ -129,6 +129,22 @@ import {
129
129
  startCommentAiSubmission,
130
130
  type CommentAiController,
131
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";
132
148
  import {
133
149
  ReviewAddReactionButton,
134
150
  ReviewCommentMenu,
@@ -342,6 +358,26 @@ export function estimateThreadCardHeight(thread: ThreadLayoutIdentity) {
342
358
  */
343
359
  export const PENDING_COMMENT_LAYOUT_ID = "pending-comment";
344
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
+ }
345
381
 
346
382
  type CommentLayoutItem<T extends ThreadLayoutIdentity> = {
347
383
  thread: T;
@@ -774,6 +810,8 @@ interface CommentsSidebarOptions {
774
810
  onActivateSuggestion?: (id: string) => void;
775
811
  onSelectedThreadChange?: (id: string | null) => void;
776
812
  onHoveredThreadChange?: (id: string | null) => void;
813
+ /** The thread whose highlight the pointer is on. */
814
+ hoveredThreadId?: string | null;
777
815
  currentUserEmail?: string;
778
816
  /** The signed-in person's display name, for their own suggestions. */
779
817
  currentUserName?: string;
@@ -860,6 +898,7 @@ export function CommentsSidebar({
860
898
  onActivateSuggestion,
861
899
  onSelectedThreadChange,
862
900
  onHoveredThreadChange,
901
+ hoveredThreadId,
863
902
  currentUserEmail,
864
903
  currentUserName,
865
904
  currentUserOrgId,
@@ -1133,6 +1172,226 @@ export function CommentsSidebar({
1133
1172
  )
1134
1173
  setReplyingThreadId(selectedThreadId);
1135
1174
  }, [canComment, presentation, selectedThreadId, selectedThreadIsOpen]);
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);
1214
+ }
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);
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
+
1136
1395
  const historyAuthors = useMemo(() => {
1137
1396
  const authors = new Map<string, string>();
1138
1397
  // A name from any record outranks the email's local part from another.
@@ -1339,6 +1598,7 @@ export function CommentsSidebar({
1339
1598
  pendingDraft.beginSubmission(clientOperationId);
1340
1599
  onPendingChange(id, () => ({ submitting: true }));
1341
1600
  setPendingHandoff({ id, operationId: clientOperationId });
1601
+ touchThread(`optimistic-${clientOperationId}`);
1342
1602
  try {
1343
1603
  const result = await createComment.mutateAsync({
1344
1604
  clientOperationId,
@@ -1362,7 +1622,11 @@ export function CommentsSidebar({
1362
1622
  });
1363
1623
  pendingDraft.finishSubmission(clientOperationId);
1364
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);
1365
1628
  if (pendingCommentRef.current?.id === id) {
1629
+ postedThreadIdRef.current = result.threadId;
1366
1630
  onPendingDone(id, result.threadId);
1367
1631
  }
1368
1632
  return { id: result.id, threadId: result.threadId };
@@ -1522,18 +1786,35 @@ export function CommentsSidebar({
1522
1786
  proposalMemberCounts,
1523
1787
  threadPositions,
1524
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]);
1525
1801
  const layoutThreads = useMemo(
1526
1802
  () =>
1527
- inlineThreads.filter(
1528
- (thread) =>
1529
- !("suggestion" in thread) ||
1530
- !("proposalId" in thread.suggestion) ||
1531
- !thread.suggestion.proposalId ||
1532
- (proposalMemberCounts.get(thread.suggestion.proposalId) ?? 0) < 2 ||
1533
- inlineProposalLeaders.get(thread.suggestion.proposalId) ===
1534
- 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),
1535
1811
  ),
1536
- [inlineThreads, inlineProposalLeaders, proposalMemberCounts],
1812
+ [
1813
+ inlineThreads,
1814
+ inlineProposalLeaders,
1815
+ proposalMemberCounts,
1816
+ stackHiddenIds,
1817
+ ],
1537
1818
  );
1538
1819
  const openThreadKey = inlineThreads
1539
1820
  .map(
@@ -1555,9 +1836,36 @@ export function CommentsSidebar({
1555
1836
  );
1556
1837
 
1557
1838
  const hasPendingComment = !!displayedPendingComment;
1839
+ const pendingCommentId = displayedPendingComment?.id;
1840
+ const pendingFieldId = displayedPendingComment?.fieldId ?? null;
1841
+ const pendingOnProperty = !!displayedPendingComment?.property;
1558
1842
  const recomputeOffsets = useCallback(() => {
1559
1843
  const container = scrollContainerRef?.current ?? null;
1560
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
+ }
1561
1869
  const layoutContainer = alignToAnchors ? sidebarRef.current : null;
1562
1870
  const nextPendingPosition =
1563
1871
  hasPendingComment && alignToAnchors
@@ -1612,7 +1920,17 @@ export function CommentsSidebar({
1612
1920
  }
1613
1921
  return positions;
1614
1922
  });
1615
- }, [alignToAnchors, hasPendingComment, inlineThreads, scrollContainerRef]);
1923
+ }, [
1924
+ alignToAnchors,
1925
+ hasPendingComment,
1926
+ inlineThreads,
1927
+ openThreads,
1928
+ pendingCommentId,
1929
+ pendingFieldId,
1930
+ pendingOnProperty,
1931
+ scrollContainerRef,
1932
+ stacksEnabled,
1933
+ ]);
1616
1934
 
1617
1935
  // Measured before paint, so a card under a new id never paints unplaced.
1618
1936
  useLayoutEffect(() => {
@@ -1636,14 +1954,18 @@ export function CommentsSidebar({
1636
1954
  subtree: true,
1637
1955
  characterData: true,
1638
1956
  attributes: true,
1639
- attributeFilter: ["hidden"],
1957
+ attributeFilter: ["hidden", "data-comment-thread"],
1640
1958
  });
1641
1959
  }
1642
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.
1643
1963
  observer.observe(primaryBlockFieldEditor(container) ?? container, {
1644
1964
  childList: true,
1645
1965
  subtree: true,
1646
1966
  characterData: true,
1967
+ attributes: true,
1968
+ attributeFilter: ["data-comment-thread"],
1647
1969
  });
1648
1970
  }
1649
1971
  const resizeObserver =
@@ -1662,7 +1984,6 @@ export function CommentsSidebar({
1662
1984
  // eslint-disable-next-line react-hooks/exhaustive-deps
1663
1985
  }, [openThreadKey, hasPendingComment, recomputeOffsets]);
1664
1986
 
1665
- const pendingCommentId = displayedPendingComment?.id;
1666
1987
  useLayoutEffect(() => {
1667
1988
  const element = pendingCardRef.current;
1668
1989
  if (!element) {
@@ -1713,6 +2034,10 @@ export function CommentsSidebar({
1713
2034
  const activeSavedSuggestion = inlineSuggestions.find(
1714
2035
  (suggestion) => suggestion.id === activeSuggestionId,
1715
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;
1716
2041
  const selectedLayoutThreadId =
1717
2042
  (activeSavedSuggestion?.proposalId
1718
2043
  ? inlineProposalLeaders.get(activeSavedSuggestion.proposalId)
@@ -1720,7 +2045,9 @@ export function CommentsSidebar({
1720
2045
  inlineDraftSuggestions.find(
1721
2046
  (suggestion) => suggestion.id === activeSuggestionId,
1722
2047
  )?.threadId ??
1723
- selectedThreadId;
2048
+ (selectedStack && selectedStack !== expandedStack
2049
+ ? selectedStack.threads[0].threadId
2050
+ : selectedThreadId);
1724
2051
  const restingItems = layoutCommentThreads(
1725
2052
  layoutThreads,
1726
2053
  threadPositions,
@@ -1737,32 +2064,105 @@ export function CommentsSidebar({
1737
2064
  : null;
1738
2065
  const pendingAnchorTop =
1739
2066
  pendingPosition?.layoutTop ?? displayedPendingComment?.offsetTop ?? 0;
1740
- const selectedItems = pendingLayout
1741
- ? layoutCommentThreads<LayoutThread | ThreadLayoutIdentity>(
1742
- [...layoutThreads, pendingLayout],
1743
- new Map(threadPositions).set(PENDING_COMMENT_LAYOUT_ID, {
1744
- documentTop: pendingPosition?.documentTop ?? pendingAnchorTop,
1745
- layoutTop: pendingAnchorTop,
1746
- }),
1747
- new Map(threadCardHeights).set(
1748
- PENDING_COMMENT_LAYOUT_ID,
1749
- 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),
1750
2079
  ),
1751
- 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,
1752
2087
  )
1753
- : layoutCommentThreads(
1754
- layoutThreads,
1755
- threadPositions,
1756
- threadCardHeights,
1757
- selectedLayoutThreadId,
1758
- );
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;
1759
2135
  const pendingTop =
1760
2136
  selectedItems.find((item) => item.thread === pendingLayout)?.top ??
1761
2137
  pendingAnchorTop;
2138
+ const stackHeaderTop = selectedItems.find(
2139
+ (item) => item.thread === stackHeaderLayout,
2140
+ )?.top;
1762
2141
  const items = selectedItems.filter(
1763
2142
  (item): item is CommentLayoutItem<LayoutThread> =>
1764
- item.thread !== pendingLayout,
2143
+ item.thread !== pendingLayout && item.thread !== stackHeaderLayout,
1765
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;
1766
2166
 
1767
2167
  const changeResolution = async (thread: CommentThread, resolved: boolean) => {
1768
2168
  if (
@@ -1808,10 +2208,39 @@ export function CommentsSidebar({
1808
2208
  );
1809
2209
  };
1810
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
+
1811
2239
  const renderCommentThread = (
1812
2240
  thread: CommentThread,
1813
2241
  marginTop = 0,
1814
2242
  isActive = false,
2243
+ inStack = false,
1815
2244
  ) => {
1816
2245
  const aiRequest = commentAi
1817
2246
  ? latestCommentAiRequest(commentAi.requests, thread.threadId)
@@ -2019,6 +2448,7 @@ export function CommentsSidebar({
2019
2448
  threadActions={renderThreadActions?.(thread)}
2020
2449
  currentUserEmail={currentUserEmail}
2021
2450
  quote={thread.quotedText}
2451
+ showQuote={inStack}
2022
2452
  propertyTarget={
2023
2453
  thread.property
2024
2454
  ? propertyThreadTarget(thread.property, documentProperties)
@@ -2482,6 +2912,18 @@ export function CommentsSidebar({
2482
2912
  ref={sidebarRef}
2483
2913
  className="relative flow-root w-full min-w-0 shrink-0 pb-16"
2484
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
+ }}
2485
2927
  >
2486
2928
  {isLoading ? (
2487
2929
  <div className="space-y-3 px-2 pt-3" aria-hidden="true">
@@ -2493,108 +2935,143 @@ export function CommentsSidebar({
2493
2935
  ))}
2494
2936
  </div>
2495
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}
2496
2949
  {/* Pending new comment — positioned at the selection Y offset */}
2497
2950
  {displayedPendingComment && (
2498
2951
  <div
2499
2952
  ref={pendingCardRef}
2500
2953
  className={cn(
2501
- "overflow-hidden rounded-xl bg-popover ring-1 ring-border/60 shadow-comment-raised",
2502
2954
  alignToAnchors
2503
- ? "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"
2504
2958
  : surface === "popover"
2505
2959
  ? "relative"
2506
2960
  : "relative mx-2 mt-3",
2507
2961
  )}
2508
2962
  style={alignToAnchors ? { top: pendingTop } : undefined}
2509
2963
  data-comment-pending
2964
+ data-comment-stack={composingStack?.key}
2965
+ data-comment-stack-state={composingStack ? "composing" : undefined}
2510
2966
  >
2511
- {surface === "popover" ? (
2512
- <div className="flex h-12 items-center gap-1 border-b border-border/70 pe-2 ps-4">
2513
- <span className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
2514
- {t("comments.commentTitle")}
2515
- </span>
2516
- {onClose ? (
2517
- <Tooltip>
2518
- <TooltipTrigger asChild>
2519
- <CommentIconButton
2520
- aria-label={t("comments.close")}
2521
- onClick={handlePendingCancel}
2522
- >
2523
- <IconX size={18} />
2524
- </CommentIconButton>
2525
- </TooltipTrigger>
2526
- <TooltipContent>{t("comments.close")}</TooltipContent>
2527
- </Tooltip>
2528
- ) : null}
2529
- </div>
2530
- ) : null}
2531
- <div className="flex items-start gap-2.5 p-3.5">
2532
- <div className="flex h-9.5 shrink-0 items-center">
2533
- <CommentAvatar email={currentUserEmail} name={currentUserEmail} />
2534
- </div>
2535
- <div className="min-w-0 flex-1">
2536
- {displayedPendingComment.property ? (
2537
- <PropertyThreadTargetLabel
2538
- className="mb-2"
2539
- target={propertyThreadTarget(
2540
- displayedPendingComment.property,
2541
- documentProperties,
2542
- )}
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}
2543
2999
  />
2544
- ) : null}
2545
- {!pendingTargetValid && (
2546
- <p role="alert" className="mb-2 text-xs text-muted-foreground">
2547
- {t("comments.selectTextToComment")}
2548
- </p>
2549
- )}
2550
- <CommentComposer
2551
- ref={pendingInputRef}
2552
- value={pendingText}
2553
- onChange={(text) =>
2554
- onPendingChange?.(displayedPendingComment.id, () => ({
2555
- text,
2556
- }))
2557
- }
2558
- mentions={pendingMentions}
2559
- onMentionsChange={(mentions) =>
2560
- onPendingChange?.(displayedPendingComment.id, () => ({
2561
- mentions,
2562
- }))
2563
- }
2564
- onSubmit={() => void handlePendingSubmit()}
2565
- onAiSubmit={
2566
- commentAi && !displayedPendingComment.fieldId
2567
- ? (selection) => void handlePendingAiSubmit(selection)
2568
- : undefined
2569
- }
2570
- aiDraft={pendingDraft.draft.aiDraft}
2571
- onAiDraftChange={pendingDraft.setAiDraft}
2572
- aiModelStorageKey={
2573
- commentAi && !displayedPendingComment.fieldId
2574
- ? aiModelStorageKey
2575
- : undefined
2576
- }
2577
- onCancel={handlePendingCancel}
2578
- onEscape={() => {
2579
- if (!pendingText.trim()) handlePendingCancel();
2580
- }}
2581
- onFocus={() => {
2582
- const selection = pendingInputRef.current?.getSelection();
2583
- if (selection && pendingFocus)
2584
- pendingFocus.current = selection;
2585
- }}
2586
- onSelectionChange={(selection) => {
2587
- if (pendingFocus) pendingFocus.current = selection;
2588
- }}
2589
- onBlur={() => {
2590
- if (pendingFocus) pendingFocus.current = null;
2591
- }}
2592
- placeholder={t("comments.add")}
2593
- submitLabel={t("comments.submit")}
2594
- submitDisabled={!pendingTargetValid || ambiguousCreate()}
2595
- disabled={pendingSubmitting}
2596
- />
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>
2597
3067
  </div>
3068
+ {composingStack ? (
3069
+ <CommentStackShowAll
3070
+ threads={composingStack.threads}
3071
+ onOpen={() => openStackOn(composingStack.threads[0].threadId)}
3072
+ t={t}
3073
+ />
3074
+ ) : null}
2598
3075
  </div>
2599
3076
  </div>
2600
3077
  )}
@@ -2605,26 +3082,33 @@ export function CommentsSidebar({
2605
3082
  const restingItem = restingItemsById.get(thread.threadId);
2606
3083
  const marginTop = restingItem?.marginTop ?? item.marginTop;
2607
3084
  const translateY = top - (restingItem?.top ?? top);
2608
- const card =
3085
+ const stack =
2609
3086
  "suggestion" in thread
2610
- ? "durability" in thread.suggestion
2611
- ? renderDraftSuggestionCard(thread.suggestion)
2612
- : thread.suggestion.proposalId &&
2613
- (proposalMemberCounts.get(thread.suggestion.proposalId) ??
2614
- 0) >= 2
2615
- ? renderProposalGroup(
2616
- thread.suggestion.proposalId,
2617
- inlineProposalMembers.get(thread.suggestion.proposalId) ?? [
2618
- thread.suggestion,
2619
- ],
2620
- thread.threadId,
2621
- )
2622
- : renderSuggestionCard(thread.suggestion)
2623
- : renderCommentThread(
2624
- thread,
2625
- 0,
2626
- activeThreadId === thread.threadId,
2627
- );
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
+ );
2628
3112
  // A Property thread has no highlight to lose, so it isn't one that
2629
3113
  // went missing.
2630
3114
  const lostHighlight = (candidate: (typeof items)[number]) =>
@@ -3565,6 +4049,7 @@ function ThreadView({
3565
4049
  renderEntry,
3566
4050
  surface = "rail",
3567
4051
  quote,
4052
+ showQuote = false,
3568
4053
  propertyTarget,
3569
4054
  anchorUnavailable = false,
3570
4055
  onClose,
@@ -3611,6 +4096,8 @@ function ThreadView({
3611
4096
  surface?: CommentSurface;
3612
4097
  /** The anchored text, shown as a quote line above the thread in the panel. */
3613
4098
  quote?: string | null;
4099
+ /** Show the quote in the margin too, for a card away from its highlight. */
4100
+ showQuote?: boolean;
3614
4101
  /** The Property a thread is on, shown above it on every surface. */
3615
4102
  propertyTarget?: PropertyThreadTarget | null;
3616
4103
  anchorUnavailable?: boolean;
@@ -3875,18 +4362,33 @@ function ThreadView({
3875
4362
  target={propertyTarget}
3876
4363
  />
3877
4364
  ) : null}
3878
- {isPanel && quote ? (
3879
- <button
3880
- type="button"
3881
- onClick={(event) => {
3882
- event.stopPropagation();
3883
- if (canExpand) onExpand();
3884
- }}
3885
- 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"
3886
- data-comment-quote
3887
- >
3888
- {quote}
3889
- </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
+ )
3890
4392
  ) : null}
3891
4393
  {anchorUnavailable ? (
3892
4394
  <span