@opengeni/react 0.36.1 → 0.40.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 (85) hide show
  1. package/README.md +92 -2
  2. package/dist/{chunk-J2RVJ6JX.js → chunk-4IJCL7YO.js} +13 -2
  3. package/dist/chunk-4IJCL7YO.js.map +1 -0
  4. package/dist/{chunk-7CJOAW3V.js → chunk-EB67J347.js} +1 -135
  5. package/dist/chunk-EB67J347.js.map +1 -0
  6. package/dist/chunk-EKZH6IDG.js +141 -0
  7. package/dist/chunk-EKZH6IDG.js.map +1 -0
  8. package/dist/{chunk-OKKMZDQF.js → chunk-JALF5FI3.js} +9 -7
  9. package/dist/{chunk-OKKMZDQF.js.map → chunk-JALF5FI3.js.map} +1 -1
  10. package/dist/{chunk-Z5FV355Z.js → chunk-KR2SK5GJ.js} +1382 -724
  11. package/dist/chunk-KR2SK5GJ.js.map +1 -0
  12. package/dist/{chunk-N4XEBE23.js → chunk-OMCFRWHL.js} +22 -13
  13. package/dist/{chunk-N4XEBE23.js.map → chunk-OMCFRWHL.js.map} +1 -1
  14. package/dist/{chunk-FT2TXNGZ.js → chunk-Q2NCKWTK.js} +183 -18
  15. package/dist/chunk-Q2NCKWTK.js.map +1 -0
  16. package/dist/{chunk-22RM4GMO.js → chunk-SJKT4TKW.js} +26 -2
  17. package/dist/chunk-SJKT4TKW.js.map +1 -0
  18. package/dist/{chunk-ALA3UGWB.js → chunk-SXIQK54Z.js} +8 -6
  19. package/dist/{chunk-ALA3UGWB.js.map → chunk-SXIQK54Z.js.map} +1 -1
  20. package/dist/chunk-WZT5G5OR.js +433 -0
  21. package/dist/chunk-WZT5G5OR.js.map +1 -0
  22. package/dist/client.d.ts +2 -0
  23. package/dist/components/chat-composer.d.ts +7 -1
  24. package/dist/components/human-input-form.d.ts +16 -6
  25. package/dist/components/human-input-surface.d.ts +19 -0
  26. package/dist/components/machine-input-display.d.ts +12 -0
  27. package/dist/components/message-timeline.d.ts +6 -1
  28. package/dist/components/model-policy-picker.d.ts +78 -0
  29. package/dist/components/session-chrome.d.ts +2 -1
  30. package/dist/components/tip-follow.d.ts +30 -0
  31. package/dist/composer.d.ts +2 -0
  32. package/dist/composer.js +22 -5
  33. package/dist/hooks/use-composer.d.ts +10 -1
  34. package/dist/index.d.ts +7 -1
  35. package/dist/index.js +2261 -2230
  36. package/dist/index.js.map +1 -1
  37. package/dist/machines.js +4 -3
  38. package/dist/model-policy.d.ts +12 -4
  39. package/dist/model-policy.js +5 -1
  40. package/dist/realtime/dropdown-menu.d.ts +16 -0
  41. package/dist/realtime/realtime-control.d.ts +126 -0
  42. package/dist/realtime.d.ts +11 -0
  43. package/dist/realtime.js +1023 -0
  44. package/dist/realtime.js.map +1 -0
  45. package/dist/session-context.d.ts +7 -1
  46. package/dist/session-ui.d.ts +2 -0
  47. package/dist/session-ui.js +6 -4
  48. package/dist/session.js +5 -4
  49. package/dist/timeline/compute-label.d.ts +2 -0
  50. package/dist/timeline/index.d.ts +1 -0
  51. package/dist/timeline/types.d.ts +5 -0
  52. package/package.json +7 -2
  53. package/src/client.ts +13 -0
  54. package/src/components/chat-composer.tsx +19 -5
  55. package/src/components/composer-transcription-control.tsx +2 -2
  56. package/src/components/composer.tsx +14 -5
  57. package/src/components/human-input-form.tsx +506 -186
  58. package/src/components/human-input-surface.tsx +80 -0
  59. package/src/components/machine-input-display.ts +83 -0
  60. package/src/components/message-timeline.tsx +348 -308
  61. package/src/components/model-policy-picker.tsx +587 -0
  62. package/src/components/sandbox-files.tsx +1 -1
  63. package/src/components/session-chrome.tsx +141 -15
  64. package/src/components/tip-follow.ts +127 -6
  65. package/src/composer.ts +13 -0
  66. package/src/hooks/use-composer.ts +229 -29
  67. package/src/index.ts +19 -0
  68. package/src/model-policy.ts +51 -7
  69. package/src/realtime/dropdown-menu.tsx +123 -0
  70. package/src/realtime/realtime-control.tsx +1178 -0
  71. package/src/realtime.ts +27 -0
  72. package/src/session-context.ts +16 -0
  73. package/src/session-ui.ts +2 -0
  74. package/src/timeline/compute-label.tsx +18 -0
  75. package/src/timeline/index.ts +3 -0
  76. package/src/timeline/projection.ts +21 -1
  77. package/src/timeline/tool-renderers.tsx +232 -43
  78. package/src/timeline/types.ts +5 -0
  79. package/styles/index.css +61 -0
  80. package/styles/tokens.css +2 -0
  81. package/dist/chunk-22RM4GMO.js.map +0 -1
  82. package/dist/chunk-7CJOAW3V.js.map +0 -1
  83. package/dist/chunk-FT2TXNGZ.js.map +0 -1
  84. package/dist/chunk-J2RVJ6JX.js.map +0 -1
  85. package/dist/chunk-Z5FV355Z.js.map +0 -1
@@ -18,6 +18,7 @@
18
18
  * | `--og-session-chrome-chip-pad-x` / `--og-session-chrome-chip-gap` | Chip padding / rail gap |
19
19
  * | `--og-session-chrome-rail-pad` | Outer rail inset |
20
20
  * | `--og-session-chrome-panel-pad-x` / `-y` | Expanded panel padding |
21
+ * | `--og-session-chrome-panel-max-height` | Cap for expanded body (scrolls inside) |
21
22
  * | `--og-session-chrome-duration` / `--og-session-chrome-ease` | Expand + pill motion |
22
23
  * | `--og-session-chrome-crossfade-duration` | Segment content opacity crossfade |
23
24
  * | `--og-session-chrome-row-hover` | Inbox / queue row hover wash |
@@ -32,6 +33,7 @@
32
33
  */
33
34
  import type { SessionGoal, SessionPendingInputPreview, SessionTurn } from "@opengeni/sdk";
34
35
  import {
36
+ AudioLinesIcon,
35
37
  ArrowDownIcon,
36
38
  ArrowUpIcon,
37
39
  BotIcon,
@@ -64,7 +66,7 @@ import { cn } from "../lib/cn";
64
66
  import { requestQueueDraftEdit } from "./queue-draft-policy";
65
67
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
66
68
 
67
- export type SessionChromeSignalId = "incoming" | "queue" | "goal" | "agents";
69
+ export type SessionChromeSignalId = "incoming" | "steering" | "queue" | "goal" | "agents";
68
70
 
69
71
  export type SessionChromeSignalTone = "neutral" | "accent" | "waiting" | "running";
70
72
 
@@ -105,6 +107,11 @@ type GoalPillState =
105
107
  | "invariant_broken"
106
108
  | "completed";
107
109
 
110
+ type QueuedTurnPresentation = {
111
+ kind: "prompt" | "realtime_voice" | "realtime_voice_handoff";
112
+ text: string;
113
+ };
114
+
108
115
  const GOAL_LABEL: Record<GoalPillState, string> = {
109
116
  pursuing: "Pursuing",
110
117
  scheduled: "Scheduled",
@@ -115,6 +122,28 @@ const GOAL_LABEL: Record<GoalPillState, string> = {
115
122
  completed: "Done",
116
123
  };
117
124
 
125
+ function queuedTurnPresentation(turn: SessionTurn): QueuedTurnPresentation {
126
+ const realtimeDelegation = objectValue(turn.metadata.realtimeDelegation);
127
+ const inputTranscript = realtimeDelegation?.inputTranscript;
128
+ if (typeof inputTranscript === "string" && inputTranscript.trim()) {
129
+ return { kind: "realtime_voice", text: inputTranscript.trim() };
130
+ }
131
+ if (objectValue(turn.metadata.realtimeTailFlush)) {
132
+ return { kind: "realtime_voice_handoff", text: "Remaining voice context" };
133
+ }
134
+ return { kind: "prompt", text: turn.prompt };
135
+ }
136
+
137
+ function isSteeringTurn(turn: SessionTurn): boolean {
138
+ return turn.metadata.delivery === "steer";
139
+ }
140
+
141
+ function objectValue(value: unknown): Record<string, unknown> | null {
142
+ return value !== null && typeof value === "object" && !Array.isArray(value)
143
+ ? (value as Record<string, unknown>)
144
+ : null;
145
+ }
146
+
118
147
  /** Select pill state from the goal's authoritative continuation projection. */
119
148
  export function sessionChromeGoalPillState(
120
149
  goalStatus: "active" | "paused" | "completed",
@@ -215,6 +244,34 @@ export function SessionChrome({
215
244
  const record = goal?.goal ?? null;
216
245
  const incoming = queue.pendingInputs;
217
246
  const turns = queue.queue;
247
+ const composerSteering = composer?.steering ?? null;
248
+ const { steering, queuedTurns } = useMemo(() => {
249
+ const pendingQueueSteer =
250
+ turns.find((turn) => queue.pendingByTurn[turn.id] === "steer") ?? null;
251
+ const durableQueueSteer =
252
+ !pendingQueueSteer && turns[0] && isSteeringTurn(turns[0]) ? turns[0] : null;
253
+ const currentSteering =
254
+ composerSteering?.phase === "submitting"
255
+ ? composerSteering
256
+ : pendingQueueSteer
257
+ ? {
258
+ phase: "submitting" as const,
259
+ text: queuedTurnPresentation(pendingQueueSteer).text,
260
+ turnId: pendingQueueSteer.id,
261
+ }
262
+ : durableQueueSteer
263
+ ? {
264
+ phase: "accepted" as const,
265
+ text: queuedTurnPresentation(durableQueueSteer).text,
266
+ turnId: durableQueueSteer.id,
267
+ }
268
+ : composerSteering;
269
+ const currentTurnId = currentSteering?.turnId ?? null;
270
+ return {
271
+ steering: currentSteering,
272
+ queuedTurns: currentTurnId ? turns.filter((turn) => turn.id !== currentTurnId) : turns,
273
+ };
274
+ }, [composerSteering, queue.pendingByTurn, turns]);
218
275
  const canMutateQueue = !readOnly && composer !== undefined;
219
276
 
220
277
  const liveGoal =
@@ -250,14 +307,44 @@ export function SessionChrome({
250
307
  icon: <InboxIcon className="size-3" />,
251
308
  });
252
309
  }
253
- if (turns.length > 0) {
254
- const detail = turns[0]?.prompt;
310
+ if (steering) {
311
+ rows.push({
312
+ id: "steering",
313
+ label: "Changing direction…",
314
+ detail: steering.text,
315
+ tone: "accent",
316
+ icon:
317
+ steering.phase === "submitting" ? (
318
+ <Loader2Icon className="size-3 animate-og-spin" />
319
+ ) : (
320
+ <ZapIcon className="size-3" />
321
+ ),
322
+ });
323
+ }
324
+ if (queuedTurns.length > 0) {
325
+ const presentations = queuedTurns.map(queuedTurnPresentation);
326
+ const first = presentations[0];
327
+ const allVoiceRequests = presentations.every(({ kind }) => kind === "realtime_voice");
328
+ const onlyVoiceHandoff =
329
+ presentations.length === 1 && first?.kind === "realtime_voice_handoff";
330
+ const voiceOnly = allVoiceRequests || onlyVoiceHandoff;
331
+ const detail = first?.text;
255
332
  rows.push({
256
333
  id: "queue",
257
- label: `${turns.length} queued prompt${turns.length === 1 ? "" : "s"}`,
334
+ label: allVoiceRequests
335
+ ? queuedTurns.length === 1
336
+ ? "Voice request queued"
337
+ : `${queuedTurns.length} voice requests queued`
338
+ : onlyVoiceHandoff
339
+ ? "Voice handoff queued"
340
+ : `${queuedTurns.length} queued prompt${queuedTurns.length === 1 ? "" : "s"}`,
258
341
  ...(detail ? { detail } : {}),
259
342
  tone: "neutral",
260
- icon: <ListOrderedIcon className="size-3" />,
343
+ icon: voiceOnly ? (
344
+ <AudioLinesIcon className="size-3" />
345
+ ) : (
346
+ <ListOrderedIcon className="size-3" />
347
+ ),
261
348
  });
262
349
  }
263
350
  if (record && goalState) {
@@ -292,7 +379,7 @@ export function SessionChrome({
292
379
  });
293
380
  }
294
381
  return rows;
295
- }, [agentsSignal, elapsed, goalState, incoming, record, turns]);
382
+ }, [agentsSignal, elapsed, goalState, incoming, queuedTurns, record, steering]);
296
383
 
297
384
  const [activeUncontrolled, setActiveUncontrolled] = useState<SessionChromeSignalId | null>(
298
385
  defaultActive,
@@ -320,10 +407,10 @@ export function SessionChrome({
320
407
 
321
408
  useEffect(() => {
322
409
  if (active !== "queue" || !replaceDraftFor) return;
323
- if (!turns.some((turn) => turn.id === replaceDraftFor)) {
410
+ if (!queuedTurns.some((turn) => turn.id === replaceDraftFor)) {
324
411
  setReplaceDraftFor(null);
325
412
  }
326
- }, [active, replaceDraftFor, turns]);
413
+ }, [active, queuedTurns, replaceDraftFor]);
327
414
 
328
415
  useEffect(() => {
329
416
  const rail = railRef.current;
@@ -363,7 +450,7 @@ export function SessionChrome({
363
450
  const node = panelBodyRef.current;
364
451
  if (!node) return;
365
452
  setPanelHeight(node.offsetHeight);
366
- }, [open, active, incoming, turns, record, goalState, agentsPanel, agentsSignal]);
453
+ }, [open, active, incoming, queuedTurns, record, goalState, agentsPanel, agentsSignal, steering]);
367
454
 
368
455
  useEffect(() => {
369
456
  if (!open) return;
@@ -385,9 +472,11 @@ export function SessionChrome({
385
472
  const panelBody =
386
473
  active === "incoming" ? (
387
474
  <IncomingPanel inputs={incoming} onDismiss={onDismissIncoming} />
475
+ ) : active === "steering" && steering ? (
476
+ <SteeringPanel phase={steering.phase} text={steering.text} />
388
477
  ) : active === "queue" ? (
389
478
  <QueuePanel
390
- turns={turns}
479
+ turns={queuedTurns}
391
480
  readOnly={!canMutateQueue}
392
481
  mutationFor={queue.mutationFor}
393
482
  replaceDraftFor={replaceDraftFor}
@@ -576,8 +665,9 @@ export function SessionChrome({
576
665
  >
577
666
  <div
578
667
  ref={panelBodyRef}
579
- className="relative border-t border-og-border/50"
668
+ className="relative overflow-y-auto overscroll-contain border-t border-og-border/50"
580
669
  style={{
670
+ maxHeight: "var(--og-session-chrome-panel-max-height)",
581
671
  paddingInline: "var(--og-session-chrome-panel-pad-x)",
582
672
  paddingBlock: "var(--og-session-chrome-panel-pad-y)",
583
673
  }}
@@ -661,6 +751,33 @@ function IncomingPanel({
661
751
  );
662
752
  }
663
753
 
754
+ function SteeringPanel({ phase, text }: { phase: "submitting" | "accepted"; text: string }) {
755
+ return (
756
+ <div
757
+ className="flex items-start gap-2 rounded-og-sm px-1.5 py-1"
758
+ role="status"
759
+ aria-live="polite"
760
+ data-og-session-chrome-panel="steering"
761
+ >
762
+ <span className="mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded-full bg-og-accent-soft text-og-accent">
763
+ {phase === "submitting" ? (
764
+ <Loader2Icon className="size-3 animate-og-spin" aria-hidden="true" />
765
+ ) : (
766
+ <ZapIcon className="size-3" aria-hidden="true" />
767
+ )}
768
+ </span>
769
+ <div className="min-w-0">
770
+ <p className="truncate text-og-xs font-medium leading-4 text-og-fg">{text}</p>
771
+ <p className="mt-0.5 text-[10px] leading-4 text-og-fg-muted">
772
+ {phase === "submitting"
773
+ ? "Sending your latest direction…"
774
+ : "Direction accepted. The agent will continue from it."}
775
+ </p>
776
+ </div>
777
+ </div>
778
+ );
779
+ }
780
+
664
781
  function QueuePanel({
665
782
  turns,
666
783
  readOnly,
@@ -691,6 +808,8 @@ function QueuePanel({
691
808
  data-og-session-chrome-panel="queue"
692
809
  >
693
810
  {turns.map((turn, index) => {
811
+ const presentation = queuedTurnPresentation(turn);
812
+ const voice = presentation.kind !== "prompt";
694
813
  const pending = mutationFor(turn.id);
695
814
  const beforeUp = index > 0 ? (turns[index - 1]?.id ?? null) : null;
696
815
  const beforeDown = index < turns.length - 1 ? (turns[index + 2]?.id ?? null) : null;
@@ -703,11 +822,18 @@ function QueuePanel({
703
822
  className="group flex flex-col gap-1 rounded-og-sm px-1.5 py-1 transition-colors hover:bg-[var(--og-session-chrome-row-hover)]"
704
823
  >
705
824
  <div className="flex items-start gap-1.5">
706
- <span className="mt-px shrink-0 font-og-mono text-[10px] leading-4 text-og-fg-subtle">
707
- {index + 1}
708
- </span>
825
+ {voice ? (
826
+ <AudioLinesIcon
827
+ aria-hidden="true"
828
+ className="mt-0.5 size-3 shrink-0 text-og-accent"
829
+ />
830
+ ) : (
831
+ <span className="mt-px shrink-0 font-og-mono text-[10px] leading-4 text-og-fg-subtle">
832
+ {index + 1}
833
+ </span>
834
+ )}
709
835
  <p className="min-w-0 flex-1 truncate text-og-xs leading-4 text-og-fg">
710
- {turn.prompt}
836
+ {presentation.text}
711
837
  </p>
712
838
  {showActions ? (
713
839
  <div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100 max-sm:opacity-100">
@@ -81,6 +81,13 @@ export const TIP_FOLLOW_VELOCITY_ARM_DEBT_PX = 72;
81
81
  export const TIP_FOLLOW_VELOCITY_DECAY_MS = _t(180);
82
82
  /** Reader-up pixels above clamp budget that count as leaving the tip. */
83
83
  export const TIP_FOLLOW_READER_UP_EPS_PX = 2;
84
+ /**
85
+ * Browsers snap scrollTop writes to device pixels (whole px at dpr 1). A DOM
86
+ * position within this window of the camera's own fractional position is that
87
+ * quantization echo, not external motion — keep the fraction so sub-pixel
88
+ * settle steps accumulate. Anything larger (clamp, user, glue) re-bases.
89
+ */
90
+ export const TIP_FOLLOW_QUANTIZE_WINDOW_PX = 1;
84
91
 
85
92
  /** Test-only override for scrollend feature detection (`null` = probe DOM). */
86
93
  let scrollEndSupportOverride: boolean | null = null;
@@ -122,11 +129,20 @@ export type TipFollowState = {
122
129
  hotUntil: number;
123
130
  lastTs: number;
124
131
  lastHeight: number;
132
+ /** Last observed scroller clientHeight — viewport shrink heats like tip growth. */
133
+ lastClientHeight: number;
125
134
  lastGrowthAt: number;
126
135
  /** EMA of recent height growth (px/s); nudges τ / ceiling under bursts. */
127
136
  growthVelocity: number;
128
137
  /** Camera velocity toward tip (px/s). Smoothed — never jumps with debt. */
129
138
  scrollVelocity: number;
139
+ /**
140
+ * Fractional camera position. The DOM quantizes scrollTop to device pixels,
141
+ * so settle steps under 1px would be discarded every frame if the camera
142
+ * re-based on the DOM — it then parks 20-50px short of the tip forever.
143
+ * `null` = no fraction in flight; adopt the DOM position.
144
+ */
145
+ cameraTop: number | null;
130
146
  };
131
147
 
132
148
  export type TipFollowStepInput = {
@@ -150,9 +166,11 @@ export function createTipFollowState(): TipFollowState {
150
166
  hotUntil: 0,
151
167
  lastTs: 0,
152
168
  lastHeight: 0,
169
+ lastClientHeight: 0,
153
170
  lastGrowthAt: 0,
154
171
  growthVelocity: 0,
155
172
  scrollVelocity: 0,
173
+ cameraTop: null,
156
174
  };
157
175
  }
158
176
 
@@ -162,6 +180,7 @@ export function tipFollowCancel(state: TipFollowState): TipFollowState {
162
180
  running: false,
163
181
  lastTs: 0,
164
182
  scrollVelocity: 0,
183
+ cameraTop: null,
165
184
  };
166
185
  }
167
186
 
@@ -196,6 +215,49 @@ export function tipFollowNoteGrowth(
196
215
  };
197
216
  }
198
217
 
218
+ /**
219
+ * Record scroller viewport height. A shrink (composer / SessionChrome /
220
+ * window) raises tip debt (`scrollHeight - clientHeight`) without growing
221
+ * content — the same debt content-growth would create. Heat the camera so
222
+ * catch-up does not fall into cold soft-settle (~42px/s) and leave the tip
223
+ * parked under the chrome.
224
+ */
225
+ export function tipFollowNoteViewportShrink(
226
+ state: TipFollowState,
227
+ clientHeight: number,
228
+ now: number,
229
+ ): TipFollowState {
230
+ const previous = state.lastClientHeight;
231
+ if (previous <= 0) {
232
+ return { ...state, lastClientHeight: clientHeight };
233
+ }
234
+ const shrink = previous - clientHeight;
235
+ if (shrink <= 0) {
236
+ return { ...state, lastClientHeight: clientHeight };
237
+ }
238
+ if (shrink <= TIP_FOLLOW_SHRINK_EPS_PX) {
239
+ // Sub-eps shrink: HOLD the baseline (deadband with memory). An animated
240
+ // chrome/composer ease-out delivers many ≤eps frames; adopting each one
241
+ // leaked the whole tail into cold debt with no glue and no heat.
242
+ return state;
243
+ }
244
+ const dt = state.lastGrowthAt > 0 ? now - state.lastGrowthAt : 0;
245
+ let growthVelocity = state.growthVelocity;
246
+ if (dt > 0) {
247
+ const instant = (shrink / dt) * 1000;
248
+ growthVelocity = growthVelocity * 0.65 + instant * 0.35;
249
+ } else {
250
+ growthVelocity = Math.max(growthVelocity, shrink * (1000 / TIP_FOLLOW_HOT_IDLE_MS));
251
+ }
252
+ return {
253
+ ...state,
254
+ lastClientHeight: clientHeight,
255
+ lastGrowthAt: now,
256
+ growthVelocity,
257
+ hotUntil: now + TIP_FOLLOW_HOT_IDLE_MS,
258
+ };
259
+ }
260
+
199
261
  /**
200
262
  * Adaptive τ: small debt → calm; large debt / armed velocity → catch-up.
201
263
  * While hot, floor urgency so live streams are not stuck on calm τ.
@@ -376,6 +438,29 @@ export function tipFollowCompensateShrink(
376
438
  return Math.max(0, Math.min(maxScroll, scrollTop - delta));
377
439
  }
378
440
 
441
+ /**
442
+ * Viewport shrank (SessionChrome / composer / window). maxScroll rises by ≈Δc;
443
+ * keep the same tip distance: scrollTop += Δc. Without this, pinned follow only
444
+ * sees new tip debt and soft-settles under the chrome.
445
+ */
446
+ export function tipFollowCompensateViewportShrink(
447
+ scrollTop: number,
448
+ previousClientHeight: number,
449
+ nextClientHeight: number,
450
+ scrollHeight: number,
451
+ epsPx: number = TIP_FOLLOW_SHRINK_EPS_PX,
452
+ ): number {
453
+ if (previousClientHeight <= 0) {
454
+ return scrollTop;
455
+ }
456
+ const shrink = previousClientHeight - nextClientHeight;
457
+ if (shrink <= epsPx) {
458
+ return scrollTop;
459
+ }
460
+ const maxScroll = Math.max(0, scrollHeight - nextClientHeight);
461
+ return Math.max(0, Math.min(maxScroll, scrollTop + shrink));
462
+ }
463
+
379
464
  /**
380
465
  * One camera step. Shell schedules the next rAF while `result.state.running`.
381
466
  */
@@ -385,12 +470,39 @@ export function tipFollowStep(
385
470
  ): TipFollowStepResult {
386
471
  const { scrollTop, scrollHeight, clientHeight, now, pinned, reducedMotion, revealed } = input;
387
472
  const previousHeight = state.lastHeight;
473
+ const previousClient = state.lastClientHeight;
388
474
  const frameGrowth =
389
475
  previousHeight > 0 && scrollHeight > previousHeight ? scrollHeight - previousHeight : 0;
390
- const grew = frameGrowth > 0;
476
+ const frameViewportShrink =
477
+ previousClient > 0 && clientHeight < previousClient - TIP_FOLLOW_SHRINK_EPS_PX
478
+ ? previousClient - clientHeight
479
+ : 0;
480
+ const grew = frameGrowth > 0 || frameViewportShrink > 0;
391
481
  let noted = tipFollowNoteGrowth(state, scrollHeight, now);
482
+ noted = tipFollowNoteViewportShrink(noted, clientHeight, now);
483
+ // Sub-device-pixel writes are floored by the engine: a DOM position within
484
+ // the quantization window of our own fractional camera is the echo of our
485
+ // last write, not external motion — keep the fraction so settle steps
486
+ // accumulate. Larger disagreement (clamp / reader / snap) re-bases.
487
+ let cameraTop = scrollTop;
488
+ if (
489
+ state.cameraTop !== null &&
490
+ Math.abs(state.cameraTop - scrollTop) <= TIP_FOLLOW_QUANTIZE_WINDOW_PX
491
+ ) {
492
+ cameraTop = state.cameraTop;
493
+ }
494
+ // Chrome/composer dock: tip-glue before ease so debt from ΔclientHeight is
495
+ // not left for cold soft-settle (content still under the new bar).
496
+ if (pinned && frameViewportShrink > 0) {
497
+ cameraTop = tipFollowCompensateViewportShrink(
498
+ cameraTop,
499
+ previousClient,
500
+ clientHeight,
501
+ scrollHeight,
502
+ );
503
+ }
392
504
  const target = targetScrollTop(scrollHeight, clientHeight);
393
- const debt = target - scrollTop;
505
+ const debt = target - cameraTop;
394
506
  const hot = now < noted.hotUntil;
395
507
  const settling = !hot && Math.abs(debt) < TIP_FOLLOW_CATCHUP_DEBT_PX;
396
508
 
@@ -426,7 +538,7 @@ export function tipFollowStep(
426
538
 
427
539
  if (Math.abs(debt) <= 0.75) {
428
540
  return {
429
- scrollTop: Math.abs(debt) > 0 ? target : scrollTop,
541
+ scrollTop: Math.abs(debt) > 0 ? target : cameraTop,
430
542
  state: {
431
543
  ...tipFollowCancel(noted),
432
544
  growthVelocity: hot ? noted.growthVelocity : 0,
@@ -445,13 +557,19 @@ export function tipFollowStep(
445
557
  let scrollVelocity =
446
558
  (noted.scrollVelocity ?? 0) + (desiredVel - (noted.scrollVelocity ?? 0)) * velAlpha;
447
559
 
448
- const maxStep = tipFollowMaxStepPx(debt, noted.growthVelocity, dt, frameGrowth, hot);
560
+ const maxStep = tipFollowMaxStepPx(
561
+ debt,
562
+ noted.growthVelocity,
563
+ dt,
564
+ frameGrowth + frameViewportShrink,
565
+ hot,
566
+ );
449
567
  const maxVel = maxStep / dtSec;
450
568
  if (Math.abs(scrollVelocity) > maxVel) {
451
569
  scrollVelocity = Math.sign(scrollVelocity) * maxVel;
452
570
  }
453
571
 
454
- let next = scrollTop + scrollVelocity * dtSec;
572
+ let next = cameraTop + scrollVelocity * dtSec;
455
573
  // Never overshoot the tip — stop and kill velocity on arrival.
456
574
  if (debt > 0 && next >= target - 0.35) {
457
575
  next = target;
@@ -462,13 +580,16 @@ export function tipFollowStep(
462
580
  }
463
581
  next = Math.max(0, next);
464
582
 
583
+ const running = Math.abs(target - next) > 0.35 || Math.abs(scrollVelocity) > 1;
465
584
  return {
466
585
  scrollTop: next,
467
586
  state: {
468
587
  ...noted,
469
588
  scrollVelocity,
470
- running: Math.abs(target - next) > 0.35 || Math.abs(scrollVelocity) > 1,
589
+ running,
471
590
  lastTs: now,
591
+ // Carry the fraction the DOM will floor away; drop it once landed.
592
+ cameraTop: running ? next : null,
472
593
  },
473
594
  };
474
595
  }
package/src/composer.ts CHANGED
@@ -4,6 +4,19 @@
4
4
  * `import * as Composer from "@opengeni/react/composer"`.
5
5
  */
6
6
  export { OPEN_WORKSTREAM_CONTROL_EVENT } from "./workstream-control-event";
7
+ export {
8
+ BillingClassMark,
9
+ ModelPolicyPicker,
10
+ ModelPolicyPickerMenu,
11
+ PickerAnimatedPage,
12
+ PickerBackHeader,
13
+ PickerNavRow,
14
+ defaultModelPolicyPickerMessages,
15
+ } from "./components/model-policy-picker";
16
+ export type {
17
+ ModelPolicyPickerMessages,
18
+ ModelPolicyPickerProps,
19
+ } from "./components/model-policy-picker";
7
20
  export {
8
21
  Actions,
9
22
  AttachButton,