@flamingo-stack/openframe-frontend-core 0.0.489 → 0.0.490-snapshot.20260728181420

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 (190) hide show
  1. package/dist/{chunk-IN4IO7BL.cjs → chunk-2RTNC3KP.cjs} +1212 -970
  2. package/dist/chunk-2RTNC3KP.cjs.map +1 -0
  3. package/dist/{chunk-5VSEMWWE.js → chunk-2YQK6LHR.js} +5 -5
  4. package/dist/{chunk-W6ZUOWSB.js → chunk-35CTCUJ7.js} +6 -6
  5. package/dist/{chunk-ZIAS5NNL.cjs → chunk-6O3YTVLG.cjs} +12 -12
  6. package/dist/{chunk-ZIAS5NNL.cjs.map → chunk-6O3YTVLG.cjs.map} +1 -1
  7. package/dist/{chunk-RW2LPL72.js → chunk-6WTCJI5R.js} +2 -2
  8. package/dist/{chunk-X2YF5TJS.js → chunk-6XNWT2KC.js} +3 -3
  9. package/dist/{chunk-VLOE7MI2.cjs → chunk-7NRRONK5.cjs} +32 -32
  10. package/dist/{chunk-VLOE7MI2.cjs.map → chunk-7NRRONK5.cjs.map} +1 -1
  11. package/dist/{chunk-YMJZOANX.js → chunk-CYIUPI7J.js} +6298 -6056
  12. package/dist/chunk-CYIUPI7J.js.map +1 -0
  13. package/dist/{chunk-FKPXFTZT.js → chunk-CZTKQVIQ.js} +3 -3
  14. package/dist/{chunk-5YMY4BAW.cjs → chunk-EHHX22JL.cjs} +93 -93
  15. package/dist/{chunk-5YMY4BAW.cjs.map → chunk-EHHX22JL.cjs.map} +1 -1
  16. package/dist/{chunk-LLEDO4P7.cjs → chunk-GL33BHGZ.cjs} +73 -73
  17. package/dist/{chunk-LLEDO4P7.cjs.map → chunk-GL33BHGZ.cjs.map} +1 -1
  18. package/dist/{chunk-N4MTNTSX.js → chunk-GTMW4KY7.js} +6 -6
  19. package/dist/{chunk-H3YRJ3LZ.cjs → chunk-HRMOCJKU.cjs} +12 -12
  20. package/dist/{chunk-H3YRJ3LZ.cjs.map → chunk-HRMOCJKU.cjs.map} +1 -1
  21. package/dist/{chunk-3OIB763B.cjs → chunk-ITV6LSG7.cjs} +19 -19
  22. package/dist/{chunk-3OIB763B.cjs.map → chunk-ITV6LSG7.cjs.map} +1 -1
  23. package/dist/{chunk-EU6QNO4S.js → chunk-IUGJAILN.js} +3 -3
  24. package/dist/{chunk-J7H32E3R.cjs → chunk-J2V4B6FF.cjs} +18 -18
  25. package/dist/{chunk-J7H32E3R.cjs.map → chunk-J2V4B6FF.cjs.map} +1 -1
  26. package/dist/{chunk-RO7Q56ZK.cjs → chunk-PVSXTCJY.cjs} +40 -40
  27. package/dist/{chunk-RO7Q56ZK.cjs.map → chunk-PVSXTCJY.cjs.map} +1 -1
  28. package/dist/{chunk-B4YR342L.js → chunk-QF54RPHZ.js} +6 -6
  29. package/dist/{chunk-GZK7FWJJ.js → chunk-QKWOBV55.js} +12 -12
  30. package/dist/chunk-QKWOBV55.js.map +1 -0
  31. package/dist/{chunk-Z7Y63IFU.cjs → chunk-QNFAZIFC.cjs} +5 -5
  32. package/dist/{chunk-Z7Y63IFU.cjs.map → chunk-QNFAZIFC.cjs.map} +1 -1
  33. package/dist/{chunk-STYA77PH.cjs → chunk-SGL6URIJ.cjs} +12 -12
  34. package/dist/chunk-SGL6URIJ.cjs.map +1 -0
  35. package/dist/{chunk-TWGYBXYV.js → chunk-T4BUS6JV.js} +3 -3
  36. package/dist/{chunk-KP5KRRD6.cjs → chunk-THC3K262.cjs} +3 -3
  37. package/dist/{chunk-KP5KRRD6.cjs.map → chunk-THC3K262.cjs.map} +1 -1
  38. package/dist/{chunk-BIKOJ4FH.cjs → chunk-TTYJAZ7P.cjs} +58 -32
  39. package/dist/chunk-TTYJAZ7P.cjs.map +1 -0
  40. package/dist/{chunk-BGWDDB7Y.js → chunk-U2JIGUXF.js} +4 -4
  41. package/dist/{chunk-2RNJYJWO.cjs → chunk-VD4QDSP7.cjs} +40 -40
  42. package/dist/{chunk-2RNJYJWO.cjs.map → chunk-VD4QDSP7.cjs.map} +1 -1
  43. package/dist/{chunk-PB3BTZDU.js → chunk-WEUNPE3S.js} +139 -113
  44. package/dist/chunk-WEUNPE3S.js.map +1 -0
  45. package/dist/{chunk-2IHN7NNB.cjs → chunk-YKE2KZ2Y.cjs} +33 -33
  46. package/dist/{chunk-2IHN7NNB.cjs.map → chunk-YKE2KZ2Y.cjs.map} +1 -1
  47. package/dist/{chunk-VL4LUO32.js → chunk-ZDUJ6BDI.js} +4 -4
  48. package/dist/{chunk-K7MNWYYI.js → chunk-ZEC5U5T7.js} +2 -2
  49. package/dist/components/case-studies/index.cjs +10 -10
  50. package/dist/components/case-studies/index.js +4 -4
  51. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  52. package/dist/components/chat/guide-display.d.ts +14 -0
  53. package/dist/components/chat/guide-display.d.ts.map +1 -0
  54. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  55. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +1 -1
  56. package/dist/components/chat/index.cjs +6 -4
  57. package/dist/components/chat/index.cjs.map +1 -1
  58. package/dist/components/chat/index.d.ts +1 -0
  59. package/dist/components/chat/index.d.ts.map +1 -1
  60. package/dist/components/chat/index.js +5 -3
  61. package/dist/components/chat/types/component.types.d.ts +6 -0
  62. package/dist/components/chat/types/component.types.d.ts.map +1 -1
  63. package/dist/components/chat/types/message.types.d.ts +15 -2
  64. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  65. package/dist/components/chat/types/processing.types.d.ts +3 -0
  66. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  67. package/dist/components/chat/utils/chunk-parser.d.ts.map +1 -1
  68. package/dist/components/chat/utils/message-segment-accumulator.d.ts +5 -0
  69. package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
  70. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  71. package/dist/components/contact/index.cjs +5 -5
  72. package/dist/components/contact/index.js +4 -4
  73. package/dist/components/docs/index.cjs +7 -7
  74. package/dist/components/docs/index.js +6 -6
  75. package/dist/components/embeds/index.cjs +5 -5
  76. package/dist/components/embeds/index.js +4 -4
  77. package/dist/components/faq/index.cjs +6 -6
  78. package/dist/components/faq/index.js +5 -5
  79. package/dist/components/features/index.cjs +4 -4
  80. package/dist/components/features/index.js +3 -3
  81. package/dist/components/features/select-button.d.ts.map +1 -1
  82. package/dist/components/help-center-pages/index.cjs +23 -23
  83. package/dist/components/help-center-pages/index.js +14 -14
  84. package/dist/components/index.cjs +163 -161
  85. package/dist/components/index.cjs.map +1 -1
  86. package/dist/components/index.js +14 -12
  87. package/dist/components/index.js.map +1 -1
  88. package/dist/components/layout/page-layout.d.ts +7 -1
  89. package/dist/components/layout/page-layout.d.ts.map +1 -1
  90. package/dist/components/layout/title-block.d.ts +23 -1
  91. package/dist/components/layout/title-block.d.ts.map +1 -1
  92. package/dist/components/navigation/index.cjs +4 -4
  93. package/dist/components/navigation/index.js +3 -3
  94. package/dist/components/onboarding-guides/index.cjs +8 -8
  95. package/dist/components/onboarding-guides/index.js +7 -7
  96. package/dist/components/related-content/index.cjs +6 -6
  97. package/dist/components/related-content/index.js +5 -5
  98. package/dist/components/tickets/index.cjs +7 -7
  99. package/dist/components/tickets/index.js +6 -6
  100. package/dist/components/ui/autocomplete.d.ts.map +1 -1
  101. package/dist/components/ui/checkbox-block.d.ts.map +1 -1
  102. package/dist/components/ui/date-picker.d.ts.map +1 -1
  103. package/dist/components/ui/field-wrapper.d.ts +10 -1
  104. package/dist/components/ui/field-wrapper.d.ts.map +1 -1
  105. package/dist/components/ui/file-manager/index.cjs +50 -50
  106. package/dist/components/ui/file-manager/index.js +1 -1
  107. package/dist/components/ui/floating-tooltip.d.ts +8 -1
  108. package/dist/components/ui/floating-tooltip.d.ts.map +1 -1
  109. package/dist/components/ui/index.cjs +6 -4
  110. package/dist/components/ui/index.cjs.map +1 -1
  111. package/dist/components/ui/index.js +5 -3
  112. package/dist/components/ui/input-trigger.d.ts.map +1 -1
  113. package/dist/components/ui/input.d.ts.map +1 -1
  114. package/dist/components/ui/page-actions.d.ts +7 -1
  115. package/dist/components/ui/page-actions.d.ts.map +1 -1
  116. package/dist/components/ui/radio-group.d.ts.map +1 -1
  117. package/dist/components/ui/search-input.d.ts.map +1 -1
  118. package/dist/components/ui/select.d.ts.map +1 -1
  119. package/dist/components/ui/tab-navigation.d.ts +10 -1
  120. package/dist/components/ui/tab-navigation.d.ts.map +1 -1
  121. package/dist/components/ui/tag-search-input.d.ts.map +1 -1
  122. package/dist/components/ui/textarea.d.ts.map +1 -1
  123. package/dist/components/ui/truncate-text.d.ts +2 -2
  124. package/dist/components/ui/truncate-text.d.ts.map +1 -1
  125. package/dist/hooks/index.cjs +4 -2
  126. package/dist/hooks/index.cjs.map +1 -1
  127. package/dist/hooks/index.js +3 -1
  128. package/dist/hooks/ui/index.d.ts +1 -0
  129. package/dist/hooks/ui/index.d.ts.map +1 -1
  130. package/dist/hooks/ui/use-is-truncated.d.ts +33 -0
  131. package/dist/hooks/ui/use-is-truncated.d.ts.map +1 -0
  132. package/dist/index.cjs +8 -4
  133. package/dist/index.cjs.map +1 -1
  134. package/dist/index.js +7 -3
  135. package/dist/utils/index.cjs.map +1 -1
  136. package/dist/utils/index.js.map +1 -1
  137. package/package.json +1 -1
  138. package/src/components/chat/chat-message-enhanced.tsx +63 -52
  139. package/src/components/chat/guide-display.tsx +44 -0
  140. package/src/components/chat/hooks/use-nats-chat-adapter.ts +2 -0
  141. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +10 -0
  142. package/src/components/chat/index.ts +1 -0
  143. package/src/components/chat/types/component.types.ts +9 -0
  144. package/src/components/chat/types/message.types.ts +17 -1
  145. package/src/components/chat/types/processing.types.ts +1 -0
  146. package/src/components/chat/utils/chunk-parser.ts +6 -0
  147. package/src/components/chat/utils/message-segment-accumulator.ts +19 -0
  148. package/src/components/chat/utils/process-historical-messages.ts +6 -0
  149. package/src/components/features/select-button.tsx +4 -1
  150. package/src/components/layout/page-layout.tsx +16 -0
  151. package/src/components/layout/title-block.tsx +106 -7
  152. package/src/components/ui/.field-wrapper.md +5 -4
  153. package/src/components/ui/.tab-navigation.md +14 -1
  154. package/src/components/ui/autocomplete.tsx +13 -1
  155. package/src/components/ui/button/button.tsx +1 -1
  156. package/src/components/ui/checkbox-block.tsx +27 -3
  157. package/src/components/ui/date-picker.tsx +15 -1
  158. package/src/components/ui/field-wrapper.tsx +12 -6
  159. package/src/components/ui/floating-tooltip.tsx +9 -1
  160. package/src/components/ui/input-trigger.tsx +8 -1
  161. package/src/components/ui/input.tsx +8 -1
  162. package/src/components/ui/page-actions.tsx +142 -30
  163. package/src/components/ui/radio-group.tsx +28 -3
  164. package/src/components/ui/search-input.tsx +2 -1
  165. package/src/components/ui/select.tsx +7 -0
  166. package/src/components/ui/tab-navigation.tsx +26 -4
  167. package/src/components/ui/tag-search-input.tsx +9 -2
  168. package/src/components/ui/textarea.tsx +8 -1
  169. package/src/components/ui/truncate-text.tsx +3 -18
  170. package/src/hooks/ui/index.ts +1 -0
  171. package/src/hooks/ui/use-is-truncated.ts +66 -0
  172. package/src/stories/ChatMessageEnhanced.stories.tsx +48 -1
  173. package/dist/chunk-BIKOJ4FH.cjs.map +0 -1
  174. package/dist/chunk-GZK7FWJJ.js.map +0 -1
  175. package/dist/chunk-IN4IO7BL.cjs.map +0 -1
  176. package/dist/chunk-PB3BTZDU.js.map +0 -1
  177. package/dist/chunk-STYA77PH.cjs.map +0 -1
  178. package/dist/chunk-YMJZOANX.js.map +0 -1
  179. /package/dist/{chunk-5VSEMWWE.js.map → chunk-2YQK6LHR.js.map} +0 -0
  180. /package/dist/{chunk-W6ZUOWSB.js.map → chunk-35CTCUJ7.js.map} +0 -0
  181. /package/dist/{chunk-RW2LPL72.js.map → chunk-6WTCJI5R.js.map} +0 -0
  182. /package/dist/{chunk-X2YF5TJS.js.map → chunk-6XNWT2KC.js.map} +0 -0
  183. /package/dist/{chunk-FKPXFTZT.js.map → chunk-CZTKQVIQ.js.map} +0 -0
  184. /package/dist/{chunk-N4MTNTSX.js.map → chunk-GTMW4KY7.js.map} +0 -0
  185. /package/dist/{chunk-EU6QNO4S.js.map → chunk-IUGJAILN.js.map} +0 -0
  186. /package/dist/{chunk-B4YR342L.js.map → chunk-QF54RPHZ.js.map} +0 -0
  187. /package/dist/{chunk-TWGYBXYV.js.map → chunk-T4BUS6JV.js.map} +0 -0
  188. /package/dist/{chunk-BGWDDB7Y.js.map → chunk-U2JIGUXF.js.map} +0 -0
  189. /package/dist/{chunk-VL4LUO32.js.map → chunk-ZDUJ6BDI.js.map} +0 -0
  190. /package/dist/{chunk-K7MNWYYI.js.map → chunk-ZEC5U5T7.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.489",
3
+ "version": "0.0.490-snapshot.20260728181420",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -11,6 +11,7 @@ import { ApprovalBatchMessage } from "./approval-batch-message"
11
11
  import { ErrorMessageDisplay } from "./error-message-display"
12
12
  import { ContextCompactionDisplay } from "./context-compaction-display"
13
13
  import { ThinkingDisplay } from "./thinking-display"
14
+ import { GuideDisplay } from "./guide-display"
14
15
  import { SimpleMarkdownRenderer } from "../ui/simple-markdown-renderer"
15
16
  import type { ChatRef } from "./chat-ref.types"
16
17
  import { remarkCardLinks } from "./remark-card-links"
@@ -172,7 +173,12 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
172
173
  // duplicates so two siblings never collide on the same React key.
173
174
  const emittedBlockKeys = new Set<string>()
174
175
  segments.forEach((segment, segIdx) => {
175
- if (segment.type !== 'text') return
176
+ // Both markdown-bearing segment types take part: a `guide` body is
177
+ // authored by the same LLM and carries the same `[card://]` markers,
178
+ // so skipping it here left its markers with no `inlineByKey` entry and
179
+ // no hoisted card — the `<a card://>` override silently degraded them
180
+ // to a bare title / id.
181
+ if (segment.type !== 'text' && segment.type !== 'guide') return
176
182
  const text = segment.text
177
183
  const parts: SegmentPart[] = []
178
184
  let cursor = 0
@@ -384,6 +390,52 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
384
390
  }
385
391
  }, [hasMarkerSupport, hasMentionSupport, renderMention, chatRefs, renderingPlan, NavLinkAnchor])
386
392
 
393
+ /**
394
+ * Body of a markdown-bearing segment (`text` / `guide`).
395
+ *
396
+ * No block markers in this segment → a single SimpleMarkdownRenderer call
397
+ * (the vast majority of messages). Otherwise the text is split at each
398
+ * marker and block payloads are interleaved: each text chunk includes its
399
+ * trailing marker so the inline pill renders at the right position via the
400
+ * `<a>` override, and block payloads land AS SIBLINGS between text chunks —
401
+ * HTML-valid (block DOM never nests inside `<p>`) AND positionally correct
402
+ * (the block appears where the marker is in the flow, not at the segment's
403
+ * end). Stable React keys come from the card key (block) and chunk position
404
+ * (text); streaming token-by-token reuses the same React instances so
405
+ * `<Video>` doesn't remount mid-play.
406
+ */
407
+ const renderSegmentBody = (segIndex: number, text: string) => {
408
+ const parts = renderingPlan?.partsBySegment.get(segIndex)
409
+ if (!parts || parts.length === 0) {
410
+ return (
411
+ <SimpleMarkdownRenderer
412
+ content={text}
413
+ textSize="compact"
414
+ additionalRemarkPlugins={cardRemarkPlugins}
415
+ componentOverrides={cardComponentOverrides}
416
+ />
417
+ )
418
+ }
419
+ return parts.map((part, pIdx) => {
420
+ if (part.kind === 'text') {
421
+ return (
422
+ <SimpleMarkdownRenderer
423
+ key={`t-${pIdx}`}
424
+ content={part.text}
425
+ textSize="compact"
426
+ additionalRemarkPlugins={cardRemarkPlugins}
427
+ componentOverrides={cardComponentOverrides}
428
+ />
429
+ )
430
+ }
431
+ return (
432
+ <div key={`b-${part.key}`} className="my-3">
433
+ {part.node}
434
+ </div>
435
+ )
436
+ })
437
+ }
438
+
387
439
  const getAvatarProps = () => {
388
440
  const displayName = name || (isUser ? "User" : assistantType === 'mingo' ? "Mingo" : "Fae")
389
441
  const isMingo = assistantType === 'mingo'
@@ -488,60 +540,19 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
488
540
  {(!isSystem || segments.length > 0) && <div className="flex flex-col gap-2">
489
541
  {segments.map((segment, index) => {
490
542
  if (segment.type === 'text') {
491
- const parts = renderingPlan?.partsBySegment.get(index)
492
- const wrapperClass = cn(
493
- "min-w-0 w-full break-words text-h4",
494
- isError ? "text-ods-error" : "text-ods-text-primary",
495
- )
496
- // No block markers in this segment → single
497
- // SimpleMarkdownRenderer call (existing behaviour
498
- // preserved for the vast majority of messages).
499
- if (!parts || parts.length === 0) {
500
- return (
501
- <div key={index} className={wrapperClass}>
502
- <SimpleMarkdownRenderer
503
- content={segment.text}
504
- textSize="compact"
505
- additionalRemarkPlugins={cardRemarkPlugins}
506
- componentOverrides={cardComponentOverrides}
507
- />
508
- </div>
509
- )
510
- }
511
- // Block markers present → split text at each marker
512
- // and interleave block payloads. Each text chunk
513
- // includes its trailing marker so the inline pill
514
- // renders at the right position via the `<a>`
515
- // override. Block payloads land AS SIBLINGS between
516
- // text chunks — HTML-valid (block DOM never nests
517
- // inside `<p>`) AND positionally correct (block
518
- // appears where the marker is in the flow, not at
519
- // the segment's end). Stable React keys come from
520
- // the card key (block) and chunk position (text);
521
- // streaming token-by-token reuses the same React
522
- // instances so `<Video>` doesn't remount mid-play.
523
543
  return (
524
- <div key={index} className={wrapperClass}>
525
- {parts.map((part, pIdx) => {
526
- if (part.kind === 'text') {
527
- return (
528
- <SimpleMarkdownRenderer
529
- key={`t-${pIdx}`}
530
- content={part.text}
531
- textSize="compact"
532
- additionalRemarkPlugins={cardRemarkPlugins}
533
- componentOverrides={cardComponentOverrides}
534
- />
535
- )
536
- }
537
- return (
538
- <div key={`b-${part.key}`} className="my-3">
539
- {part.node}
540
- </div>
541
- )
542
- })}
544
+ <div
545
+ key={index}
546
+ className={cn(
547
+ "min-w-0 w-full break-words text-h4",
548
+ isError ? "text-ods-error" : "text-ods-text-primary",
549
+ )}
550
+ >
551
+ {renderSegmentBody(index, segment.text)}
543
552
  </div>
544
553
  )
554
+ } else if (segment.type === 'guide') {
555
+ return <GuideDisplay key={index}>{renderSegmentBody(index, segment.text)}</GuideDisplay>
545
556
  } else if (segment.type === 'tool_execution') {
546
557
  return (
547
558
  <ToolExecutionDisplay
@@ -0,0 +1,44 @@
1
+ "use client"
2
+
3
+ import { forwardRef } from "react"
4
+
5
+ import { cn } from "../../utils/cn"
6
+ import { CompassIcon } from "../icons-v2-generated"
7
+ import type { GuideDisplayProps } from "./types"
8
+
9
+ const GUIDE_LABEL = "OpenFrame Guide"
10
+
11
+ /**
12
+ * `GUIDE` segment — the assistant's how-to/documentation answer, framed as a
13
+ * titled card instead of a bare paragraph.
14
+ *
15
+ * Chrome only: the caller renders the markdown body and passes it as children,
16
+ * so a guide goes through the SAME body pipeline as a text segment
17
+ * (`[card://]` splitting + hoisted entity cards + mention chips). Rendering the
18
+ * raw markdown here instead would bypass that plan and degrade every card
19
+ * marker inside a guide to a bare title.
20
+ */
21
+ const GuideDisplay = forwardRef<HTMLDivElement, GuideDisplayProps>(
22
+ ({ className, children, ...props }, ref) => {
23
+ return (
24
+ <div
25
+ ref={ref}
26
+ className={cn(
27
+ "flex flex-col gap-[var(--spacing-system-m)] rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-m)]",
28
+ className
29
+ )}
30
+ {...props}
31
+ >
32
+ <div className="flex items-center gap-[var(--spacing-system-xs)]">
33
+ <CompassIcon size={16} className="shrink-0 text-ods-open-yellow" />
34
+ <span className="text-h5 text-ods-open-yellow">{GUIDE_LABEL}</span>
35
+ </div>
36
+ <div className="min-w-0 w-full break-words text-h4 text-ods-text-primary">{children}</div>
37
+ </div>
38
+ )
39
+ }
40
+ )
41
+
42
+ GuideDisplay.displayName = "GuideDisplay"
43
+
44
+ export { GuideDisplay }
@@ -442,6 +442,8 @@ export function appendToTrailingAssistant(
442
442
  merged[merged.length - 1] = { type: 'text', text: tail.text + seg.text }
443
443
  } else if (seg.type === 'thinking' && tail?.type === 'thinking') {
444
444
  merged[merged.length - 1] = { type: 'thinking', text: tail.text + seg.text }
445
+ } else if (seg.type === 'guide' && tail?.type === 'guide') {
446
+ merged[merged.length - 1] = { type: 'guide', text: tail.text + seg.text }
445
447
  } else if (seg.type === 'approval_batch') {
446
448
  // Approval deltas must be IDEMPOTENT: the escalated-result emit can be
447
449
  // seen twice (live + catch-up replay over hydrated history), so upsert
@@ -173,6 +173,16 @@ export function useRealtimeChunkProcessor(
173
173
  break
174
174
  }
175
175
 
176
+ case 'guide': {
177
+ const segments = accumulator.appendGuide(action.text)
178
+ if (isInStreamRef.current || !hasEverStreamedRef.current) {
179
+ emitSegments(segments)
180
+ } else {
181
+ emitSegments([{ type: 'guide', text: action.text }], { append: true })
182
+ }
183
+ break
184
+ }
185
+
176
186
  case 'tool_execution': {
177
187
  // A starting tool run means the agent's turn is in progress even
178
188
  // when this lands after MESSAGE_END (approved commands execute
@@ -6,6 +6,7 @@ export * from './approval-batch-message'
6
6
  export * from './context-compaction-display'
7
7
  export * from './expand-chevron'
8
8
  export * from './thinking-display'
9
+ export * from './guide-display'
9
10
  export * from './error-message-display'
10
11
  export * from './chat-container'
11
12
  export * from './chat-input'
@@ -556,6 +556,15 @@ export interface ThinkingDisplayProps extends HTMLAttributes<HTMLDivElement> {
556
556
  isStreaming?: boolean
557
557
  }
558
558
 
559
+ // ========== Guide Display Props ==========
560
+
561
+ export interface GuideDisplayProps extends HTMLAttributes<HTMLDivElement> {
562
+ /** Rendered guide body. `ChatMessageEnhanced` passes the SAME markdown
563
+ * output a text segment gets, so `[card://]` cards and mention chips
564
+ * behave identically inside a guide. */
565
+ children?: ReactNode
566
+ }
567
+
559
568
  // ========== Model Display Props ==========
560
569
 
561
570
  export interface ModelDisplayProps extends HTMLAttributes<HTMLDivElement> {
@@ -10,6 +10,7 @@ import type { AssistantType, AuthorType, ChatApprovalStatus, MessageOwner } from
10
10
  export const MESSAGE_TYPE = {
11
11
  TEXT: 'TEXT',
12
12
  THINKING: 'THINKING',
13
+ GUIDE: 'GUIDE',
13
14
  EXECUTING_TOOL: 'EXECUTING_TOOL',
14
15
  EXECUTED_TOOL: 'EXECUTED_TOOL',
15
16
  APPROVAL_REQUEST: 'APPROVAL_REQUEST',
@@ -170,6 +171,15 @@ export type ThinkingSegment = {
170
171
  text: string
171
172
  }
172
173
 
174
+ /** Guide answer body — the assistant's how-to/documentation reply, rendered as
175
+ * a titled "OpenFrame Guide" card instead of a bare paragraph. `text` is
176
+ * markdown, streamed in fragments like a `text` segment and coalesced by the
177
+ * accumulator. */
178
+ export type GuideSegment = {
179
+ type: 'guide'
180
+ text: string
181
+ }
182
+
173
183
  export type ToolExecutionSegment = {
174
184
  type: 'tool_execution'
175
185
  data: ToolExecutionData
@@ -208,7 +218,7 @@ export type ContextCompactionSegment = {
208
218
  summary?: string
209
219
  }
210
220
 
211
- export type MessageSegment = TextSegment | ThinkingSegment | ToolExecutionSegment | ApprovalRequestSegment | ApprovalBatchSegment | ErrorSegment | ContextCompactionSegment
221
+ export type MessageSegment = TextSegment | ThinkingSegment | GuideSegment | ToolExecutionSegment | ApprovalRequestSegment | ApprovalBatchSegment | ErrorSegment | ContextCompactionSegment
212
222
 
213
223
  export type MessageContent = string | MessageSegment[]
214
224
 
@@ -228,6 +238,11 @@ export interface ThinkingMessageData extends MessageDataBase {
228
238
  text?: string
229
239
  }
230
240
 
241
+ export interface GuideMessageData extends MessageDataBase {
242
+ type: 'GUIDE'
243
+ text?: string
244
+ }
245
+
231
246
  export interface ExecutingToolMessageData extends MessageDataBase {
232
247
  type: 'EXECUTING_TOOL'
233
248
  integratedToolType?: string
@@ -309,6 +324,7 @@ export interface ContextCompactionEndMessageData extends MessageDataBase {
309
324
  export type MessageData =
310
325
  | TextMessageData
311
326
  | ThinkingMessageData
327
+ | GuideMessageData
312
328
  | ExecutingToolMessageData
313
329
  | ExecutedToolMessageData
314
330
  | ApprovalRequestMessageData
@@ -16,6 +16,7 @@ export type ParsedChunkAction =
16
16
  | { action: 'metadata'; modelDisplayName: string; modelName: string; providerName: string; contextWindow: number }
17
17
  | { action: 'text'; text: string }
18
18
  | { action: 'thinking'; text: string }
19
+ | { action: 'guide'; text: string }
19
20
  | { action: 'tool_execution'; segment: ToolExecutionSegment }
20
21
  | { action: 'approval_request'; requestId: string; command: string; explanation?: string; approvalType: string }
21
22
  | { action: 'approval_batch'; requestId: string; approvalType: string; toolCalls: PendingToolCallData[] }
@@ -65,6 +65,12 @@ export function parseChunkToAction(chunk: unknown): ParsedChunkAction | null {
65
65
  }
66
66
  return null
67
67
 
68
+ case MESSAGE_TYPE.GUIDE:
69
+ if (typeof data.text === 'string') {
70
+ return { action: 'guide', text: data.text }
71
+ }
72
+ return null
73
+
68
74
  case MESSAGE_TYPE.EXECUTING_TOOL:
69
75
  return {
70
76
  action: 'tool_execution',
@@ -139,6 +139,22 @@ export class MessageSegmentAccumulator {
139
139
  return this.getSegments()
140
140
  }
141
141
 
142
+ /**
143
+ * Append guide text to the current message.
144
+ * If the last segment is guide, append to it; otherwise start a new guide segment.
145
+ */
146
+ appendGuide(text: string): MessageSegment[] {
147
+ const lastSegment = this.segments[this.segments.length - 1]
148
+
149
+ if (lastSegment && lastSegment.type === 'guide') {
150
+ this.segments[this.segments.length - 1] = { type: 'guide', text: lastSegment.text + text }
151
+ } else {
152
+ this.segments.push({ type: 'guide', text })
153
+ }
154
+
155
+ return this.getSegments()
156
+ }
157
+
142
158
  /**
143
159
  * Add a tool execution segment.
144
160
  *
@@ -516,6 +532,9 @@ export class MessageSegmentAccumulator {
516
532
  case 'thinking':
517
533
  if (segment.text) this.appendThinking(segment.text)
518
534
  break
535
+ case 'guide':
536
+ if (segment.text) this.appendGuide(segment.text)
537
+ break
519
538
  case 'tool_execution':
520
539
  this.addToolExecution(segment)
521
540
  break
@@ -275,6 +275,12 @@ function processMessageData(
275
275
  }
276
276
  break
277
277
 
278
+ case MESSAGE_TYPE.GUIDE:
279
+ if ('text' in data && data.text) {
280
+ accumulator.appendGuide(data.text)
281
+ }
282
+ break
283
+
278
284
  case MESSAGE_TYPE.EXECUTING_TOOL:
279
285
  if ('integratedToolType' in data) {
280
286
  accumulator.addToolExecution({
@@ -36,9 +36,12 @@ export const SelectButton = React.forwardRef<HTMLButtonElement, SelectButtonProp
36
36
  "font-body text-left w-full",
37
37
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ods-focus",
38
38
  "disabled:pointer-events-none disabled:opacity-50",
39
+ // Hover / active move the BACKGROUND only, like Input — the accent
40
+ // border is reserved for the selected state, so an unselected button
41
+ // no longer flashes "selected" under the cursor.
39
42
  selected
40
43
  ? "bg-ods-open-yellow-light border-ods-accent hover:bg-ods-open-yellow-light-hover active:bg-ods-open-yellow-light-action"
41
- : "bg-ods-card border-ods-border hover:bg-ods-bg-hover hover:border-ods-accent active:bg-ods-bg-active active:border-ods-accent",
44
+ : "bg-ods-card border-ods-border hover:bg-ods-bg-hover active:bg-ods-bg-active",
42
45
  className,
43
46
  )}
44
47
  >
@@ -36,6 +36,12 @@
36
36
  * `titleWrap` prop, forwarded to `TitleBlock`. Defaults to `false` (frozen
37
37
  * single-line truncate). Content detail pages with CMS titles pass it to let
38
38
  * long titles wrap instead of ellipsis-clip. Additive + default-preserving.
39
+ *
40
+ * SANCTIONED EXCEPTION (2026-07, explicit human sign-off): the OPTIONAL
41
+ * `subtitleRow` prop, forwarded to `TitleBlock`. Defaults to `'when-set'` (the
42
+ * subtitle row renders only when there is text for it). Pages whose subtitle is
43
+ * optional record data use it to choose how the row behaves while loading.
44
+ * Additive + default-preserving.
39
45
  * ========================================================================== */
40
46
 
41
47
  import React from 'react'
@@ -68,6 +74,12 @@ export interface PageLayoutProps {
68
74
  /** When true, the title/subtitle render as line-box-accurate skeleton bars (forwarded to
69
75
  * `TitleBlock`). Header height stays identical to the loaded state — for page skeletons. */
70
76
  loading?: boolean
77
+ /** Render the header ACTIONS as placeholders (their set depends on data still loading). */
78
+ loadingActions?: boolean
79
+ /** When the subtitle row occupies the layout (forwarded to `TitleBlock`):
80
+ * `'when-set'` (default), `'while-loading'` (skeleton bar while loading, collapses
81
+ * if the record has no subtitle), or `'always'` (never collapses). */
82
+ subtitleRow?: 'when-set' | 'while-loading' | 'always'
71
83
  /** When true, a long title wraps onto multiple lines instead of the frozen single-line
72
84
  * ellipsis clamp (forwarded to `TitleBlock`). For content detail pages whose h1 is CMS
73
85
  * data of arbitrary length. Additive + default-preserving. */
@@ -96,6 +108,8 @@ export function PageLayout({
96
108
  titleSize,
97
109
  titleAdornment,
98
110
  loading,
111
+ loadingActions,
112
+ subtitleRow,
99
113
  titleWrap,
100
114
  }: PageLayoutProps) {
101
115
  const hasActions = actions && actions.length > 0
@@ -118,6 +132,8 @@ export function PageLayout({
118
132
  titleSize={titleSize}
119
133
  titleAdornment={titleAdornment}
120
134
  loading={loading}
135
+ loadingActions={loadingActions}
136
+ subtitleRow={subtitleRow}
121
137
  titleWrap={titleWrap}
122
138
  />
123
139
  )}
@@ -40,6 +40,14 @@
40
40
  * byte-identical. Do NOT change defaults or touch anything else here.
41
41
  *
42
42
  * SANCTIONED EXCEPTION (2026-07, explicit human sign-off): the OPTIONAL
43
+ * `subtitleRow` prop. Defaults to `'when-set'` — the subtitle row keeps
44
+ * rendering only when `subtitle` is truthy, so every existing caller is
45
+ * byte-identical. The other two values decide how a page trades a header that
46
+ * never moves against a header that never shows an empty line; see the prop's
47
+ * own doc for which to pick. Additive + default-preserving; do NOT change the
48
+ * default.
49
+ *
50
+ * SANCTIONED EXCEPTION (2026-07, explicit human sign-off): the OPTIONAL
43
51
  * `titleWrap` prop. Defaults to `false` — the frozen single-line `truncate`
44
52
  * baseline is unchanged for every existing caller. Content DETAIL pages (whose
45
53
  * h1 is CMS data of arbitrary length — releases, legal docs, FAQ docs, dev
@@ -58,12 +66,27 @@
58
66
  * so its natural width drives the wrap while `truncate` still clamps a title
59
67
  * that alone exceeds the container. The mobile (base) layout is untouched.
60
68
  * This is the new frozen baseline; do NOT re-introduce breakpoint stacking.
69
+ *
70
+ * SANCTIONED EXCEPTION (2026-07, explicit human sign-off): overflow tooltips on
71
+ * the title and subtitle. This one CHANGES the baseline for every caller: the
72
+ * native `title=""` attribute on both is replaced by the ODS `FloatingTooltip`,
73
+ * armed only when the text is genuinely clipped (`useIsTruncated`). Both stay
74
+ * single-line — that is the design, and the tooltip is what makes a clipped
75
+ * title readable instead of merely pretty. Nothing is lost for assistive tech:
76
+ * `text-overflow: ellipsis` never hid the text from the accessibility tree, so
77
+ * the full string was always exposed; the attribute only ever served a mouse.
78
+ * The wrapper `div` the tooltip needs goes OUTSIDE the `h1`/`p` — never inside:
79
+ * a `div` inside a `p` is auto-closed by the HTML parser, which would split the
80
+ * subtitle paragraph and desync hydration. The `titleWrap` branch is untouched
81
+ * (wrapped text never clips, so it never needs a tooltip).
61
82
  * ========================================================================== */
62
83
 
63
84
  import React from 'react'
85
+ import { useIsTruncated } from '../../hooks/ui/use-is-truncated'
64
86
  import { cn } from '../../utils/cn'
65
87
  import type { ActionsMenuGroup } from '../ui/actions-menu'
66
88
  import { EntityImage } from '../ui/entity-image'
89
+ import { FloatingTooltip } from '../ui/floating-tooltip'
67
90
  import { PageActions, type PageActionButton } from '../ui/page-actions'
68
91
  import { BackButton } from './back-button'
69
92
 
@@ -106,6 +129,28 @@ export interface TitleBlockProps {
106
129
  * (typography + surrounding markup unchanged, so header height is identical to loaded).
107
130
  * Additive + default-preserving: omit it and every existing caller is byte-identical. */
108
131
  loading?: boolean
132
+ /**
133
+ * Render the ACTIONS as placeholders too. Separate from `loading` on purpose:
134
+ * a page whose action set is already final (it depends on the route, not on
135
+ * the record) keeps showing its real buttons while the title loads.
136
+ */
137
+ loadingActions?: boolean
138
+ /**
139
+ * When the subtitle row occupies the layout. The subtitle is often optional
140
+ * RECORD data — unknown while loading, sometimes absent afterwards — and there
141
+ * is no setting that both fills the loading header and never leaves a blank
142
+ * line, so the caller picks which one matters.
143
+ *
144
+ * - `'when-set'` (default) — only when `subtitle` has text.
145
+ * - `'while-loading'` — also during `loading`, as a skeleton bar. The header
146
+ * looks complete while it loads, and the row collapses if the record turns
147
+ * out to have no subtitle (so that case settles one line shorter).
148
+ * - `'always'` — the row never collapses; an empty subtitle holds its line
149
+ * with an invisible spacer. The header cannot change height. Use it when the
150
+ * loaded page ALWAYS has a subtitle — typically a standalone skeleton
151
+ * component matching a page whose subtitle is guaranteed.
152
+ */
153
+ subtitleRow?: 'when-set' | 'while-loading' | 'always'
109
154
  /** When true, a long title WRAPS onto multiple lines instead of the frozen single-line
110
155
  * ellipsis clamp. For content detail pages whose h1 is CMS data of arbitrary length.
111
156
  * Additive + default-preserving: omit it and every existing caller is byte-identical. */
@@ -129,6 +174,22 @@ function TitleTextSkeleton({ widthClass, heightClass }: { widthClass: string; he
129
174
  )
130
175
  }
131
176
 
177
+ /**
178
+ * Bar geometry per title size, following the ODS type scale across breakpoints
179
+ * (`md` = 800px, `lg` = 1280px here — the SAME widths the typography tokens
180
+ * switch at, so bar and text step together).
181
+ *
182
+ * `h2` needs no `lg` step because `text-h2` has none: it is 24/32 on mobile and
183
+ * 32/40 from tablet up. `text-h1` scales at both breakpoints (40 → 48 → 56), so
184
+ * its bar does too — reusing the h2 bar there would leave a stamp-sized smudge
185
+ * inside a 64px line. Heights stay well under the line box's ascent on purpose;
186
+ * see `TitleTextSkeleton` for why that is what keeps the header height honest.
187
+ */
188
+ const TITLE_SKELETON_SIZE = {
189
+ h1: { width: 'w-56 md:w-80 lg:w-96', height: 'h-7 md:h-8 lg:h-9' },
190
+ h2: { width: 'w-48 md:w-72', height: 'h-4 md:h-6' },
191
+ } as const
192
+
132
193
  export function TitleBlock({
133
194
  title,
134
195
  subtitle,
@@ -143,14 +204,28 @@ export function TitleBlock({
143
204
  titleSize = 'h2',
144
205
  titleAdornment,
145
206
  loading,
207
+ loadingActions,
208
+ subtitleRow = 'when-set',
146
209
  titleWrap = false,
147
210
  }: TitleBlockProps) {
211
+ const hasSubtitleRow =
212
+ !!subtitle || subtitleRow === 'always' || (subtitleRow === 'while-loading' && !!loading)
148
213
  const hasActions = actions && actions.length > 0
149
214
  const hasMenuActions = !!menuActions && menuActions.some(g => g.items.length > 0)
150
215
  const titleClass = titleSize === 'h1' ? 'text-h1' : 'text-h2'
151
216
  // Frozen baseline is the single-line `truncate`; `titleWrap` swaps it for
152
217
  // multi-line wrapping (break-words guards pathological unbroken tokens).
153
218
  const titleOverflowClass = titleWrap ? 'break-words' : 'truncate'
219
+ const skeletonSize = TITLE_SKELETON_SIZE[titleSize]
220
+
221
+ // Exactly one title element renders per pass, so one ref covers every branch.
222
+ // The tooltip arms itself only on real clipping — repeating a fully visible
223
+ // title back to the user is noise, and `titleWrap`/loading never clip at all.
224
+ const { ref: titleRef, isTruncated: titleTruncated } = useIsTruncated<HTMLHeadingElement>(loading ? null : title)
225
+ const { ref: subtitleRef, isTruncated: subtitleTruncated } = useIsTruncated<HTMLParagraphElement>(
226
+ loading ? null : subtitle,
227
+ )
228
+ const titleNode = loading ? <TitleTextSkeleton widthClass={skeletonSize.width} heightClass={skeletonSize.height} /> : title
154
229
 
155
230
  return (
156
231
  <div
@@ -183,7 +258,7 @@ export function TitleBlock({
183
258
  className="hidden md:inline-flex"
184
259
  />
185
260
  )}
186
- {(image || subtitle || loading) ? (
261
+ {(image || hasSubtitleRow || loading) ? (
187
262
  <div className="flex items-center gap-[var(--spacing-system-m)] min-w-0 w-full">
188
263
  {image && (
189
264
  <EntityImage
@@ -196,15 +271,32 @@ export function TitleBlock({
196
271
  {(loading || title) && (
197
272
  titleAdornment ? (
198
273
  <div className="flex items-center gap-[var(--spacing-system-m)] min-w-0 w-full">
199
- <h1 className={cn(titleClass, 'text-ods-text-primary min-w-0', titleOverflowClass)} title={title}>{loading ? <TitleTextSkeleton widthClass="w-48 md:w-72" heightClass="h-4 md:h-6" /> : title}</h1>
274
+ {/* `min-w-0` moves onto the tooltip's wrapper: the wrapper is
275
+ now the flex item, and without it the row refuses to
276
+ shrink and pushes the adornment out. */}
277
+ <FloatingTooltip content={title} side="bottom" disabled={!titleTruncated} triggerClassName="min-w-0">
278
+ <h1 ref={titleRef} className={cn(titleClass, 'text-ods-text-primary min-w-0', titleOverflowClass)}>{titleNode}</h1>
279
+ </FloatingTooltip>
200
280
  <span className="shrink-0">{titleAdornment}</span>
201
281
  </div>
202
282
  ) : (
203
- <h1 className={cn(titleClass, 'text-ods-text-primary', titleOverflowClass)} title={title}>{loading ? <TitleTextSkeleton widthClass="w-48 md:w-72" heightClass="h-4 md:h-6" /> : title}</h1>
283
+ <FloatingTooltip content={title} side="bottom" disabled={!titleTruncated}>
284
+ <h1 ref={titleRef} className={cn(titleClass, 'text-ods-text-primary', titleOverflowClass)}>{titleNode}</h1>
285
+ </FloatingTooltip>
204
286
  )
205
287
  )}
206
- {subtitle && (
207
- <p className="text-h6 text-ods-text-secondary truncate" title={subtitle}>{loading ? <TitleTextSkeleton widthClass="w-28 md:w-36" heightClass="h-2.5 md:h-3" /> : subtitle}</p>
288
+ {hasSubtitleRow && (
289
+ /* The NBSP is a pure spacer, reached only in `'always'` mode: an
290
+ empty `p` collapses to zero height (its only content would be
291
+ collapsible whitespace), which is exactly the shift that mode
292
+ exists to prevent. Hidden from AT — it carries no meaning. */
293
+ <FloatingTooltip content={subtitle} side="bottom" disabled={!subtitleTruncated}>
294
+ <p
295
+ ref={subtitleRef}
296
+ className="text-h6 text-ods-text-secondary truncate"
297
+ aria-hidden={!loading && !subtitle ? true : undefined}
298
+ >{loading ? <TitleTextSkeleton widthClass="w-28 md:w-36" heightClass="h-2.5 md:h-3" /> : (subtitle || '\u00A0')}</p>
299
+ </FloatingTooltip>
208
300
  )}
209
301
  </div>
210
302
  </div>
@@ -212,7 +304,9 @@ export function TitleBlock({
212
304
  title && (
213
305
  titleAdornment ? (
214
306
  <div className="flex items-center gap-[var(--spacing-system-m)] min-w-0 w-full">
215
- <h1 className={cn(titleClass, 'text-ods-text-primary min-w-0', titleOverflowClass)} title={title}>{title}</h1>
307
+ <FloatingTooltip content={title} side="bottom" disabled={!titleTruncated} triggerClassName="min-w-0">
308
+ <h1 ref={titleRef} className={cn(titleClass, 'text-ods-text-primary min-w-0', titleOverflowClass)}>{title}</h1>
309
+ </FloatingTooltip>
216
310
  <span className="shrink-0">{titleAdornment}</span>
217
311
  </div>
218
312
  ) : (
@@ -226,13 +320,18 @@ export function TitleBlock({
226
320
  )}
227
321
  </div>
228
322
 
229
- {(hasActions || hasMenuActions || selector) && (
323
+ {/* `loadingActions` opens this gate on its own: the case the flag exists for
324
+ is a page that does not YET know its action set, which is exactly the
325
+ page that passes `actions={[]}` — gating on `hasActions` alone would
326
+ render nothing and then pop the real buttons in. */}
327
+ {(hasActions || hasMenuActions || selector || loadingActions) && (
230
328
  <div className="flex gap-2 items-center shrink-0">
231
329
  <PageActions
232
330
  variant={actionsVariant}
233
331
  actions={actions ?? []}
234
332
  menuActions={menuActions}
235
333
  selector={selector}
334
+ loading={loadingActions}
236
335
  />
237
336
  </div>
238
337
  )}