@opengeni/react 0.44.4 → 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.
@@ -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";
@@ -6,8 +6,10 @@ import {
6
6
  QueueSurface,
7
7
  SessionChrome,
8
8
  TimelineRow,
9
- sessionChromeGoalPillState
10
- } from "./chunk-LYRJUU5V.js";
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
- sessionChromeGoalPillState
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.44.4",
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.3",
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 tip leave.
517
- if (event.deltaY >= 0) {
518
- return;
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
- <TimelineGroupView
1228
- group={group}
1229
- renderMessageText={renderMessageText}
1230
- onOpenSession={onOpenSession}
1231
- onMemoryClick={onMemoryClick}
1232
- onReconnect={onReconnect}
1233
- resolveProviderLogo={resolveProviderLogo}
1234
- toolRegistry={toolRegistry}
1235
- turnSummary={turnSummary}
1236
- foldLiveCluster={isAgentProgress(next)}
1237
- trailingAgentText={trailingAgentTextAfterTurn(group, next)}
1238
- contextCompactionCount={
1239
- contextCompactionCount > 0 ? contextCompactionCount : undefined
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
- <Markdown>{item.text}</Markdown>
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
+ }
@@ -80,7 +80,7 @@ export type UseCodexAccountsResult = {
80
80
 
81
81
  const EMPTY_SETTINGS: CodexRotationSettings = {
82
82
  rotationEnabled: false,
83
- rotationStrategy: "most_remaining",
83
+ rotationStrategy: "sharded",
84
84
  activeCredentialId: null,
85
85
  };
86
86
 
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,