@opengeni/react 0.44.5 → 0.46.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/{chunk-L3EWO3UK.js → chunk-3PB7MIT6.js} +243 -22
- package/dist/chunk-3PB7MIT6.js.map +1 -0
- package/dist/{chunk-EHSYZ4KP.js → chunk-FGZUCXZF.js} +2 -2
- package/dist/{chunk-EHSYZ4KP.js.map → chunk-FGZUCXZF.js.map} +1 -1
- package/dist/{chunk-LYRJUU5V.js → chunk-LGVMUIRV.js} +543 -200
- package/dist/chunk-LGVMUIRV.js.map +1 -0
- package/dist/components/user-message-body.d.ts +36 -0
- package/dist/composer.js +2 -2
- package/dist/hooks/use-voice-input.d.ts +8 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +8 -4
- package/dist/index.js.map +1 -1
- package/dist/realtime.js +4 -4
- package/dist/realtime.js.map +1 -1
- package/dist/session-ui.d.ts +2 -0
- package/dist/session-ui.js +7 -3
- package/package.json +2 -2
- package/src/components/composer.tsx +4 -4
- package/src/components/copy-button.tsx +1 -1
- package/src/components/markdown.tsx +1 -1
- package/src/components/message-timeline.tsx +134 -25
- package/src/components/model-picker.tsx +1 -1
- package/src/components/model-policy-picker.tsx +1 -1
- package/src/components/user-message-body.tsx +341 -0
- package/src/hooks/use-codex-accounts.ts +1 -1
- package/src/hooks/use-voice-input.ts +320 -19
- package/src/index.ts +2 -0
- package/src/realtime/realtime-control.tsx +3 -3
- package/src/session-ui.ts +2 -0
- package/src/timeline/activity-rail.tsx +1 -1
- package/src/timeline/shared.tsx +3 -3
- package/src/timeline/turn-summary.tsx +4 -4
- package/dist/chunk-L3EWO3UK.js.map +0 -1
- package/dist/chunk-LYRJUU5V.js.map +0 -1
|
@@ -36,6 +36,11 @@ import { cn } from "../lib/cn";
|
|
|
36
36
|
import { formatClockTime, formatRelativeTime, truncate } from "../lib/format";
|
|
37
37
|
import { prefersReducedMotion } from "../lib/motion";
|
|
38
38
|
import { Markdown } from "./markdown";
|
|
39
|
+
import {
|
|
40
|
+
UserMessageBody,
|
|
41
|
+
UserMessageDisclosureProvider,
|
|
42
|
+
type UserMessageDisclosureContextValue,
|
|
43
|
+
} from "./user-message-body";
|
|
39
44
|
import {
|
|
40
45
|
createTipFollowState,
|
|
41
46
|
readerScrollUpPx,
|
|
@@ -358,6 +363,13 @@ export function MessageTimeline({
|
|
|
358
363
|
* one scroll event (or fired two) — use a count, and clear to 0 on echo.
|
|
359
364
|
*/
|
|
360
365
|
const programmaticScrollRef = useRef(0);
|
|
366
|
+
/**
|
|
367
|
+
* Disclosure height changes are not reader navigation. While an unpinned
|
|
368
|
+
* Show more/less state is active, its clamp/native-anchor scroll echoes must
|
|
369
|
+
* never geometrically re-enable bottom-follow. A later real reader navigation
|
|
370
|
+
* or explicit Jump to latest releases this fence.
|
|
371
|
+
*/
|
|
372
|
+
const disclosureKeepsUnpinnedRef = useRef(false);
|
|
361
373
|
/**
|
|
362
374
|
* Unarmed scroll-away observed; waiting for scrollend (or rAF fallback).
|
|
363
375
|
* Blocks layout tip-follow so a stream token cannot yank before leave settles.
|
|
@@ -369,6 +381,7 @@ export function MessageTimeline({
|
|
|
369
381
|
// deliberate chip remounts (activity→turn wrap, nested key flips) so a fold
|
|
370
382
|
// that already settled closed — or that the reader closed — never reopens.
|
|
371
383
|
const foldMemoryRef = useRef<Map<string, FoldRestingState>>(new Map());
|
|
384
|
+
const userMessageDisclosureMemoryRef = useRef<Map<string, boolean>>(new Map());
|
|
372
385
|
const seenActivityIdsRef = useRef<Set<string>>(new Set());
|
|
373
386
|
const groupKeyByItemIdRef = useRef<Map<string, string>>(new Map());
|
|
374
387
|
const groupOffsetByKeyRef = useRef<Map<string, number>>(new Map());
|
|
@@ -513,16 +526,20 @@ export function MessageTimeline({
|
|
|
513
526
|
target: EventTarget | null;
|
|
514
527
|
currentTarget: EventTarget | null;
|
|
515
528
|
}) => {
|
|
516
|
-
// Nested overflow (code / notice pre) or mostly-horizontal pan: not
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
529
|
+
// Nested overflow (code / notice pre) or mostly-horizontal pan: not
|
|
530
|
+
// timeline reader intent. A real timeline wheel in either direction
|
|
531
|
+
// releases the disclosure fence; downward movement may then re-pin
|
|
532
|
+
// naturally when it reaches the bottom.
|
|
520
533
|
if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) {
|
|
521
534
|
return;
|
|
522
535
|
}
|
|
523
536
|
if (wheelConsumedByNestedScrollable(event)) {
|
|
524
537
|
return;
|
|
525
538
|
}
|
|
539
|
+
disclosureKeepsUnpinnedRef.current = false;
|
|
540
|
+
if (event.deltaY >= 0) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
526
543
|
const node =
|
|
527
544
|
event.currentTarget instanceof HTMLElement ? event.currentTarget : scrollRef.current;
|
|
528
545
|
releasePinFromReader(node);
|
|
@@ -546,10 +563,21 @@ export function MessageTimeline({
|
|
|
546
563
|
) {
|
|
547
564
|
return;
|
|
548
565
|
}
|
|
566
|
+
disclosureKeepsUnpinnedRef.current = false;
|
|
549
567
|
readerIntentArmRef.current = true;
|
|
550
568
|
};
|
|
551
569
|
|
|
552
570
|
const onKeyDown = (event: { key: string; currentTarget: EventTarget | null }) => {
|
|
571
|
+
if (
|
|
572
|
+
event.key === "ArrowUp" ||
|
|
573
|
+
event.key === "ArrowDown" ||
|
|
574
|
+
event.key === "PageUp" ||
|
|
575
|
+
event.key === "PageDown" ||
|
|
576
|
+
event.key === "Home" ||
|
|
577
|
+
event.key === "End"
|
|
578
|
+
) {
|
|
579
|
+
disclosureKeepsUnpinnedRef.current = false;
|
|
580
|
+
}
|
|
553
581
|
if (event.key !== "ArrowUp" && event.key !== "PageUp" && event.key !== "Home") {
|
|
554
582
|
return;
|
|
555
583
|
}
|
|
@@ -574,6 +602,65 @@ export function MessageTimeline({
|
|
|
574
602
|
[cancelLeaveFallback, stopFollow, syncScrollBaseline, writeScrollTop],
|
|
575
603
|
);
|
|
576
604
|
|
|
605
|
+
const beginUserMessageDisclosureChange = useCallback(
|
|
606
|
+
(messageBody: HTMLElement, disclosureControl: HTMLElement) => {
|
|
607
|
+
const node = scrollRef.current;
|
|
608
|
+
if (!node || !node.contains(messageBody)) {
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
const keepBottom = autoFollow && pinnedRef.current && !hasNewerRef.current;
|
|
612
|
+
if (keepBottom) {
|
|
613
|
+
return () => {
|
|
614
|
+
const current = scrollRef.current;
|
|
615
|
+
if (current) {
|
|
616
|
+
snapToBottom(current);
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
disclosureKeepsUnpinnedRef.current = true;
|
|
622
|
+
|
|
623
|
+
const scrollerRect = node.getBoundingClientRect();
|
|
624
|
+
const group = messageBody.closest<HTMLElement>("[data-og-timeline-group-anchor]");
|
|
625
|
+
const groupRect = group?.getBoundingClientRect();
|
|
626
|
+
// Expanding from a visible message top keeps the beginning in place.
|
|
627
|
+
// Collapsing after reading deep in the message keeps the disclosure
|
|
628
|
+
// control in place because the message top is already above the viewport.
|
|
629
|
+
const anchor =
|
|
630
|
+
group &&
|
|
631
|
+
groupRect &&
|
|
632
|
+
groupRect.top >= scrollerRect.top - 1 &&
|
|
633
|
+
groupRect.top < scrollerRect.bottom
|
|
634
|
+
? group
|
|
635
|
+
: disclosureControl;
|
|
636
|
+
const beforeTop = anchor.getBoundingClientRect().top - scrollerRect.top;
|
|
637
|
+
|
|
638
|
+
return () => {
|
|
639
|
+
const current = scrollRef.current;
|
|
640
|
+
if (!current || !current.contains(anchor)) {
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
const currentScrollerTop = current.getBoundingClientRect().top;
|
|
644
|
+
const afterTop = anchor.getBoundingClientRect().top - currentScrollerTop;
|
|
645
|
+
const delta = afterTop - beforeTop;
|
|
646
|
+
if (Math.abs(delta) > 0.5) {
|
|
647
|
+
writeScrollTop(current, current.scrollTop + delta);
|
|
648
|
+
}
|
|
649
|
+
applyPinned(false);
|
|
650
|
+
syncScrollBaseline(current);
|
|
651
|
+
};
|
|
652
|
+
},
|
|
653
|
+
[applyPinned, autoFollow, snapToBottom, syncScrollBaseline, writeScrollTop],
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
const userMessageDisclosureContext = useMemo<UserMessageDisclosureContextValue>(
|
|
657
|
+
() => ({
|
|
658
|
+
expandedByMessageId: userMessageDisclosureMemoryRef.current,
|
|
659
|
+
beginChange: beginUserMessageDisclosureChange,
|
|
660
|
+
}),
|
|
661
|
+
[beginUserMessageDisclosureChange],
|
|
662
|
+
);
|
|
663
|
+
|
|
577
664
|
const driveFollowRef = useRef<(node: HTMLElement, now?: number) => void>(() => undefined);
|
|
578
665
|
const driveFollow = useCallback(
|
|
579
666
|
(node: HTMLElement, nowMs?: number) => {
|
|
@@ -868,6 +955,8 @@ export function MessageTimeline({
|
|
|
868
955
|
groupKeyByItemIdRef.current = new Map();
|
|
869
956
|
groupOffsetByKeyRef.current = new Map();
|
|
870
957
|
foldMemoryRef.current.clear();
|
|
958
|
+
userMessageDisclosureMemoryRef.current.clear();
|
|
959
|
+
disclosureKeepsUnpinnedRef.current = false;
|
|
871
960
|
seenActivityIdsRef.current.clear();
|
|
872
961
|
applyPinned(true);
|
|
873
962
|
}, [allGroups.length, revealed, applyPinned]);
|
|
@@ -1045,6 +1134,12 @@ export function MessageTimeline({
|
|
|
1045
1134
|
if (programmatic) {
|
|
1046
1135
|
programmaticScrollRef.current = 0;
|
|
1047
1136
|
}
|
|
1137
|
+
if (disclosureKeepsUnpinnedRef.current) {
|
|
1138
|
+
stopFollow();
|
|
1139
|
+
applyPinned(false);
|
|
1140
|
+
syncScrollBaseline(node);
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1048
1143
|
|
|
1049
1144
|
if (autoFollow && pinnedRef.current && !hasNewer) {
|
|
1050
1145
|
// Fold / composer / SessionChrome: viewport shrink raises maxScroll without
|
|
@@ -1144,6 +1239,13 @@ export function MessageTimeline({
|
|
|
1144
1239
|
return;
|
|
1145
1240
|
}
|
|
1146
1241
|
cancelLeaveFallback();
|
|
1242
|
+
if (disclosureKeepsUnpinnedRef.current) {
|
|
1243
|
+
programmaticScrollRef.current = 0;
|
|
1244
|
+
stopFollow();
|
|
1245
|
+
applyPinned(false);
|
|
1246
|
+
syncScrollBaseline(node);
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1147
1249
|
if (programmaticScrollRef.current > 0) {
|
|
1148
1250
|
programmaticScrollRef.current = 0;
|
|
1149
1251
|
syncScrollBaseline(node);
|
|
@@ -1164,6 +1266,8 @@ export function MessageTimeline({
|
|
|
1164
1266
|
Unpinned: native scroll anchoring holds the reader's place. */}
|
|
1165
1267
|
<div
|
|
1166
1268
|
ref={scrollRef}
|
|
1269
|
+
data-og-timeline-scroller=""
|
|
1270
|
+
data-og-bottom-follow={autoFollow && pinned && !hasNewer ? "true" : "false"}
|
|
1167
1271
|
tabIndex={-1}
|
|
1168
1272
|
onScroll={onScroll}
|
|
1169
1273
|
onScrollEnd={onScrollEnd}
|
|
@@ -1224,21 +1328,23 @@ export function MessageTimeline({
|
|
|
1224
1328
|
turnSummary,
|
|
1225
1329
|
]}
|
|
1226
1330
|
>
|
|
1227
|
-
<
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
contextCompactionCount
|
|
1240
|
-
|
|
1241
|
-
|
|
1331
|
+
<UserMessageDisclosureProvider value={userMessageDisclosureContext}>
|
|
1332
|
+
<TimelineGroupView
|
|
1333
|
+
group={group}
|
|
1334
|
+
renderMessageText={renderMessageText}
|
|
1335
|
+
onOpenSession={onOpenSession}
|
|
1336
|
+
onMemoryClick={onMemoryClick}
|
|
1337
|
+
onReconnect={onReconnect}
|
|
1338
|
+
resolveProviderLogo={resolveProviderLogo}
|
|
1339
|
+
toolRegistry={toolRegistry}
|
|
1340
|
+
turnSummary={turnSummary}
|
|
1341
|
+
foldLiveCluster={isAgentProgress(next)}
|
|
1342
|
+
trailingAgentText={trailingAgentTextAfterTurn(group, next)}
|
|
1343
|
+
contextCompactionCount={
|
|
1344
|
+
contextCompactionCount > 0 ? contextCompactionCount : undefined
|
|
1345
|
+
}
|
|
1346
|
+
/>
|
|
1347
|
+
</UserMessageDisclosureProvider>
|
|
1242
1348
|
</TimelineGroupRenderBoundary>
|
|
1243
1349
|
</div>
|
|
1244
1350
|
);
|
|
@@ -1354,6 +1460,7 @@ export function MessageTimeline({
|
|
|
1354
1460
|
exit={{ opacity: 0, y: 8 }}
|
|
1355
1461
|
transition={{ duration: 0.15, ease: "easeOut" }}
|
|
1356
1462
|
onClick={() => {
|
|
1463
|
+
disclosureKeepsUnpinnedRef.current = false;
|
|
1357
1464
|
if (hasNewer) {
|
|
1358
1465
|
// Do not pin against the current history page — its bottom
|
|
1359
1466
|
// is not the tip. The pin + snap run when the tip window
|
|
@@ -2077,12 +2184,12 @@ function CompactionRow({ item }: { item: ContextCompactionItem }) {
|
|
|
2077
2184
|
: null;
|
|
2078
2185
|
const title =
|
|
2079
2186
|
item.phase === "started"
|
|
2080
|
-
? "Compacting conversation
|
|
2187
|
+
? "Compacting conversation history…"
|
|
2081
2188
|
: item.phase === "compacted"
|
|
2082
2189
|
? before && after
|
|
2083
|
-
? `Conversation
|
|
2084
|
-
: "Conversation
|
|
2085
|
-
: "Couldn’t compact conversation
|
|
2190
|
+
? `Conversation history compacted · ~${before} → ~${after} estimated history tokens`
|
|
2191
|
+
: "Conversation history compacted"
|
|
2192
|
+
: "Couldn’t compact conversation history";
|
|
2086
2193
|
const subtitle =
|
|
2087
2194
|
item.phase === "compacted"
|
|
2088
2195
|
? "Chat history above is unchanged"
|
|
@@ -2140,7 +2247,7 @@ function MessageFooterTime({ occurredAt }: { occurredAt: string }) {
|
|
|
2140
2247
|
className={cn(
|
|
2141
2248
|
"shrink-0 tabular-nums text-og-xs text-og-fg-subtle",
|
|
2142
2249
|
"opacity-0 transition-opacity duration-150",
|
|
2143
|
-
"group-hover/copy:opacity-100 group-focus-within/copy:opacity-100 pointer-coarse:opacity-
|
|
2250
|
+
"group-hover/copy:opacity-100 group-focus-within/copy:opacity-100 pointer-coarse:opacity-100",
|
|
2144
2251
|
)}
|
|
2145
2252
|
>
|
|
2146
2253
|
{formatClockTime(occurredAt)}
|
|
@@ -2171,7 +2278,9 @@ function UserMessageRow({
|
|
|
2171
2278
|
{renderMessageText ? (
|
|
2172
2279
|
renderMessageText(item.text, item)
|
|
2173
2280
|
) : (
|
|
2174
|
-
<
|
|
2281
|
+
<UserMessageBody messageId={item.id} text={item.text}>
|
|
2282
|
+
<Markdown>{item.text}</Markdown>
|
|
2283
|
+
</UserMessageBody>
|
|
2175
2284
|
)}
|
|
2176
2285
|
</div>
|
|
2177
2286
|
</CopyHoverFrame>
|
|
@@ -100,7 +100,7 @@ export function ModelPicker({
|
|
|
100
100
|
disabled={disabled === true}
|
|
101
101
|
aria-label={label}
|
|
102
102
|
className={cn(
|
|
103
|
-
"h-8 max-w-[180px] cursor-pointer appearance-none truncate rounded-og-md bg-transparent",
|
|
103
|
+
"h-8 max-w-[180px] cursor-pointer appearance-none truncate rounded-og-md bg-transparent pointer-coarse:h-11",
|
|
104
104
|
"py-0 pl-2 pr-6 text-[13px] text-og-fg-muted",
|
|
105
105
|
"transition-colors duration-150 hover:bg-og-surface-2 hover:text-og-fg",
|
|
106
106
|
"focus:outline-none focus-visible:outline-none",
|
|
@@ -573,7 +573,7 @@ export function ModelPolicyPicker(props: ModelPolicyPickerProps) {
|
|
|
573
573
|
disabled={props.disabled}
|
|
574
574
|
aria-label={messages.label}
|
|
575
575
|
className={cn(
|
|
576
|
-
"og-root inline-flex h-[var(--og-model-picker-trigger-height)] min-w-0 max-w-64 items-center gap-1 rounded-full border border-transparent px-2.5 text-og-control text-og-fg-muted outline-none transition-colors hover:border-og-border hover:bg-og-surface-2 hover:text-og-fg focus-visible:ring-2 focus-visible:ring-og-accent/40 disabled:cursor-not-allowed disabled:opacity-50 max-sm:h-
|
|
576
|
+
"og-root inline-flex h-[var(--og-model-picker-trigger-height)] min-w-0 max-w-64 items-center gap-1 rounded-full border border-transparent px-2.5 text-og-control text-og-fg-muted outline-none transition-colors hover:border-og-border hover:bg-og-surface-2 hover:text-og-fg focus-visible:ring-2 focus-visible:ring-og-accent/40 disabled:cursor-not-allowed disabled:opacity-50 max-sm:h-11 max-sm:max-w-[7.5rem] max-sm:px-2",
|
|
577
577
|
props.className,
|
|
578
578
|
)}
|
|
579
579
|
>
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useId,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { cn } from "../lib/cn";
|
|
12
|
+
|
|
13
|
+
const FALLBACK_LINE_THRESHOLD = 12;
|
|
14
|
+
const FALLBACK_TEXT_THRESHOLD = 900;
|
|
15
|
+
const FALLBACK_UNBROKEN_THRESHOLD = 260;
|
|
16
|
+
const INTERACTIVE_DESCENDANT_SELECTOR = [
|
|
17
|
+
"a[href]",
|
|
18
|
+
"area[href]",
|
|
19
|
+
"button",
|
|
20
|
+
"input:not([type='hidden'])",
|
|
21
|
+
"select",
|
|
22
|
+
"textarea",
|
|
23
|
+
"iframe",
|
|
24
|
+
"object",
|
|
25
|
+
"embed",
|
|
26
|
+
"audio[controls]",
|
|
27
|
+
"video[controls]",
|
|
28
|
+
"summary",
|
|
29
|
+
"[contenteditable]:not([contenteditable='false'])",
|
|
30
|
+
"[tabindex]",
|
|
31
|
+
"[role='button']",
|
|
32
|
+
"[role='link']",
|
|
33
|
+
"[role='checkbox']",
|
|
34
|
+
"[role='radio']",
|
|
35
|
+
"[role='switch']",
|
|
36
|
+
"[role='slider']",
|
|
37
|
+
"[role='spinbutton']",
|
|
38
|
+
"[role='textbox']",
|
|
39
|
+
"[role='combobox']",
|
|
40
|
+
"[role='listbox']",
|
|
41
|
+
"[role='menuitem']",
|
|
42
|
+
"[role='option']",
|
|
43
|
+
"[role='tab']",
|
|
44
|
+
"[role='treeitem']",
|
|
45
|
+
].join(",");
|
|
46
|
+
|
|
47
|
+
function composedParentElement(element: Element): Element | null {
|
|
48
|
+
if (element.assignedSlot) {
|
|
49
|
+
return element.assignedSlot;
|
|
50
|
+
}
|
|
51
|
+
if (element.parentElement) {
|
|
52
|
+
return element.parentElement;
|
|
53
|
+
}
|
|
54
|
+
const root = element.getRootNode();
|
|
55
|
+
return typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot ? root.host : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hasComposedInertAncestor(element: Element, boundary: Element): boolean {
|
|
59
|
+
let current: Element | null = element;
|
|
60
|
+
while (current) {
|
|
61
|
+
if (current.hasAttribute("inert")) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
if (current === boundary) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
current = composedParentElement(current);
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Native inert crosses shadow boundaries, but ordinary selectors do not.
|
|
74
|
+
* Recursively inspect open roots control-by-control so visible shadow content
|
|
75
|
+
* stays interactive. An opaque custom-element host is the conservative focus
|
|
76
|
+
* boundary for a closed root that cannot be inspected.
|
|
77
|
+
*/
|
|
78
|
+
function collectInteractionBoundaries(root: ParentNode): HTMLElement[] {
|
|
79
|
+
const boundaries: HTMLElement[] = [];
|
|
80
|
+
const scopes: ParentNode[] = [root];
|
|
81
|
+
for (let scopeIndex = 0; scopeIndex < scopes.length; scopeIndex += 1) {
|
|
82
|
+
for (const element of scopes[scopeIndex]!.querySelectorAll("*")) {
|
|
83
|
+
if (!(element instanceof HTMLElement)) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const shadowRoot = element.shadowRoot;
|
|
87
|
+
const opaqueCustomElement = element.localName.includes("-") && !shadowRoot;
|
|
88
|
+
if (element.matches(INTERACTIVE_DESCENDANT_SELECTOR) || opaqueCustomElement) {
|
|
89
|
+
boundaries.push(element);
|
|
90
|
+
}
|
|
91
|
+
if (shadowRoot) {
|
|
92
|
+
scopes.push(shadowRoot);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return boundaries;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isFullyInsideVisiblePreview(rect: DOMRect, clipRect: DOMRect): boolean {
|
|
100
|
+
return (
|
|
101
|
+
rect.width > 0 &&
|
|
102
|
+
rect.height > 0 &&
|
|
103
|
+
rect.top >= clipRect.top - 1 &&
|
|
104
|
+
rect.bottom <= clipRect.bottom + 1 &&
|
|
105
|
+
rect.left >= clipRect.left - 1 &&
|
|
106
|
+
rect.right <= clipRect.right + 1
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
type RestoreDisclosureAnchor = (() => void) | null;
|
|
111
|
+
|
|
112
|
+
export type UserMessageDisclosureContextValue = {
|
|
113
|
+
expandedByMessageId: Map<string, boolean>;
|
|
114
|
+
beginChange: (
|
|
115
|
+
messageBody: HTMLElement,
|
|
116
|
+
disclosureControl: HTMLElement,
|
|
117
|
+
) => RestoreDisclosureAnchor;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const UserMessageDisclosureContext = createContext<UserMessageDisclosureContextValue | null>(null);
|
|
121
|
+
|
|
122
|
+
export function UserMessageDisclosureProvider({
|
|
123
|
+
value,
|
|
124
|
+
children,
|
|
125
|
+
}: {
|
|
126
|
+
value: UserMessageDisclosureContextValue;
|
|
127
|
+
children: ReactNode;
|
|
128
|
+
}) {
|
|
129
|
+
return (
|
|
130
|
+
<UserMessageDisclosureContext.Provider value={value}>
|
|
131
|
+
{children}
|
|
132
|
+
</UserMessageDisclosureContext.Provider>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Deterministic first-paint fallback for runtimes without layout measurement.
|
|
138
|
+
* Real browsers replace this estimate with the rendered-height decision in the
|
|
139
|
+
* first layout effect. The complete text always remains mounted either way.
|
|
140
|
+
*/
|
|
141
|
+
export function userMessageLikelyNeedsDisclosure(text: string): boolean {
|
|
142
|
+
const lines = text.split(/\r?\n/);
|
|
143
|
+
return (
|
|
144
|
+
lines.length > FALLBACK_LINE_THRESHOLD ||
|
|
145
|
+
text.length > FALLBACK_TEXT_THRESHOLD ||
|
|
146
|
+
lines.some((line) => line.length > FALLBACK_UNBROKEN_THRESHOLD)
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export type UserMessageBodyProps = {
|
|
151
|
+
/** Durable timeline item id. Expansion memory is keyed by this value. */
|
|
152
|
+
messageId: string;
|
|
153
|
+
/** Complete source text. Used only for the deterministic measurement fallback. */
|
|
154
|
+
text: string;
|
|
155
|
+
children: ReactNode;
|
|
156
|
+
className?: string | undefined;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Lossless disclosure boundary for already-sent user-message text.
|
|
161
|
+
*
|
|
162
|
+
* The full rendered subtree always remains in the DOM. A real browser decides
|
|
163
|
+
* whether disclosure is needed from rendered height (including Markdown
|
|
164
|
+
* structure and wrapping); the source-text heuristic is only a deterministic
|
|
165
|
+
* fallback for SSR/test environments without layout. Timeline-owned context
|
|
166
|
+
* remembers expansion per durable message id and preserves the reader's scroll
|
|
167
|
+
* anchor when height changes.
|
|
168
|
+
*/
|
|
169
|
+
export function UserMessageBody({ messageId, text, children, className }: UserMessageBodyProps) {
|
|
170
|
+
const disclosure = useContext(UserMessageDisclosureContext);
|
|
171
|
+
const [expanded, setExpanded] = useState(
|
|
172
|
+
() => disclosure?.expandedByMessageId.get(messageId) ?? false,
|
|
173
|
+
);
|
|
174
|
+
const [collapsible, setCollapsible] = useState(() => userMessageLikelyNeedsDisclosure(text));
|
|
175
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
176
|
+
const clipRef = useRef<HTMLDivElement | null>(null);
|
|
177
|
+
const contentRef = useRef<HTMLDivElement | null>(null);
|
|
178
|
+
const thresholdRef = useRef<HTMLSpanElement | null>(null);
|
|
179
|
+
const pendingRestoreRef = useRef<RestoreDisclosureAnchor>(null);
|
|
180
|
+
const managedInertDescendantsRef = useRef(new Set<HTMLElement>());
|
|
181
|
+
const contentId = `og-user-message-${useId().replace(/:/g, "")}`;
|
|
182
|
+
const collapsed = collapsible && !expanded;
|
|
183
|
+
|
|
184
|
+
const restoreManagedInertDescendants = useCallback(() => {
|
|
185
|
+
for (const descendant of managedInertDescendantsRef.current) {
|
|
186
|
+
if (descendant.hasAttribute("data-og-user-message-managed-inert")) {
|
|
187
|
+
descendant.removeAttribute("inert");
|
|
188
|
+
descendant.removeAttribute("data-og-user-message-managed-inert");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
managedInertDescendantsRef.current.clear();
|
|
192
|
+
}, []);
|
|
193
|
+
|
|
194
|
+
const syncCollapsedInteractivity = useCallback(() => {
|
|
195
|
+
restoreManagedInertDescendants();
|
|
196
|
+
if (!collapsed) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const clip = clipRef.current;
|
|
201
|
+
const content = contentRef.current;
|
|
202
|
+
if (!clip || !content) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const clipRect = clip.getBoundingClientRect();
|
|
207
|
+
if (clipRect.height <= 0) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
for (const descendant of collectInteractionBoundaries(content)) {
|
|
212
|
+
if (hasComposedInertAncestor(descendant, content)) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const rect = descendant.getBoundingClientRect();
|
|
216
|
+
if (isFullyInsideVisiblePreview(rect, clipRect)) {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
descendant.setAttribute("inert", "");
|
|
220
|
+
descendant.setAttribute("data-og-user-message-managed-inert", "");
|
|
221
|
+
managedInertDescendantsRef.current.add(descendant);
|
|
222
|
+
}
|
|
223
|
+
}, [collapsed, restoreManagedInertDescendants]);
|
|
224
|
+
|
|
225
|
+
const measure = useCallback(() => {
|
|
226
|
+
const content = contentRef.current;
|
|
227
|
+
const threshold = thresholdRef.current;
|
|
228
|
+
if (!content || !threshold) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const renderedHeight = Math.max(content.scrollHeight, content.getBoundingClientRect().height);
|
|
232
|
+
const collapseHeight = Math.max(
|
|
233
|
+
threshold.offsetHeight,
|
|
234
|
+
threshold.getBoundingClientRect().height,
|
|
235
|
+
);
|
|
236
|
+
setCollapsible(
|
|
237
|
+
renderedHeight > 0 && collapseHeight > 0
|
|
238
|
+
? renderedHeight > collapseHeight + 1
|
|
239
|
+
: userMessageLikelyNeedsDisclosure(text),
|
|
240
|
+
);
|
|
241
|
+
}, [text]);
|
|
242
|
+
|
|
243
|
+
useLayoutEffect(() => {
|
|
244
|
+
measure();
|
|
245
|
+
const content = contentRef.current;
|
|
246
|
+
const threshold = thresholdRef.current;
|
|
247
|
+
const observer =
|
|
248
|
+
typeof ResizeObserver === "undefined"
|
|
249
|
+
? null
|
|
250
|
+
: new ResizeObserver(() => {
|
|
251
|
+
measure();
|
|
252
|
+
syncCollapsedInteractivity();
|
|
253
|
+
});
|
|
254
|
+
if (content) {
|
|
255
|
+
observer?.observe(content);
|
|
256
|
+
}
|
|
257
|
+
if (threshold) {
|
|
258
|
+
observer?.observe(threshold);
|
|
259
|
+
}
|
|
260
|
+
const handleResize = () => {
|
|
261
|
+
measure();
|
|
262
|
+
syncCollapsedInteractivity();
|
|
263
|
+
};
|
|
264
|
+
window.addEventListener("resize", handleResize);
|
|
265
|
+
return () => {
|
|
266
|
+
observer?.disconnect();
|
|
267
|
+
window.removeEventListener("resize", handleResize);
|
|
268
|
+
};
|
|
269
|
+
}, [measure, syncCollapsedInteractivity]);
|
|
270
|
+
|
|
271
|
+
useLayoutEffect(() => {
|
|
272
|
+
syncCollapsedInteractivity();
|
|
273
|
+
return restoreManagedInertDescendants;
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
useLayoutEffect(() => {
|
|
277
|
+
const restore = pendingRestoreRef.current;
|
|
278
|
+
pendingRestoreRef.current = null;
|
|
279
|
+
restore?.();
|
|
280
|
+
}, [expanded]);
|
|
281
|
+
|
|
282
|
+
const toggle = (control: HTMLButtonElement) => {
|
|
283
|
+
const root = rootRef.current;
|
|
284
|
+
pendingRestoreRef.current = root && disclosure ? disclosure.beginChange(root, control) : null;
|
|
285
|
+
const next = !expanded;
|
|
286
|
+
if (!next && contentRef.current?.contains(document.activeElement)) {
|
|
287
|
+
control.focus({ preventScroll: true });
|
|
288
|
+
}
|
|
289
|
+
disclosure?.expandedByMessageId.set(messageId, next);
|
|
290
|
+
setExpanded(next);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
<div
|
|
295
|
+
ref={rootRef}
|
|
296
|
+
data-og-user-message-body=""
|
|
297
|
+
data-og-message-id={messageId}
|
|
298
|
+
data-og-expanded={expanded ? "true" : "false"}
|
|
299
|
+
className={cn("relative min-w-0", className)}
|
|
300
|
+
>
|
|
301
|
+
<span
|
|
302
|
+
ref={thresholdRef}
|
|
303
|
+
aria-hidden="true"
|
|
304
|
+
className="pointer-events-none absolute h-56 w-0 invisible sm:h-72"
|
|
305
|
+
/>
|
|
306
|
+
<div
|
|
307
|
+
ref={clipRef}
|
|
308
|
+
id={contentId}
|
|
309
|
+
data-og-user-message-clip=""
|
|
310
|
+
className={cn("relative min-w-0", collapsed && "max-h-56 overflow-hidden sm:max-h-72")}
|
|
311
|
+
>
|
|
312
|
+
<div
|
|
313
|
+
ref={contentRef}
|
|
314
|
+
data-og-user-message-content=""
|
|
315
|
+
className="min-w-0 [overflow-wrap:anywhere]"
|
|
316
|
+
>
|
|
317
|
+
{children}
|
|
318
|
+
</div>
|
|
319
|
+
{collapsed ? (
|
|
320
|
+
<span
|
|
321
|
+
aria-hidden="true"
|
|
322
|
+
data-og-user-message-fade=""
|
|
323
|
+
className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-og-surface-2 via-og-surface-2/90 to-transparent"
|
|
324
|
+
/>
|
|
325
|
+
) : null}
|
|
326
|
+
</div>
|
|
327
|
+
{collapsible ? (
|
|
328
|
+
<button
|
|
329
|
+
type="button"
|
|
330
|
+
aria-controls={contentId}
|
|
331
|
+
aria-expanded={expanded}
|
|
332
|
+
data-og-user-message-disclosure=""
|
|
333
|
+
className="mt-1.5 inline-flex min-h-7 items-center rounded-og-sm px-1.5 text-og-xs font-medium text-og-fg-muted outline-none transition-colors hover:bg-og-surface-3/60 hover:text-og-fg focus-visible:ring-2 focus-visible:ring-og-accent/45 pointer-coarse:min-h-11"
|
|
334
|
+
onClick={(event) => toggle(event.currentTarget)}
|
|
335
|
+
>
|
|
336
|
+
{expanded ? "Show less" : "Show more"}
|
|
337
|
+
</button>
|
|
338
|
+
) : null}
|
|
339
|
+
</div>
|
|
340
|
+
);
|
|
341
|
+
}
|