@opengeni/react 7.4.0 → 7.5.0-canary.36927100590001

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 (210) hide show
  1. package/README.md +124 -27
  2. package/dist/artifacts-document.js +3 -2
  3. package/dist/artifacts-presentation.js +3 -2
  4. package/dist/artifacts-spreadsheet.js +2 -2
  5. package/dist/artifacts.d.ts +1 -0
  6. package/dist/artifacts.js +516 -1413
  7. package/dist/artifacts.js.map +1 -1
  8. package/dist/{browser-viewer-2E6R2Y4W.js → browser-viewer-Z7S7QEJL.js} +6 -5
  9. package/dist/chat-interactive-block-UGBTNB3L.js +11 -0
  10. package/dist/{chunk-Y3QTZN2F.js → chunk-2VFNCX4E.js} +6 -6
  11. package/dist/{chunk-UDD6DLLT.js → chunk-3IJJEYW4.js} +15 -3
  12. package/dist/chunk-3IJJEYW4.js.map +1 -0
  13. package/dist/chunk-4RVYOY4O.js +379 -0
  14. package/dist/chunk-4RVYOY4O.js.map +1 -0
  15. package/dist/chunk-5CVXZA3Y.js +292 -0
  16. package/dist/chunk-5CVXZA3Y.js.map +1 -0
  17. package/dist/{chunk-5YK2ZDIG.js → chunk-5J4FOW6Q.js} +2 -10
  18. package/dist/chunk-5J4FOW6Q.js.map +1 -0
  19. package/dist/{chunk-KVERXCFY.js → chunk-5KYZIQFY.js} +40 -3
  20. package/dist/chunk-5KYZIQFY.js.map +1 -0
  21. package/dist/{chunk-KNDIXZN6.js → chunk-7UAMYXJY.js} +25 -18
  22. package/dist/{chunk-KNDIXZN6.js.map → chunk-7UAMYXJY.js.map} +1 -1
  23. package/dist/{chunk-SEEVCT36.js → chunk-A4UJB2MM.js} +2 -2
  24. package/dist/{chunk-X4HIN6TV.js → chunk-AFLPWUQ7.js} +71 -162
  25. package/dist/chunk-AFLPWUQ7.js.map +1 -0
  26. package/dist/chunk-B3V6TG77.js +41 -0
  27. package/dist/chunk-B3V6TG77.js.map +1 -0
  28. package/dist/{chunk-XEJEOAOV.js → chunk-B7WMS3CS.js} +51 -40
  29. package/dist/chunk-B7WMS3CS.js.map +1 -0
  30. package/dist/{chunk-S5JQ4R22.js → chunk-BDKR6BAR.js} +54 -24
  31. package/dist/chunk-BDKR6BAR.js.map +1 -0
  32. package/dist/{chunk-4MDLEHXL.js → chunk-DYWBUX5Q.js} +24 -12
  33. package/dist/chunk-DYWBUX5Q.js.map +1 -0
  34. package/dist/{chunk-YZ3YYMOM.js → chunk-E3G7SAYZ.js} +252 -624
  35. package/dist/chunk-E3G7SAYZ.js.map +1 -0
  36. package/dist/chunk-JT4JW7BC.js +54 -0
  37. package/dist/chunk-JT4JW7BC.js.map +1 -0
  38. package/dist/{chunk-5AZSK6MV.js → chunk-LWNLETQT.js} +5 -5
  39. package/dist/{chunk-USXVQACU.js → chunk-M7LBYP63.js} +5 -4
  40. package/dist/{chunk-USXVQACU.js.map → chunk-M7LBYP63.js.map} +1 -1
  41. package/dist/{chunk-L4OSXQ6H.js → chunk-NBOHNRLJ.js} +1289 -1060
  42. package/dist/chunk-NBOHNRLJ.js.map +1 -0
  43. package/dist/chunk-RHREZ7BO.js +11 -0
  44. package/dist/chunk-RHREZ7BO.js.map +1 -0
  45. package/dist/chunk-SCOTPE2I.js +589 -0
  46. package/dist/chunk-SCOTPE2I.js.map +1 -0
  47. package/dist/chunk-WEHMPOXK.js +39 -0
  48. package/dist/chunk-WEHMPOXK.js.map +1 -0
  49. package/dist/chunk-WRHIZYZF.js +988 -0
  50. package/dist/chunk-WRHIZYZF.js.map +1 -0
  51. package/dist/{chunk-3E2AITTZ.js → chunk-WUISZ3WU.js} +32 -28
  52. package/dist/{chunk-3E2AITTZ.js.map → chunk-WUISZ3WU.js.map} +1 -1
  53. package/dist/chunk-XCHWDJ22.js +107 -0
  54. package/dist/chunk-XCHWDJ22.js.map +1 -0
  55. package/dist/{chunk-DUEBTL3N.js → chunk-Y2PRMYLC.js} +4 -3
  56. package/dist/{chunk-DUEBTL3N.js.map → chunk-Y2PRMYLC.js.map} +1 -1
  57. package/dist/chunk-YBXQDQFX.js +1772 -0
  58. package/dist/chunk-YBXQDQFX.js.map +1 -0
  59. package/dist/chunk-YV4L3ILF.js +9 -0
  60. package/dist/chunk-YV4L3ILF.js.map +1 -0
  61. package/dist/{chunk-V2ABIVYJ.js → chunk-ZXSEDZP7.js} +70 -6
  62. package/dist/chunk-ZXSEDZP7.js.map +1 -0
  63. package/dist/components/artifacts/artifact-chrome.d.ts +120 -0
  64. package/dist/components/artifacts/artifact-sandbox.d.ts +27 -0
  65. package/dist/components/artifacts/chat-interactive-block.d.ts +29 -0
  66. package/dist/components/artifacts/deferred-chat-media.d.ts +15 -0
  67. package/dist/components/artifacts/editable-artifact-ui.d.ts +2 -1
  68. package/dist/components/artifacts/editable-artifact-view.d.ts +46 -0
  69. package/dist/components/artifacts/index.d.ts +7 -0
  70. package/dist/components/artifacts/latest-task-runner.d.ts +15 -0
  71. package/dist/components/artifacts/session-artifact-viewer.d.ts +39 -0
  72. package/dist/components/artifacts/site-view.d.ts +23 -0
  73. package/dist/components/browser-select-control.d.ts +3 -1
  74. package/dist/components/code-editor.d.ts +1 -4
  75. package/dist/components/desktop-viewer.d.ts +1 -1
  76. package/dist/components/message-timeline.d.ts +20 -3
  77. package/dist/components/open-geni-links.d.ts +16 -0
  78. package/dist/components/session-conversation.d.ts +20 -1
  79. package/dist/components/timeline-pill-geometry.d.ts +8 -0
  80. package/dist/components/timeline-pill-placement.d.ts +24 -0
  81. package/dist/components/usage-limit-notice.d.ts +49 -0
  82. package/dist/components/usage-member-list.d.ts +91 -0
  83. package/dist/components/usage-meter.d.ts +73 -0
  84. package/dist/composer.js +5 -5
  85. package/dist/{computer-viewer-WMJE6OA3.js → computer-viewer-KJWLH3GI.js} +7 -5
  86. package/dist/connect.js +7 -6
  87. package/dist/connect.js.map +1 -1
  88. package/dist/conversation-timeline.d.ts +1 -1
  89. package/dist/desktop.d.ts +7 -0
  90. package/dist/desktop.js +26 -0
  91. package/dist/desktop.js.map +1 -0
  92. package/dist/editor.d.ts +7 -0
  93. package/dist/editor.js +21 -0
  94. package/dist/editor.js.map +1 -0
  95. package/dist/hooks/latest-question.d.ts +1 -1
  96. package/dist/hooks/use-composer.d.ts +4 -0
  97. package/dist/hooks/use-desktop-stream.d.ts +1 -1
  98. package/dist/hooks/use-usage.d.ts +40 -0
  99. package/dist/index.d.ts +7 -2
  100. package/dist/index.js +460 -1386
  101. package/dist/index.js.map +1 -1
  102. package/dist/interaction.js +8 -6
  103. package/dist/interaction.js.map +1 -1
  104. package/dist/{latest-question-QB4EUCW5.js → latest-question-IS445HOW.js} +3 -3
  105. package/dist/lib/format.d.ts +6 -1
  106. package/dist/lib/workbench-peers.d.ts +36 -0
  107. package/dist/machines.js +2 -2
  108. package/dist/{markdown-table-layout-PFUEZ2U4.js → markdown-table-layout-QXSLK4BY.js} +37 -5
  109. package/dist/markdown-table-layout-QXSLK4BY.js.map +1 -0
  110. package/dist/{queue-surface-implementation-IM2HD3SK.js → queue-surface-implementation-2CAQAIHM.js} +2 -2
  111. package/dist/realtime.js +3 -3
  112. package/dist/{sandbox-files-PXPIZAFG.js → sandbox-files-AEJTWGT4.js} +8 -5
  113. package/dist/sandbox-files-AEJTWGT4.js.map +1 -0
  114. package/dist/session-ui.d.ts +9 -2
  115. package/dist/session-ui.js +39 -20
  116. package/dist/session-ui.js.map +1 -1
  117. package/dist/session.js +4 -4
  118. package/dist/sites.js +4 -289
  119. package/dist/sites.js.map +1 -1
  120. package/dist/terminal.d.ts +8 -0
  121. package/dist/terminal.js +31 -0
  122. package/dist/terminal.js.map +1 -0
  123. package/dist/timeline/allowance-exhausted-row.d.ts +24 -0
  124. package/dist/timeline/genie-loading.d.ts +3 -1
  125. package/dist/timeline/types.d.ts +8 -1
  126. package/dist/{timeline-annotation-selection-ODLVF4OI.js → timeline-annotation-selection-DMLMM2TX.js} +6 -1
  127. package/dist/timeline-annotation-selection-DMLMM2TX.js.map +1 -0
  128. package/dist/timeline-pill-geometry-Z23V243M.js +110 -0
  129. package/dist/timeline-pill-geometry-Z23V243M.js.map +1 -0
  130. package/dist/usage/allowance-copy.d.ts +25 -0
  131. package/dist/usage/summary.d.ts +35 -0
  132. package/dist/usage.d.ts +13 -0
  133. package/dist/usage.js +1073 -0
  134. package/dist/usage.js.map +1 -0
  135. package/package.json +19 -3
  136. package/src/artifacts.ts +37 -0
  137. package/src/components/artifacts/artifact-chrome.tsx +413 -0
  138. package/src/components/artifacts/artifact-sandbox.tsx +185 -0
  139. package/src/components/artifacts/chat-interactive-block.tsx +260 -0
  140. package/src/components/artifacts/deferred-chat-media.tsx +67 -0
  141. package/src/components/artifacts/editable-artifact-ui.tsx +3 -1
  142. package/src/components/artifacts/editable-artifact-view.tsx +187 -0
  143. package/src/components/artifacts/editable-artifact-workbench.tsx +5 -2
  144. package/src/components/artifacts/editable-document.tsx +34 -36
  145. package/src/components/artifacts/editable-presentation.tsx +21 -16
  146. package/src/components/artifacts/index.ts +41 -0
  147. package/src/components/artifacts/latest-task-runner.ts +49 -0
  148. package/src/components/artifacts/session-artifact-viewer.tsx +376 -0
  149. package/src/components/artifacts/site-view.tsx +116 -0
  150. package/src/components/browser-select-control.tsx +4 -1
  151. package/src/components/browser-viewer.tsx +81 -22
  152. package/src/components/code-editor.tsx +13 -16
  153. package/src/components/computer-viewer.tsx +51 -23
  154. package/src/components/desktop-viewer.tsx +1 -1
  155. package/src/components/markdown-table-layout.ts +49 -5
  156. package/src/components/markdown.tsx +5 -2
  157. package/src/components/message-timeline.tsx +557 -516
  158. package/src/components/open-geni-links.tsx +24 -0
  159. package/src/components/sandbox-terminal.tsx +48 -11
  160. package/src/components/session-chrome.tsx +22 -3
  161. package/src/components/session-conversation.tsx +195 -102
  162. package/src/components/timeline-annotation-selection.tsx +7 -0
  163. package/src/components/timeline-pill-geometry.ts +169 -0
  164. package/src/components/timeline-pill-placement.ts +99 -0
  165. package/src/components/usage-limit-notice.tsx +179 -0
  166. package/src/components/usage-member-list.tsx +792 -0
  167. package/src/components/usage-meter.tsx +382 -0
  168. package/src/conversation-timeline.ts +10 -2
  169. package/src/desktop.ts +24 -0
  170. package/src/editor.ts +13 -0
  171. package/src/hooks/use-available-models.ts +1 -1
  172. package/src/hooks/use-composer.ts +57 -2
  173. package/src/hooks/use-desktop-stream.ts +19 -28
  174. package/src/hooks/use-usage.ts +100 -0
  175. package/src/index.ts +22 -0
  176. package/src/lib/format.ts +32 -6
  177. package/src/lib/workbench-peers.ts +62 -0
  178. package/src/session-ui.ts +22 -0
  179. package/src/terminal.ts +26 -0
  180. package/src/timeline/activity-rail.tsx +5 -3
  181. package/src/timeline/allowance-exhausted-row.tsx +86 -0
  182. package/src/timeline/genie-loading.tsx +16 -6
  183. package/src/timeline/projection.ts +78 -4
  184. package/src/timeline/types.ts +8 -0
  185. package/src/usage/allowance-copy.ts +64 -0
  186. package/src/usage/summary.ts +93 -0
  187. package/src/usage.ts +50 -0
  188. package/styles/compiled.css +325 -0
  189. package/dist/chunk-4MDLEHXL.js.map +0 -1
  190. package/dist/chunk-5YK2ZDIG.js.map +0 -1
  191. package/dist/chunk-KVERXCFY.js.map +0 -1
  192. package/dist/chunk-L4OSXQ6H.js.map +0 -1
  193. package/dist/chunk-S5JQ4R22.js.map +0 -1
  194. package/dist/chunk-UDD6DLLT.js.map +0 -1
  195. package/dist/chunk-UGMKPUEM.js +0 -299
  196. package/dist/chunk-UGMKPUEM.js.map +0 -1
  197. package/dist/chunk-V2ABIVYJ.js.map +0 -1
  198. package/dist/chunk-X4HIN6TV.js.map +0 -1
  199. package/dist/chunk-XEJEOAOV.js.map +0 -1
  200. package/dist/chunk-YZ3YYMOM.js.map +0 -1
  201. package/dist/markdown-table-layout-PFUEZ2U4.js.map +0 -1
  202. package/dist/timeline-annotation-selection-ODLVF4OI.js.map +0 -1
  203. /package/dist/{browser-viewer-2E6R2Y4W.js.map → browser-viewer-Z7S7QEJL.js.map} +0 -0
  204. /package/dist/{computer-viewer-WMJE6OA3.js.map → chat-interactive-block-UGBTNB3L.js.map} +0 -0
  205. /package/dist/{chunk-Y3QTZN2F.js.map → chunk-2VFNCX4E.js.map} +0 -0
  206. /package/dist/{chunk-SEEVCT36.js.map → chunk-A4UJB2MM.js.map} +0 -0
  207. /package/dist/{chunk-5AZSK6MV.js.map → chunk-LWNLETQT.js.map} +0 -0
  208. /package/dist/{sandbox-files-PXPIZAFG.js.map → computer-viewer-KJWLH3GI.js.map} +0 -0
  209. /package/dist/{latest-question-QB4EUCW5.js.map → latest-question-IS445HOW.js.map} +0 -0
  210. /package/dist/{queue-surface-implementation-IM2HD3SK.js.map → queue-surface-implementation-2CAQAIHM.js.map} +0 -0
@@ -63,7 +63,7 @@ import {
63
63
  type OlderHistoryLoadReceipt,
64
64
  type OlderHistoryLoader,
65
65
  } from "../older-history";
66
- import { Markdown } from "./markdown";
66
+ import { Markdown, type MarkdownProps } from "./markdown";
67
67
  import { OpenGeniLinkProvider, type OpenGeniLinkResolver } from "./open-geni-links";
68
68
  import {
69
69
  TimelineBeforeLayout,
@@ -73,6 +73,7 @@ import {
73
73
  type TimelineAnchor,
74
74
  } from "./timeline-anchor";
75
75
  import { useReadingProgress } from "./timeline-reading-progress";
76
+ import { type TimelinePillPlacement, useTimelinePillPlacement } from "./timeline-pill-placement";
76
77
  import {
77
78
  animateTimelineSettlement,
78
79
  captureTimelineSettlement,
@@ -142,6 +143,12 @@ import {
142
143
  import { SESSION_STATUS_META, StatusDot } from "./session-status";
143
144
  import { TimelineComputeLabelProvider } from "../timeline/compute-label";
144
145
  import { EntranceAnimationProvider, useEntranceAnimation } from "../timeline/entrance";
146
+ import {
147
+ AllowanceExhaustedRow,
148
+ AllowancePresentationProvider,
149
+ type RenderAllowanceExhausted,
150
+ } from "../timeline/allowance-exhausted-row";
151
+ import type { AllowanceLabels } from "../usage/allowance-copy";
145
152
  import { SeenActivityIdsProvider } from "../timeline/seen-activity-ids";
146
153
  import { TimelineAnnotationCards } from "./timeline-annotations";
147
154
  import { TooltipProvider } from "./tooltip";
@@ -170,6 +177,11 @@ export type MessageTimelineProps = {
170
177
  context: { searchTarget: TimelineSearchTarget | null },
171
178
  ) => ReactNode)
172
179
  | undefined;
180
+ /**
181
+ * Render assistant `opengeni-html` / `opengeni-site` fences in the default
182
+ * message renderer (`SessionConversation` supplies the inline Site preview).
183
+ */
184
+ renderInteractiveBlock?: MarkdownProps["renderInteractiveBlock"];
173
185
  /** Drill into a spawned worker session. */
174
186
  onOpenSession?: ((sessionId: string) => void) | undefined;
175
187
  /**
@@ -191,6 +203,15 @@ export type MessageTimelineProps = {
191
203
  onReconnect?: ((item: AuthNeededItem) => void | Promise<void>) | undefined;
192
204
  /** Host-owned inline connection setup. Return undefined to use the default recovery card. */
193
205
  renderAuthNeeded?: ((item: AuthNeededItem) => ReactNode | undefined) | undefined;
206
+ /**
207
+ * Replace the "usage limit reached" row shown when a workspace or member
208
+ * usage allowance stops work (an `allowance_exhausted` refusal). Receives the
209
+ * typed refusal (scope, reset time) and the default row; return undefined to
210
+ * keep the default. Use it to point people at your own plan or admin page.
211
+ */
212
+ renderAllowanceExhausted?: RenderAllowanceExhausted | undefined;
213
+ /** Replace the words of the default "usage limit reached" row. */
214
+ allowanceExhaustedLabels?: Partial<AllowanceLabels> | undefined;
194
215
  /**
195
216
  * Decide which durable authentication notices this timeline presents.
196
217
  * Defaults to showing every notice. Embedded hosts can suppress notices for
@@ -268,8 +289,8 @@ export type MessageTimelineProps = {
268
289
  * scrolls the in-memory window.
269
290
  */
270
291
  onJumpToLatest?: (() => void | Promise<void>) | undefined;
271
- /** Resolve the newest durable user message and load its bounded window.
272
- * Wire useSessionEvents().jumpToLatestQuestion for unloaded history. */
292
+ /** @deprecated Retained for source compatibility; contextual prompt navigation
293
+ * uses only mounted prompts and never invokes this global resolver. */
273
294
  onJumpToLatestQuestion?: (() => Promise<number | null>) | undefined;
274
295
  /** Host-owned content appended after timeline groups, such as startup progress. */
275
296
  trailingState?: ReactNode | undefined;
@@ -311,6 +332,20 @@ const PIN_THRESHOLD_PX = 48;
311
332
  const JUMP_TO_LATEST_CATCHUP_DEBT_PX = 240;
312
333
  /** Breathing room above the question when following stops at an answer. */
313
334
  const QUESTION_NAV_MARGIN_PX = 12;
335
+ // Floating pills rest at these spots and slide sideways, in this order, when
336
+ // their resting spot would cover a control (see useTimelinePillPlacement).
337
+ const JUMP_PILL_PLACEMENTS: readonly TimelinePillPlacement[] = ["center", "end", "start"];
338
+ const QUESTION_PILL_PLACEMENTS: readonly TimelinePillPlacement[] = ["end", "center", "start"];
339
+ const JUMP_PILL_PLACEMENT_CLASS: Record<TimelinePillPlacement, string> = {
340
+ center: "inset-x-0 mx-auto",
341
+ end: "right-4 sm:right-6",
342
+ start: "left-4 sm:left-6",
343
+ };
344
+ const QUESTION_PILL_PLACEMENT_CLASS: Record<TimelinePillPlacement, string> = {
345
+ center: "justify-center",
346
+ end: "justify-end",
347
+ start: "justify-start",
348
+ };
314
349
  /**
315
350
  * Prefetch older history when the top sentinel is this far from the viewport.
316
351
  * After a page loads we stay cool until the reader leaves this band (scrolls
@@ -444,7 +479,7 @@ function contentTopOf(node: HTMLElement, groupKey: string): number | null {
444
479
  }
445
480
 
446
481
  /** The question the reader is inside of, once its start has scrolled out of view. */
447
- type QuestionNav = { key: string | null };
482
+ type QuestionNav = { key: string };
448
483
 
449
484
  /** How far a question's start must be above the viewport before navigation shows. */
450
485
  const QUESTION_NAV_HIDDEN_PX = 24;
@@ -452,11 +487,16 @@ const QUESTION_NAV_HIDDEN_PX = 24;
452
487
  function readQuestionNav(node: HTMLElement): QuestionNav | null {
453
488
  const view = node.getBoundingClientRect();
454
489
  const prompts = [...node.querySelectorAll<HTMLElement>("[data-og-prompt]")];
455
- const prompt = prompts.at(-1);
490
+ // The last prompt before the reading position owns the response/work in view.
491
+ // A newer prompt farther down the conversation is not a navigation target.
492
+ const middle = view.top + view.height / 2;
493
+ const prompt = prompts
494
+ .reverse()
495
+ .find((candidate) => candidate.getBoundingClientRect().top <= middle);
456
496
  const key = prompt?.dataset.ogGroupKey;
457
497
  if (!prompt || !key) return null;
458
- const top = prompt.getBoundingClientRect().top;
459
- if (top >= view.top - QUESTION_NAV_HIDDEN_PX && top < view.bottom) {
498
+ const bounds = prompt.getBoundingClientRect();
499
+ if (bounds.top >= view.top - QUESTION_NAV_HIDDEN_PX || bounds.bottom > view.top) {
460
500
  return null;
461
501
  }
462
502
  return { key };
@@ -482,6 +522,7 @@ function cssEscapeAttribute(value: string): string {
482
522
  */
483
523
  export function MessageTimeline({
484
524
  resolveLink,
525
+ renderInteractiveBlock,
485
526
  userMessageDisclosureLabels,
486
527
  searchTarget,
487
528
  events,
@@ -493,6 +534,8 @@ export function MessageTimeline({
493
534
  onMemoryClick,
494
535
  onReconnect,
495
536
  renderAuthNeeded,
537
+ renderAllowanceExhausted,
538
+ allowanceExhaustedLabels,
496
539
  shouldRenderAuthNeeded,
497
540
  resolveProviderLogo,
498
541
  toolRegistry = defaultToolRegistry,
@@ -515,7 +558,6 @@ export function MessageTimeline({
515
558
  loadingNewer = false,
516
559
  onLoadNewer,
517
560
  onJumpToLatest,
518
- onJumpToLatestQuestion,
519
561
  trailingState,
520
562
  emptyState,
521
563
  className,
@@ -555,6 +597,9 @@ export function MessageTimeline({
555
597
  <Markdown
556
598
  searchTarget={searchItem?.id === item.id ? searchTarget : null}
557
599
  streaming={item.kind === "agent-message" && item.streaming}
600
+ renderInteractiveBlock={
601
+ item.kind === "agent-message" ? renderInteractiveBlock : undefined
602
+ }
558
603
  >
559
604
  {text}
560
605
  </Markdown>
@@ -567,7 +612,7 @@ export function MessageTimeline({
567
612
  body
568
613
  );
569
614
  },
570
- [renderMessageText, searchItem?.id, searchTarget],
615
+ [renderInteractiveBlock, renderMessageText, searchItem?.id, searchTarget],
571
616
  );
572
617
  // Event-window identity is independent of projected rows (partial messages
573
618
  // can acquire a different first-delta id when older text arrives).
@@ -700,10 +745,22 @@ export function MessageTimeline({
700
745
  const resizeFollowRafRef = useRef<number | null>(null);
701
746
  const questionNavFrameRef = useRef<number | null>(null);
702
747
  const [questionNav, setQuestionNav] = useState<QuestionNav | null>(null);
703
- const [questionTarget, setQuestionTarget] = useState<number | null>(null);
704
- const [questionPending, setQuestionPending] = useState(false);
705
- const [questionError, setQuestionError] = useState<string | null>(null);
706
- const questionRequestRef = useRef(0);
748
+ const jumpPillRef = useRef<HTMLButtonElement | null>(null);
749
+ const questionPillRef = useRef<HTMLDivElement | null>(null);
750
+ const jumpPillShown = autoFollow && (!pinned || hasNewer || canSkipTipCatchup);
751
+ const jumpPillPlacement = useTimelinePillPlacement({
752
+ pillRef: jumpPillRef,
753
+ scrollerRef: scrollRef,
754
+ active: jumpPillShown,
755
+ preference: JUMP_PILL_PLACEMENTS,
756
+ });
757
+ const questionPillPlacement = useTimelinePillPlacement({
758
+ pillRef: questionPillRef,
759
+ scrollerRef: scrollRef,
760
+ active: questionNav !== null,
761
+ preference: QUESTION_PILL_PLACEMENTS,
762
+ allowDownwardFallback: true,
763
+ });
707
764
  const firstGroupKey = allGroups[0] ? timelineGroupKey(allGroups[0]) : null;
708
765
  // Content stays invisible until the tip is hard-parked across a short
709
766
  // post-commit settle (two rAFs). That absorbs sync late layout while hidden
@@ -1270,19 +1327,10 @@ export function MessageTimeline({
1270
1327
  questionNavFrameRef.current = requestFrame(() => {
1271
1328
  questionNavFrameRef.current = null;
1272
1329
  const node = scrollRef.current;
1273
- const next = hasNewer
1274
- ? onJumpToLatestQuestion
1275
- ? { key: null }
1276
- : null
1277
- : node
1278
- ? (readQuestionNav(node) ??
1279
- (onJumpToLatestQuestion && !node.querySelector("[data-og-prompt]")
1280
- ? { key: null }
1281
- : null))
1282
- : null;
1330
+ const next = node ? readQuestionNav(node) : null;
1283
1331
  setQuestionNav((current) => (sameQuestionNav(current, next) ? current : next));
1284
1332
  });
1285
- }, [readableTurns, hasNewer, onJumpToLatestQuestion]);
1333
+ }, [readableTurns]);
1286
1334
  useEffect(
1287
1335
  () => () => {
1288
1336
  if (questionNavFrameRef.current != null) {
@@ -1312,30 +1360,6 @@ export function MessageTimeline({
1312
1360
  syncScrollBaseline(node);
1313
1361
  scheduleQuestionNav();
1314
1362
  };
1315
- const jumpToLatestQuestion = async () => {
1316
- if (!onJumpToLatestQuestion) {
1317
- if (!hasNewer && questionNav?.key) jumpToQuestion(questionNav.key);
1318
- return;
1319
- }
1320
- const request = ++questionRequestRef.current;
1321
- releasePinFromReader();
1322
- wantPinRef.current = false;
1323
- setQuestionPending(true);
1324
- setQuestionError(null);
1325
- try {
1326
- const sequence = await onJumpToLatestQuestion();
1327
- if (request === questionRequestRef.current) setQuestionTarget(sequence);
1328
- } catch (reason) {
1329
- if (request === questionRequestRef.current)
1330
- setQuestionError(
1331
- reason instanceof Error && reason.name === "LatestQuestionQueuedError"
1332
- ? "The latest question is in the prompt queue."
1333
- : "Could not load the latest question. Try again.",
1334
- );
1335
- } finally {
1336
- if (request === questionRequestRef.current) setQuestionPending(false);
1337
- }
1338
- };
1339
1363
 
1340
1364
  const driveFollowRef = useRef<(node: HTMLElement, now?: number) => void>(
1341
1365
  requestOlderIfUnderfilled as (node: HTMLElement, now?: number) => void,
@@ -1554,21 +1578,7 @@ export function MessageTimeline({
1554
1578
  }
1555
1579
  }
1556
1580
  };
1557
- const questionGroup =
1558
- questionTarget === null
1559
- ? undefined
1560
- : groups.find(
1561
- ({ group }) =>
1562
- group.kind === "item" &&
1563
- group.item.kind === "user-message" &&
1564
- (group.item.sourceEvents?.some((source) => source.sequence === questionTarget) ||
1565
- group.item.annotationSource?.sequence === questionTarget),
1566
- );
1567
- if (questionGroup) {
1568
- // Exact navigation wins over prepend correction on the same commit.
1569
- jumpToQuestion(questionGroup.key);
1570
- setQuestionTarget(null);
1571
- } else if (pendingJumpToStartRef.current && firstItemChanged) {
1581
+ if (pendingJumpToStartRef.current && firstItemChanged) {
1572
1582
  // The oldest window landed — jump against the NEW DOM, and skip the
1573
1583
  // prepend correction (it would shift the reader away from the top).
1574
1584
  pendingJumpToStartRef.current = false;
@@ -2187,460 +2197,477 @@ export function MessageTimeline({
2187
2197
  };
2188
2198
 
2189
2199
  const timeline = (
2190
- <LightboxProvider>
2191
- <FoldMemoryProvider value={foldMemoryRef.current}>
2192
- <SeenActivityIdsProvider value={seenActivityIdsRef.current}>
2193
- <TimelineComputeLabelProvider value={computeLabel ?? null}>
2194
- <EntranceAnimationProvider value={false}>
2195
- <TooltipProvider delayDuration={400}>
2196
- <TimelineAnnotationSourceRootContext.Provider value={scrollRef}>
2197
- <div
2198
- className={cn("og-root relative flex min-h-0 flex-col", className)}
2199
- // Sticky insets start at the scroller's padded content edge
2200
- // (pt-16). Subtract exactly that padding so an expanded work
2201
- // header pins flush to the scrollport. Pinning it lower left a
2202
- // band of scrolling rows visible above the header, which then
2203
- // looked like it floated over the middle of the timeline. The
2204
- // floating question action sits below this strip instead.
2205
- style={{ "--og-work-header-top": "-4rem" } as CSSProperties}
2206
- >
2207
- {onAnnotate ? (
2208
- <Suspense fallback={null}>
2209
- <TimelineAnnotationSelection
2210
- rootRef={scrollRef}
2211
- sources={annotationSources}
2212
- onAnnotate={onAnnotate}
2213
- />
2214
- </Suspense>
2215
- ) : null}
2216
- {/* Pinned: anchoring off so the tip-follow camera owns the motion.
2217
- Unpinned: native scroll anchoring holds the reader's place. */}
2200
+ <AllowancePresentationProvider
2201
+ render={renderAllowanceExhausted}
2202
+ labels={allowanceExhaustedLabels}
2203
+ >
2204
+ <LightboxProvider>
2205
+ <FoldMemoryProvider value={foldMemoryRef.current}>
2206
+ <SeenActivityIdsProvider value={seenActivityIdsRef.current}>
2207
+ <TimelineComputeLabelProvider value={computeLabel ?? null}>
2208
+ <EntranceAnimationProvider value={false}>
2209
+ <TooltipProvider delayDuration={400}>
2210
+ <TimelineAnnotationSourceRootContext.Provider value={scrollRef}>
2218
2211
  <div
2219
- ref={scrollRef}
2220
- data-og-timeline-scroller=""
2221
- data-og-bottom-follow={autoFollow && pinned && !hasNewer ? "true" : "false"}
2222
- tabIndex={-1}
2223
- onScroll={onScroll}
2224
- onScrollEnd={onScrollEnd}
2225
- onWheel={onWheel}
2226
- onTouchStart={(event) => {
2227
- stopSettlement();
2228
- const touch = event.touches.length === 1 ? event.touches[0] : undefined;
2229
- touchPositionRef.current = touch
2230
- ? { x: touch.clientX, y: touch.clientY }
2231
- : null;
2232
- }}
2233
- onTouchMove={(event) => {
2234
- const touch = event.touches[0];
2235
- const previous = touchPositionRef.current;
2236
- if (!touch || !previous || event.touches.length !== 1) {
2237
- touchPositionRef.current = null;
2238
- return;
2239
- }
2240
- const deltaX = previous.x - touch.clientX;
2241
- const deltaY = previous.y - touch.clientY;
2242
- if (Math.max(Math.abs(deltaX), Math.abs(deltaY)) < 4) return;
2243
- touchPositionRef.current = { x: touch.clientX, y: touch.clientY };
2244
- onWheel({
2245
- deltaX,
2246
- deltaY,
2247
- target: event.target,
2248
- currentTarget: event.currentTarget,
2249
- });
2250
- }}
2251
- onTouchEnd={() => {
2252
- touchPositionRef.current = null;
2253
- }}
2254
- onTouchCancel={() => {
2255
- touchPositionRef.current = null;
2256
- }}
2257
- onClickCapture={(event) => {
2258
- const target =
2259
- event.target instanceof Element
2260
- ? event.target.closest("button[aria-expanded]")
2212
+ className={cn("og-root relative flex min-h-0 flex-col", className)}
2213
+ // Sticky insets start at the scroller's padded content edge
2214
+ // (pt-16). Subtract exactly that padding so an expanded work
2215
+ // header pins flush to the scrollport. Pinning it lower left a
2216
+ // band of scrolling rows visible above the header, which then
2217
+ // looked like it floated over the middle of the timeline. The
2218
+ // floating question action sits below this strip instead.
2219
+ style={{ "--og-work-header-top": "-4rem" } as CSSProperties}
2220
+ >
2221
+ {onAnnotate ? (
2222
+ <Suspense fallback={null}>
2223
+ <TimelineAnnotationSelection
2224
+ rootRef={scrollRef}
2225
+ sources={annotationSources}
2226
+ onAnnotate={onAnnotate}
2227
+ />
2228
+ </Suspense>
2229
+ ) : null}
2230
+ {/* Pinned: anchoring off so the tip-follow camera owns the motion.
2231
+ Unpinned: native scroll anchoring holds the reader's place. */}
2232
+ <div
2233
+ ref={scrollRef}
2234
+ data-og-timeline-scroller=""
2235
+ data-og-bottom-follow={autoFollow && pinned && !hasNewer ? "true" : "false"}
2236
+ tabIndex={-1}
2237
+ onScroll={onScroll}
2238
+ onScrollEnd={onScrollEnd}
2239
+ onWheel={onWheel}
2240
+ onTouchStart={(event) => {
2241
+ stopSettlement();
2242
+ const touch = event.touches.length === 1 ? event.touches[0] : undefined;
2243
+ touchPositionRef.current = touch
2244
+ ? { x: touch.clientX, y: touch.clientY }
2261
2245
  : null;
2262
- if (target) {
2263
- if (target.hasAttribute("data-og-work-header")) {
2264
- releaseProgress(
2265
- target
2266
- .closest("[data-og-group-key]")
2267
- ?.getAttribute("data-og-group-key") ?? null,
2268
- );
2246
+ }}
2247
+ onTouchMove={(event) => {
2248
+ const touch = event.touches[0];
2249
+ const previous = touchPositionRef.current;
2250
+ if (!touch || !previous || event.touches.length !== 1) {
2251
+ touchPositionRef.current = null;
2252
+ return;
2269
2253
  }
2270
- releasePinFromReader();
2271
- disclosureKeepsUnpinnedRef.current = true;
2272
- }
2273
- }}
2274
- onFocusCapture={(event) => {
2275
- if (readableTurns && event.target !== event.currentTarget) {
2276
- stopSettlement();
2277
- releasePinFromReader();
2278
- disclosureKeepsUnpinnedRef.current = true;
2279
- }
2280
- }}
2281
- onPointerDown={onPointerDown}
2282
- onKeyDown={onKeyDown}
2283
- style={groups.length > 0 && !revealed ? { visibility: "hidden" } : undefined}
2284
- className={cn(
2285
- // tabIndex=-1 is programmatic only — never paint a focus ring on
2286
- // the whole scroller (click + Shift used to flash a blue outline).
2287
- "min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pt-16 pb-6 sm:px-6 outline-hidden",
2288
- autoFollow && pinned && !hasNewer
2289
- ? "[overflow-anchor:none]"
2290
- : "[overflow-anchor:auto]",
2291
- )}
2292
- >
2293
- <TimelineBeforeLayout
2294
- capture={() => {
2295
- if (readableTurns && timelineHasReader(scrollRef.current)) {
2254
+ const deltaX = previous.x - touch.clientX;
2255
+ const deltaY = previous.y - touch.clientY;
2256
+ if (Math.max(Math.abs(deltaX), Math.abs(deltaY)) < 4) return;
2257
+ touchPositionRef.current = { x: touch.clientX, y: touch.clientY };
2258
+ onWheel({
2259
+ deltaX,
2260
+ deltaY,
2261
+ target: event.target,
2262
+ currentTarget: event.currentTarget,
2263
+ });
2264
+ }}
2265
+ onTouchEnd={() => {
2266
+ touchPositionRef.current = null;
2267
+ }}
2268
+ onTouchCancel={() => {
2269
+ touchPositionRef.current = null;
2270
+ }}
2271
+ onClickCapture={(event) => {
2272
+ const target =
2273
+ event.target instanceof Element
2274
+ ? event.target.closest("button[aria-expanded]")
2275
+ : null;
2276
+ if (target) {
2277
+ if (target.hasAttribute("data-og-work-header")) {
2278
+ releaseProgress(
2279
+ target
2280
+ .closest("[data-og-group-key]")
2281
+ ?.getAttribute("data-og-group-key") ?? null,
2282
+ );
2283
+ }
2296
2284
  releasePinFromReader();
2285
+ disclosureKeepsUnpinnedRef.current = true;
2297
2286
  }
2298
- readingAnchorRef.current =
2299
- !pinnedRef.current && scrollRef.current
2300
- ? captureTimelineAnchor(scrollRef.current)
2301
- : null;
2302
- settlementRef.current =
2303
- readableTurns &&
2304
- autoFollow &&
2305
- pinnedRef.current &&
2306
- revealedRef.current &&
2307
- !hasNewer &&
2308
- !pendingReaderLeaveRef.current &&
2309
- !disclosureKeepsUnpinnedRef.current &&
2310
- !prefersReducedMotion() &&
2311
- scrollRef.current
2312
- ? captureTimelineSettlement(scrollRef.current, groups)
2313
- : null;
2314
2287
  }}
2288
+ onFocusCapture={(event) => {
2289
+ if (readableTurns && event.target !== event.currentTarget) {
2290
+ stopSettlement();
2291
+ releasePinFromReader();
2292
+ disclosureKeepsUnpinnedRef.current = true;
2293
+ }
2294
+ }}
2295
+ onPointerDown={onPointerDown}
2296
+ onKeyDown={onKeyDown}
2297
+ style={
2298
+ groups.length > 0 && !revealed ? { visibility: "hidden" } : undefined
2299
+ }
2300
+ className={cn(
2301
+ // tabIndex=-1 is programmatic only — never paint a focus ring on
2302
+ // the whole scroller (click + Shift used to flash a blue outline).
2303
+ "min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pt-16 pb-6 sm:px-6 outline-hidden",
2304
+ autoFollow && pinned && !hasNewer
2305
+ ? "[overflow-anchor:none]"
2306
+ : "[overflow-anchor:auto]",
2307
+ )}
2315
2308
  >
2316
- <div className="relative mx-auto flex w-full max-w-3xl flex-col gap-5">
2317
- <AnimatePresence>
2318
- {loadingOlder ||
2319
- loadingOldest ||
2320
- (hasOlder && onJumpToStart && olderPrefetchArmed) ||
2321
- underfillRetryReady ? (
2322
- // Reserved top gutter: controls scroll with history and cannot
2323
- // cover a disclosure. Visibility never changes content height.
2324
- <motion.div
2325
- initial={{ opacity: 0, y: -6 }}
2326
- animate={{ opacity: 1, y: 0 }}
2327
- exit={{ opacity: 0, y: -6 }}
2328
- transition={{ duration: 0.15, ease: "easeOut" }}
2329
- data-og-loading-older=""
2330
- aria-live="polite"
2331
- className={cn(
2332
- "pointer-events-none absolute inset-x-0 -top-11 z-10 flex",
2333
- // The scrolling history control and fixed question
2334
- // action must never share the same pointer region.
2335
- questionNav
2336
- ? "max-w-[calc(50%-0.5rem)] justify-start"
2337
- : "justify-center",
2338
- )}
2339
- >
2340
- {loadingOlder || loadingOldest ? (
2341
- <span className={LOADING_CHIP_CLASS}>
2342
- <span className="og-shimmer-text">
2343
- {loadingOldest
2344
- ? "Jumping to start…"
2345
- : "Loading earlier activity…"}
2309
+ <TimelineBeforeLayout
2310
+ capture={() => {
2311
+ if (readableTurns && timelineHasReader(scrollRef.current)) {
2312
+ releasePinFromReader();
2313
+ }
2314
+ readingAnchorRef.current =
2315
+ !pinnedRef.current && scrollRef.current
2316
+ ? captureTimelineAnchor(scrollRef.current)
2317
+ : null;
2318
+ settlementRef.current =
2319
+ readableTurns &&
2320
+ autoFollow &&
2321
+ pinnedRef.current &&
2322
+ revealedRef.current &&
2323
+ !hasNewer &&
2324
+ !pendingReaderLeaveRef.current &&
2325
+ !disclosureKeepsUnpinnedRef.current &&
2326
+ !prefersReducedMotion() &&
2327
+ scrollRef.current
2328
+ ? captureTimelineSettlement(scrollRef.current, groups)
2329
+ : null;
2330
+ }}
2331
+ >
2332
+ <div className="relative mx-auto flex w-full max-w-3xl flex-col gap-5">
2333
+ <AnimatePresence>
2334
+ {loadingOlder ||
2335
+ loadingOldest ||
2336
+ (hasOlder && onJumpToStart && olderPrefetchArmed) ||
2337
+ underfillRetryReady ? (
2338
+ // Reserved top gutter: controls scroll with history and cannot
2339
+ // cover a disclosure. Visibility never changes content height.
2340
+ <motion.div
2341
+ initial={{ opacity: 0, y: -6 }}
2342
+ animate={{ opacity: 1, y: 0 }}
2343
+ exit={{ opacity: 0, y: -6 }}
2344
+ transition={{ duration: 0.15, ease: "easeOut" }}
2345
+ data-og-loading-older=""
2346
+ aria-live="polite"
2347
+ className={cn(
2348
+ "pointer-events-none absolute inset-x-0 -top-11 z-10 flex",
2349
+ // The scrolling history control and fixed question
2350
+ // action must never share the same pointer region.
2351
+ questionNav
2352
+ ? "max-w-[calc(50%-0.5rem)] justify-start"
2353
+ : "justify-center",
2354
+ )}
2355
+ >
2356
+ {loadingOlder || loadingOldest ? (
2357
+ <span className={LOADING_CHIP_CLASS}>
2358
+ <span className="og-shimmer-text">
2359
+ {loadingOldest
2360
+ ? "Jumping to start…"
2361
+ : "Loading earlier activity…"}
2362
+ </span>
2346
2363
  </span>
2347
- </span>
2348
- ) : null}
2349
- {hasOlder &&
2350
- !loadingOlder &&
2351
- !loadingOldest &&
2352
- (underfillRetryReady || onJumpToStart) ? (
2353
- <button
2354
- type="button"
2355
- data-og-retry={underfillRetryReady || undefined}
2356
- data-og-jump-to-start={!underfillRetryReady || undefined}
2357
- onClick={() => {
2358
- const node = scrollRef.current;
2359
- if (underfillRetryReady) {
2360
- if (node && underfillRetryReadyRef.current) {
2361
- // AnimatePresence retains this handler during
2362
- // exit. Current authorization plus the exact
2363
- // attempt check prevent stale dispatch.
2364
- requestOlderIfUnderfilled(node, underfillSettledAttempt!);
2365
- }
2366
- return;
2367
- }
2368
- applyPinned(false);
2369
- pendingJumpToStartRef.current = true;
2370
- const seq = ++jumpToStartSeqRef.current;
2371
- void Promise.resolve(onJumpToStart!()).then(
2372
- () => {
2373
- // The commit that swaps in the oldest window consumes
2374
- // the flag against the new DOM; this write covers the
2375
- // already-committed order and the no-window-change
2376
- // case (jumping within the current window).
2377
- const scroller = scrollRef.current ?? node;
2378
- if (scroller) {
2379
- scroller.scrollTop = 0;
2364
+ ) : null}
2365
+ {hasOlder &&
2366
+ !loadingOlder &&
2367
+ !loadingOldest &&
2368
+ (underfillRetryReady || onJumpToStart) ? (
2369
+ <button
2370
+ type="button"
2371
+ data-og-retry={underfillRetryReady || undefined}
2372
+ data-og-jump-to-start={!underfillRetryReady || undefined}
2373
+ onClick={() => {
2374
+ const node = scrollRef.current;
2375
+ if (underfillRetryReady) {
2376
+ if (node && underfillRetryReadyRef.current) {
2377
+ // AnimatePresence retains this handler during
2378
+ // exit. Current authorization plus the exact
2379
+ // attempt check prevent stale dispatch.
2380
+ requestOlderIfUnderfilled(
2381
+ node,
2382
+ underfillSettledAttempt!,
2383
+ );
2380
2384
  }
2381
- // A host may resolve without ever changing the
2382
- // window (already on the oldest page). Any swap
2383
- // commit runs its layout effect before the next
2384
- // frame, so a flag still armed by then is the
2385
- // no-change case — clear it, or a LATER prepend
2386
- // would spuriously jump the reader to the top.
2387
- requestFrame(() => {
2385
+ return;
2386
+ }
2387
+ applyPinned(false);
2388
+ pendingJumpToStartRef.current = true;
2389
+ const seq = ++jumpToStartSeqRef.current;
2390
+ void Promise.resolve(onJumpToStart!()).then(
2391
+ () => {
2392
+ // The commit that swaps in the oldest window consumes
2393
+ // the flag against the new DOM; this write covers the
2394
+ // already-committed order and the no-window-change
2395
+ // case (jumping within the current window).
2396
+ const scroller = scrollRef.current ?? node;
2397
+ if (scroller) {
2398
+ scroller.scrollTop = 0;
2399
+ }
2400
+ // A host may resolve without ever changing the
2401
+ // window (already on the oldest page). Any swap
2402
+ // commit runs its layout effect before the next
2403
+ // frame, so a flag still armed by then is the
2404
+ // no-change case — clear it, or a LATER prepend
2405
+ // would spuriously jump the reader to the top.
2406
+ requestFrame(() => {
2407
+ if (jumpToStartSeqRef.current === seq) {
2408
+ pendingJumpToStartRef.current = false;
2409
+ }
2410
+ });
2411
+ },
2412
+ () => {
2388
2413
  if (jumpToStartSeqRef.current === seq) {
2389
2414
  pendingJumpToStartRef.current = false;
2390
2415
  }
2391
- });
2392
- },
2393
- () => {
2394
- if (jumpToStartSeqRef.current === seq) {
2395
- pendingJumpToStartRef.current = false;
2396
- }
2397
- },
2398
- );
2399
- }}
2400
- className="pointer-events-auto rounded-full border border-og-border px-3 py-1.5 text-og-control"
2401
- >
2402
- {underfillRetryReady
2403
- ? underfillSettledAttempt?.[2]?.tailPreserved
2404
- ? "Load earlier activity"
2405
- : "Retry earlier activity"
2406
- : "Jump to start"}
2407
- </button>
2408
- ) : null}
2409
- </motion.div>
2416
+ },
2417
+ );
2418
+ }}
2419
+ className="pointer-events-auto rounded-full border border-og-border px-3 py-1.5 text-og-control"
2420
+ >
2421
+ {underfillRetryReady
2422
+ ? underfillSettledAttempt?.[2]?.tailPreserved
2423
+ ? "Load earlier activity"
2424
+ : "Retry earlier activity"
2425
+ : "Jump to start"}
2426
+ </button>
2427
+ ) : null}
2428
+ </motion.div>
2429
+ ) : null}
2430
+ </AnimatePresence>
2431
+ {!groups.length
2432
+ ? (emptyState ?? (
2433
+ <p className="py-10 text-center text-og-menu text-og-fg-subtle">
2434
+ No activity yet.
2435
+ </p>
2436
+ ))
2437
+ : null}
2438
+ {hasOlder && olderPrefetchArmed ? (
2439
+ // Overlaid, not a layout row: mounting/unmounting the sentinel
2440
+ // must never shift content (that shift was itself a wobble).
2441
+ // End at the scroll origin above the pt-16 gutter so the
2442
+ // observer and scrollTop cooldown share the same 400px band.
2443
+ <div
2444
+ ref={topSentinelRef}
2445
+ data-og-top-sentinel=""
2446
+ data-og-timeline-chrome=""
2447
+ aria-hidden="true"
2448
+ className="pointer-events-none absolute inset-x-0 -top-16 h-px -translate-y-full"
2449
+ />
2410
2450
  ) : null}
2411
- </AnimatePresence>
2412
- {!groups.length
2413
- ? (emptyState ?? (
2414
- <p className="py-10 text-center text-og-menu text-og-fg-subtle">
2415
- No activity yet.
2416
- </p>
2417
- ))
2418
- : null}
2419
- {hasOlder && olderPrefetchArmed ? (
2420
- // Overlaid, not a layout row: mounting/unmounting the sentinel
2421
- // must never shift content (that shift was itself a wobble).
2422
- // End at the scroll origin above the pt-16 gutter so the
2423
- // observer and scrollTop cooldown share the same 400px band.
2424
- <div
2425
- ref={topSentinelRef}
2426
- data-og-top-sentinel=""
2427
- data-og-timeline-chrome=""
2428
- aria-hidden="true"
2429
- className="pointer-events-none absolute inset-x-0 -top-16 h-px -translate-y-full"
2430
- />
2431
- ) : null}
2432
- {groups.map(({ group, key, entranceEnabled }, index) => {
2433
- return (
2434
- <TimelineSearchRevealContext.Provider
2435
- key={key}
2436
- value={
2437
- searchItem && timelineGroupItemIds(group).includes(searchItem.id)
2438
- ? searchRevealKey
2439
- : null
2440
- }
2441
- >
2442
- <TimelineGroupEntry
2443
- groupKey={key}
2444
- group={group}
2445
- nextGroup={groups[index + 1]?.group}
2446
- startupDismissed={
2447
- group.kind === "activity" &&
2448
- group.items.some(
2449
- (item) => item.turnId && turnsWithOutput.has(item.turnId),
2450
- )
2451
- }
2452
- entranceEnabled={entranceEnabled}
2453
- liveEntranceEnabled={
2454
- group.kind === "activity" ? !bulkRender : undefined
2451
+ {groups.map(({ group, key, entranceEnabled }, index) => {
2452
+ return (
2453
+ <TimelineSearchRevealContext.Provider
2454
+ key={key}
2455
+ value={
2456
+ searchItem &&
2457
+ timelineGroupItemIds(group).includes(searchItem.id)
2458
+ ? searchRevealKey
2459
+ : null
2455
2460
  }
2456
- context={timelineGroupEntryContext}
2457
- />
2458
- </TimelineSearchRevealContext.Provider>
2459
- );
2460
- })}
2461
- {groups.length > 0 && trailingState ? (
2462
- <div data-og-timeline-trailing-state="">{trailingState}</div>
2463
- ) : null}
2464
- {hasNewer && newerFailure?.key === newerBoundaryKey ? (
2465
- <div
2466
- data-og-newer-error=""
2467
- className="flex flex-col items-center gap-2 px-4 py-3 text-center text-og-menu text-og-fg-muted"
2461
+ >
2462
+ <TimelineGroupEntry
2463
+ groupKey={key}
2464
+ group={group}
2465
+ nextGroup={groups[index + 1]?.group}
2466
+ startupDismissed={
2467
+ group.kind === "activity" &&
2468
+ group.items.some(
2469
+ (item) => item.turnId && turnsWithOutput.has(item.turnId),
2470
+ )
2471
+ }
2472
+ entranceEnabled={entranceEnabled}
2473
+ liveEntranceEnabled={
2474
+ group.kind === "activity" ? !bulkRender : undefined
2475
+ }
2476
+ context={timelineGroupEntryContext}
2477
+ />
2478
+ </TimelineSearchRevealContext.Provider>
2479
+ );
2480
+ })}
2481
+ {groups.length > 0 && trailingState ? (
2482
+ <div data-og-timeline-trailing-state="">{trailingState}</div>
2483
+ ) : null}
2484
+ {hasNewer && newerFailure?.key === newerBoundaryKey ? (
2485
+ <div
2486
+ data-og-newer-error=""
2487
+ className="flex flex-col items-center gap-2 px-4 py-3 text-center text-og-menu text-og-fg-muted"
2488
+ >
2489
+ <p role="status" className="max-w-prose [overflow-wrap:anywhere]">
2490
+ Couldn’t load later activity. {newerFailure.message}
2491
+ </p>
2492
+ <button
2493
+ ref={newerRetryButtonRef}
2494
+ type="button"
2495
+ data-og-retry-newer=""
2496
+ className="min-h-11 rounded-og-md border border-og-border px-3 py-2 text-og-fg hover:bg-og-surface-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-og-accent"
2497
+ aria-disabled={loadingNewer || newerRetryPending}
2498
+ aria-busy={newerRetryPending}
2499
+ onClick={() => requestNewer(true)}
2500
+ >
2501
+ {newerRetryPending
2502
+ ? "Retrying later activity…"
2503
+ : "Retry later activity"}
2504
+ </button>
2505
+ </div>
2506
+ ) : null}
2507
+ {hasNewer ? (
2508
+ <div
2509
+ ref={bottomSentinelRef}
2510
+ data-og-bottom-sentinel=""
2511
+ data-og-timeline-chrome=""
2512
+ aria-hidden="true"
2513
+ className="h-px w-full"
2514
+ />
2515
+ ) : null}
2516
+ </div>
2517
+ </TimelineBeforeLayout>
2518
+ </div>
2519
+ {draftAnnotations && draftAnnotations.length > 0 ? (
2520
+ <Suspense fallback={null}>
2521
+ <TimelineAnnotationMarkers
2522
+ rootRef={scrollRef}
2523
+ annotations={draftAnnotations}
2524
+ onSelect={onDraftAnnotationSelect}
2525
+ />
2526
+ </Suspense>
2527
+ ) : null}
2528
+
2529
+ <AnimatePresence>
2530
+ {questionNav ? (
2531
+ <motion.div
2532
+ key="question-nav"
2533
+ initial={{ opacity: 0, y: -6 }}
2534
+ animate={{ opacity: 1, y: 0 }}
2535
+ exit={{ opacity: 0, y: -6 }}
2536
+ transition={{ duration: 0.15, ease: "easeOut" }}
2537
+ data-og-question-nav=""
2538
+ // Below the pinned work-header strip (py-1.5 row, 44px on coarse
2539
+ // pointers), never over it: the header stays a full-width target.
2540
+ data-og-pill-placement={questionPillPlacement.placement}
2541
+ style={{ marginTop: questionPillPlacement.offsetY }}
2542
+ className={cn(
2543
+ "pointer-events-none absolute inset-x-0 top-11 z-10 flex px-4 sm:px-6 pointer-coarse:top-14",
2544
+ QUESTION_PILL_PLACEMENT_CLASS[questionPillPlacement.placement],
2545
+ )}
2546
+ >
2547
+ <motion.div
2548
+ ref={questionPillRef}
2549
+ layout="position"
2550
+ transition={{ duration: 0.18, ease: "easeOut" }}
2551
+ className="pointer-events-auto inline-flex max-w-full items-center rounded-full border border-og-border bg-og-surface-3/90 text-og-control font-medium text-og-fg shadow-og-md backdrop-blur"
2468
2552
  >
2469
- <p role="status" className="max-w-prose [overflow-wrap:anywhere]">
2470
- Couldn’t load later activity. {newerFailure.message}
2471
- </p>
2472
2553
  <button
2473
- ref={newerRetryButtonRef}
2474
2554
  type="button"
2475
- data-og-retry-newer=""
2476
- className="min-h-11 rounded-og-md border border-og-border px-3 py-2 text-og-fg hover:bg-og-surface-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-og-accent"
2477
- aria-disabled={loadingNewer || newerRetryPending}
2478
- aria-busy={newerRetryPending}
2479
- onClick={() => requestNewer(true)}
2555
+ data-og-jump-to-question=""
2556
+ onClick={() => jumpToQuestion(questionNav.key)}
2557
+ className="inline-flex min-w-0 items-center gap-1.5 whitespace-nowrap px-3 py-1.5 hover:text-og-fg pointer-coarse:min-h-11"
2480
2558
  >
2481
- {newerRetryPending
2482
- ? "Retrying later activity…"
2483
- : "Retry later activity"}
2559
+ <ArrowUpIcon aria-hidden className="size-3.5" />
2560
+ Back to your message
2484
2561
  </button>
2485
- </div>
2486
- ) : null}
2487
- {hasNewer ? (
2488
- <div
2489
- ref={bottomSentinelRef}
2490
- data-og-bottom-sentinel=""
2491
- data-og-timeline-chrome=""
2492
- aria-hidden="true"
2493
- className="h-px w-full"
2494
- />
2495
- ) : null}
2496
- </div>
2497
- </TimelineBeforeLayout>
2498
- </div>
2499
- {draftAnnotations && draftAnnotations.length > 0 ? (
2500
- <Suspense fallback={null}>
2501
- <TimelineAnnotationMarkers
2502
- rootRef={scrollRef}
2503
- annotations={draftAnnotations}
2504
- onSelect={onDraftAnnotationSelect}
2505
- />
2506
- </Suspense>
2507
- ) : null}
2508
-
2509
- <AnimatePresence>
2510
- {questionNav ? (
2511
- <motion.div
2512
- key="question-nav"
2513
- initial={{ opacity: 0, y: -6 }}
2514
- animate={{ opacity: 1, y: 0 }}
2515
- exit={{ opacity: 0, y: -6 }}
2516
- transition={{ duration: 0.15, ease: "easeOut" }}
2517
- data-og-question-nav=""
2518
- // Below the pinned work-header strip (py-1.5 row, 44px on coarse
2519
- // pointers), never over it: the header stays a full-width target.
2520
- className="pointer-events-none absolute inset-x-0 top-11 z-10 flex justify-end px-4 sm:px-6 pointer-coarse:top-14"
2521
- >
2522
- <div className="pointer-events-auto inline-flex max-w-[calc(50%-0.5rem)] items-center rounded-full border border-og-border bg-og-surface-3/90 text-og-control font-medium text-og-fg shadow-og-md backdrop-blur">
2523
- <button
2524
- type="button"
2525
- data-og-jump-to-question=""
2526
- onClick={() => void jumpToLatestQuestion()}
2527
- disabled={questionPending}
2528
- aria-busy={questionPending}
2529
- title={questionError ?? undefined}
2530
- className="inline-flex min-w-0 items-center gap-1.5 px-3 py-1.5 hover:text-og-fg pointer-coarse:min-h-11"
2531
- >
2532
- <ArrowUpIcon aria-hidden className="size-3.5" />
2533
- Latest question
2534
- </button>
2535
- {questionError && (
2536
- <span role="status" className="px-2 text-og-xs">
2537
- {questionError}
2538
- </span>
2539
- )}
2540
- </div>
2541
- </motion.div>
2542
- ) : null}
2543
- </AnimatePresence>
2544
- <AnimatePresence>
2545
- {loadingNewer ? (
2546
- <motion.div
2547
- initial={{ opacity: 0, y: 6 }}
2548
- animate={{ opacity: 1, y: 0 }}
2549
- exit={{ opacity: 0, y: 6 }}
2550
- transition={{ duration: 0.15, ease: "easeOut" }}
2551
- data-og-loading-newer=""
2552
- aria-live="polite"
2553
- className="pointer-events-none absolute inset-x-0 bottom-14 z-10 flex justify-center"
2554
- >
2555
- <span className={LOADING_CHIP_CLASS}>
2556
- <span className="og-shimmer-text">Loading later activity…</span>
2557
- </span>
2558
- </motion.div>
2559
- ) : null}
2560
- </AnimatePresence>
2561
- <AnimatePresence>
2562
- {((!pinned && autoFollow) || hasNewer || canSkipTipCatchup) && autoFollow ? (
2563
- <motion.button
2564
- type="button"
2565
- data-og-jump-to-latest=""
2566
- initial={{ opacity: 0, y: 8 }}
2567
- animate={{ opacity: 1, y: 0 }}
2568
- exit={{ opacity: 0, y: 8 }}
2569
- transition={{ duration: 0.15, ease: "easeOut" }}
2570
- onClick={() => {
2571
- // Returning to the tip explicitly releases reader-owned
2572
- // prose. Clear only this timeline's selection before the
2573
- // synchronous ownership check on the next commit.
2574
- const viewport = scrollRef.current;
2575
- const selection = viewport?.ownerDocument.getSelection();
2576
- if (
2577
- selection &&
2578
- !selection.isCollapsed &&
2579
- (viewport?.contains(selection.anchorNode) ||
2580
- viewport?.contains(selection.focusNode))
2581
- )
2582
- selection.removeAllRanges();
2583
- disclosureKeepsUnpinnedRef.current = false;
2584
- if (hasNewer) {
2585
- // Do not pin against the current history page — its bottom
2586
- // is not the tip. The pin + snap run when the tip window
2587
- // actually lands (`hasNewer` flips false).
2588
- wantPinRef.current = true;
2589
- const node = scrollRef.current;
2590
- if (onJumpToLatest) {
2591
- void Promise.resolve(onJumpToLatest()).then(
2592
- () => {
2593
- // Covers a host that flipped hasNewer before
2594
- // resolving; otherwise the tip-window commit
2595
- // consumes the flag.
2596
- const current = scrollRef.current;
2597
- if (current && wantPinRef.current && !hasNewerRef.current) {
2562
+ </motion.div>
2563
+ </motion.div>
2564
+ ) : null}
2565
+ </AnimatePresence>
2566
+ <AnimatePresence>
2567
+ {loadingNewer ? (
2568
+ <motion.div
2569
+ initial={{ opacity: 0, y: 6 }}
2570
+ animate={{ opacity: 1, y: 0 }}
2571
+ exit={{ opacity: 0, y: 6 }}
2572
+ transition={{ duration: 0.15, ease: "easeOut" }}
2573
+ data-og-loading-newer=""
2574
+ aria-live="polite"
2575
+ className="pointer-events-none absolute inset-x-0 bottom-14 z-10 flex justify-center"
2576
+ >
2577
+ <span className={LOADING_CHIP_CLASS}>
2578
+ <span className="og-shimmer-text">Loading later activity…</span>
2579
+ </span>
2580
+ </motion.div>
2581
+ ) : null}
2582
+ </AnimatePresence>
2583
+ <AnimatePresence>
2584
+ {jumpPillShown ? (
2585
+ <motion.button
2586
+ ref={jumpPillRef}
2587
+ type="button"
2588
+ data-og-jump-to-latest=""
2589
+ data-og-pill-placement={jumpPillPlacement.placement}
2590
+ layout="position"
2591
+ initial={{ opacity: 0, y: 8 }}
2592
+ animate={{ opacity: 1, y: 0 }}
2593
+ exit={{ opacity: 0, y: 8 }}
2594
+ transition={{ duration: 0.15, ease: "easeOut" }}
2595
+ onClick={() => {
2596
+ // Returning to the tip explicitly releases reader-owned
2597
+ // prose. Clear only this timeline's selection before the
2598
+ // synchronous ownership check on the next commit.
2599
+ const viewport = scrollRef.current;
2600
+ const selection = viewport?.ownerDocument.getSelection();
2601
+ if (
2602
+ selection &&
2603
+ !selection.isCollapsed &&
2604
+ (viewport?.contains(selection.anchorNode) ||
2605
+ viewport?.contains(selection.focusNode))
2606
+ )
2607
+ selection.removeAllRanges();
2608
+ disclosureKeepsUnpinnedRef.current = false;
2609
+ if (hasNewer) {
2610
+ // Do not pin against the current history page — its bottom
2611
+ // is not the tip. The pin + snap run when the tip window
2612
+ // actually lands (`hasNewer` flips false).
2613
+ wantPinRef.current = true;
2614
+ const node = scrollRef.current;
2615
+ if (onJumpToLatest) {
2616
+ void Promise.resolve(onJumpToLatest()).then(
2617
+ () => {
2618
+ // Covers a host that flipped hasNewer before
2619
+ // resolving; otherwise the tip-window commit
2620
+ // consumes the flag.
2621
+ const current = scrollRef.current;
2622
+ if (current && wantPinRef.current && !hasNewerRef.current) {
2623
+ wantPinRef.current = false;
2624
+ applyPinned(true);
2625
+ snapToBottom(current);
2626
+ }
2627
+ },
2628
+ () => {
2629
+ // The tip reload failed (ordinary network error):
2630
+ // an armed latch would fire a surprise snap when
2631
+ // the reader later pages to the tip themselves.
2598
2632
  wantPinRef.current = false;
2599
- applyPinned(true);
2600
- snapToBottom(current);
2601
- }
2602
- },
2603
- () => {
2604
- // The tip reload failed (ordinary network error):
2605
- // an armed latch would fire a surprise snap when
2606
- // the reader later pages to the tip themselves.
2607
- wantPinRef.current = false;
2608
- },
2609
- );
2610
- } else if (node) {
2611
- // No tip reload available: jump within the in-memory
2612
- // window so the newer sentinel can page forward; the
2613
- // latch pins if the tip window eventually lands.
2633
+ },
2634
+ );
2635
+ } else if (node) {
2636
+ // No tip reload available: jump within the in-memory
2637
+ // window so the newer sentinel can page forward; the
2638
+ // latch pins if the tip window eventually lands.
2639
+ snapToBottom(node);
2640
+ }
2641
+ return;
2642
+ }
2643
+ const node = scrollRef.current;
2644
+ if (node) {
2645
+ applyPinned(true);
2614
2646
  snapToBottom(node);
2615
2647
  }
2616
- return;
2617
- }
2618
- const node = scrollRef.current;
2619
- if (node) {
2620
- applyPinned(true);
2621
- snapToBottom(node);
2622
- }
2623
- }}
2624
- className={cn(
2625
- "absolute inset-x-0 bottom-4 mx-auto w-fit",
2626
- "inline-flex items-center gap-1.5 rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1.5",
2627
- "text-og-control font-medium text-og-fg shadow-og-md backdrop-blur",
2628
- "hover:border-og-border-strong",
2629
- )}
2630
- >
2631
- <ArrowDownIcon className="size-3.5" />
2632
- Jump to latest
2633
- </motion.button>
2634
- ) : null}
2635
- </AnimatePresence>
2636
- </div>
2637
- </TimelineAnnotationSourceRootContext.Provider>
2638
- </TooltipProvider>
2639
- </EntranceAnimationProvider>
2640
- </TimelineComputeLabelProvider>
2641
- </SeenActivityIdsProvider>
2642
- </FoldMemoryProvider>
2643
- </LightboxProvider>
2648
+ }}
2649
+ className={cn(
2650
+ "absolute bottom-4 w-fit",
2651
+ JUMP_PILL_PLACEMENT_CLASS[jumpPillPlacement.placement],
2652
+ "inline-flex items-center gap-1.5 rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1.5",
2653
+ "text-og-control font-medium text-og-fg shadow-og-md backdrop-blur",
2654
+ "hover:border-og-border-strong",
2655
+ )}
2656
+ >
2657
+ <ArrowDownIcon className="size-3.5" />
2658
+ Jump to latest
2659
+ </motion.button>
2660
+ ) : null}
2661
+ </AnimatePresence>
2662
+ </div>
2663
+ </TimelineAnnotationSourceRootContext.Provider>
2664
+ </TooltipProvider>
2665
+ </EntranceAnimationProvider>
2666
+ </TimelineComputeLabelProvider>
2667
+ </SeenActivityIdsProvider>
2668
+ </FoldMemoryProvider>
2669
+ </LightboxProvider>
2670
+ </AllowancePresentationProvider>
2644
2671
  );
2645
2672
  // Agent-authored object links resolve through the host, never the console.
2646
2673
  return <OpenGeniLinkProvider resolveLink={resolveLink}>{timeline}</OpenGeniLinkProvider>;
@@ -3899,30 +3926,43 @@ function UserMessageRow({
3899
3926
  {deliveryFailed ? (
3900
3927
  <div
3901
3928
  role="status"
3902
- className="flex max-w-full items-center gap-2 px-1 text-og-xs text-og-status-failed"
3929
+ className="flex max-w-full flex-col items-start gap-1 px-1 text-og-xs text-og-status-failed"
3903
3930
  >
3904
- <span className="inline-flex items-center gap-1" title={item.delivery?.error}>
3931
+ <span className="flex min-w-0 items-start gap-1">
3905
3932
  <TriangleAlertIcon className="size-3.5 shrink-0" aria-hidden="true" />
3906
- <span>Message not sent</span>
3933
+ <span className="min-w-0 break-words">
3934
+ {item.delivery?.error || "Message not sent"}
3935
+ </span>
3907
3936
  </span>
3908
- {item.delivery?.onRetry ? (
3909
- <button
3910
- type="button"
3911
- className="font-medium text-og-status-failed underline decoration-og-status-failed/50 underline-offset-2 hover:text-og-fg"
3912
- onClick={item.delivery.onRetry}
3913
- >
3914
- Retry
3915
- </button>
3916
- ) : null}
3917
- {item.delivery?.onRemove ? (
3918
- <button
3919
- type="button"
3920
- className="font-medium text-og-fg-subtle underline decoration-og-border underline-offset-2 hover:text-og-fg"
3921
- onClick={item.delivery.onRemove}
3922
- >
3923
- Remove
3924
- </button>
3925
- ) : null}
3937
+ <div className="flex flex-wrap items-center gap-2">
3938
+ {item.delivery?.onEdit ? (
3939
+ <button
3940
+ type="button"
3941
+ className="font-medium text-og-status-failed underline decoration-og-status-failed/50 underline-offset-2 hover:text-og-fg"
3942
+ onClick={item.delivery.onEdit}
3943
+ >
3944
+ Edit message
3945
+ </button>
3946
+ ) : null}
3947
+ {item.delivery?.onRetry ? (
3948
+ <button
3949
+ type="button"
3950
+ className="font-medium text-og-status-failed underline decoration-og-status-failed/50 underline-offset-2 hover:text-og-fg"
3951
+ onClick={item.delivery.onRetry}
3952
+ >
3953
+ Retry
3954
+ </button>
3955
+ ) : null}
3956
+ {item.delivery?.onRemove ? (
3957
+ <button
3958
+ type="button"
3959
+ className="font-medium text-og-fg-subtle underline decoration-og-border underline-offset-2 hover:text-og-fg"
3960
+ onClick={item.delivery.onRemove}
3961
+ >
3962
+ Remove
3963
+ </button>
3964
+ ) : null}
3965
+ </div>
3926
3966
  </div>
3927
3967
  ) : null}
3928
3968
  </div>
@@ -4548,6 +4588,7 @@ function formatVideoDuration(seconds: number): string {
4548
4588
 
4549
4589
  function NoticeRow({ item }: { item: NoticeItem }) {
4550
4590
  const enter = useEntranceAnimation();
4591
+ if (item.allowance) return <AllowanceExhaustedRow refusal={item.allowance} />;
4551
4592
  if (item.recordedOutcome) {
4552
4593
  return (
4553
4594
  <details