@opengeni/react 0.44.5 → 0.45.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.
- package/README.md +3 -0
- package/dist/{chunk-LYRJUU5V.js → chunk-FD72GJMK.js} +533 -190
- package/dist/chunk-FD72GJMK.js.map +1 -0
- package/dist/components/user-message-body.d.ts +36 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -2
- package/dist/index.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/markdown.tsx +1 -1
- package/src/components/message-timeline.tsx +129 -20
- package/src/components/user-message-body.tsx +341 -0
- package/src/hooks/use-codex-accounts.ts +1 -1
- package/src/index.ts +2 -0
- package/src/session-ui.ts +2 -0
- package/dist/chunk-LYRJUU5V.js.map +0 -1
package/dist/session-ui.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export { HumanInputSurface } from "./components/human-input-surface";
|
|
|
4
4
|
export type { HumanInputSurfaceProps } from "./components/human-input-surface";
|
|
5
5
|
export { MessageTimeline, TimelineRow } from "./components/message-timeline";
|
|
6
6
|
export type { MessageTimelineProps } from "./components/message-timeline";
|
|
7
|
+
export { UserMessageBody, userMessageLikelyNeedsDisclosure } from "./components/user-message-body";
|
|
8
|
+
export type { UserMessageBodyProps } from "./components/user-message-body";
|
|
7
9
|
export { BUILT_IN_TURN_SUMMARY_FACET_IDS } from "./timeline/turn-summary";
|
|
8
10
|
export type { BuiltInTurnSummaryFacetId, TurnSummaryContext, TurnSummaryFacet, TurnSummaryFacetConfiguration, TurnSummaryFacetResult, TurnSummaryOptions, } from "./timeline/turn-summary";
|
|
9
11
|
export { QueueSurface } from "./components/queue-surface";
|
package/dist/session-ui.js
CHANGED
|
@@ -6,8 +6,10 @@ import {
|
|
|
6
6
|
QueueSurface,
|
|
7
7
|
SessionChrome,
|
|
8
8
|
TimelineRow,
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
UserMessageBody,
|
|
10
|
+
sessionChromeGoalPillState,
|
|
11
|
+
userMessageLikelyNeedsDisclosure
|
|
12
|
+
} from "./chunk-FD72GJMK.js";
|
|
11
13
|
import "./chunk-VCBFUSUZ.js";
|
|
12
14
|
import "./chunk-LWR4MXSS.js";
|
|
13
15
|
import "./chunk-U3CSCMWA.js";
|
|
@@ -22,6 +24,8 @@ export {
|
|
|
22
24
|
QueueSurface,
|
|
23
25
|
SessionChrome,
|
|
24
26
|
TimelineRow,
|
|
25
|
-
|
|
27
|
+
UserMessageBody,
|
|
28
|
+
sessionChromeGoalPillState,
|
|
29
|
+
userMessageLikelyNeedsDisclosure
|
|
26
30
|
};
|
|
27
31
|
//# sourceMappingURL=session-ui.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.0",
|
|
4
4
|
"description": "React hooks and styled components for OpenGeni: live session streaming, chat composer, message timeline, session status, and fleet views — token-themed (CSS variables), dark-first, built on Tailwind v4 + Radix + Motion.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"@dnd-kit/utilities": "3.2.2",
|
|
76
76
|
"@fontsource-variable/noto-sans-arabic": "5.2.10",
|
|
77
77
|
"@fontsource-variable/noto-sans-jp": "5.2.10",
|
|
78
|
-
"@opengeni/sdk": "^0.44.
|
|
78
|
+
"@opengeni/sdk": "^0.44.6",
|
|
79
79
|
"clsx": "^2.1.1",
|
|
80
80
|
"lucide-react": "^1.8.0",
|
|
81
81
|
"motion": "^12.0.0",
|
|
@@ -97,7 +97,7 @@ const components: Components = {
|
|
|
97
97
|
),
|
|
98
98
|
a: ({ children, ...props }) => (
|
|
99
99
|
<a
|
|
100
|
-
className="break-words font-medium text-og-accent underline-offset-2 hover:underline"
|
|
100
|
+
className="break-words font-medium text-og-accent-strong underline-offset-2 hover:underline"
|
|
101
101
|
target="_blank"
|
|
102
102
|
rel="noreferrer noopener"
|
|
103
103
|
{...props}
|
|
@@ -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
|
|
@@ -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>
|
|
@@ -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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -460,6 +460,8 @@ export {
|
|
|
460
460
|
export type { LatencyModeId, PickerBillingClass, PickerModelRow } from "./model-policy";
|
|
461
461
|
export { MessageTimeline, TimelineRow } from "./components/message-timeline";
|
|
462
462
|
export type { MessageTimelineProps } from "./components/message-timeline";
|
|
463
|
+
export { UserMessageBody, userMessageLikelyNeedsDisclosure } from "./components/user-message-body";
|
|
464
|
+
export type { UserMessageBodyProps } from "./components/user-message-body";
|
|
463
465
|
export { Markdown } from "./components/markdown";
|
|
464
466
|
export { CopyButton, CopyHoverFrame } from "./components/copy-button";
|
|
465
467
|
export { copyTextToClipboard, tableElementToTsv } from "./lib/clipboard";
|
package/src/session-ui.ts
CHANGED
|
@@ -11,6 +11,8 @@ export { HumanInputSurface } from "./components/human-input-surface";
|
|
|
11
11
|
export type { HumanInputSurfaceProps } from "./components/human-input-surface";
|
|
12
12
|
export { MessageTimeline, TimelineRow } from "./components/message-timeline";
|
|
13
13
|
export type { MessageTimelineProps } from "./components/message-timeline";
|
|
14
|
+
export { UserMessageBody, userMessageLikelyNeedsDisclosure } from "./components/user-message-body";
|
|
15
|
+
export type { UserMessageBodyProps } from "./components/user-message-body";
|
|
14
16
|
export { BUILT_IN_TURN_SUMMARY_FACET_IDS } from "./timeline/turn-summary";
|
|
15
17
|
export type {
|
|
16
18
|
BuiltInTurnSummaryFacetId,
|