@flamingo-stack/openframe-frontend-core 0.0.420 → 0.0.421-snapshot.20260713170448

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 (113) hide show
  1. package/dist/{chunk-WBLTGO7G.cjs → chunk-2JNFQEVX.cjs} +26 -26
  2. package/dist/{chunk-WBLTGO7G.cjs.map → chunk-2JNFQEVX.cjs.map} +1 -1
  3. package/dist/{chunk-T55A3BQD.js → chunk-3BGWK5QI.js} +4 -4
  4. package/dist/{chunk-ZZFLE5LG.js → chunk-4BWQYGIY.js} +2 -2
  5. package/dist/{chunk-KEWTMPHB.js → chunk-4TMMYZTY.js} +2 -2
  6. package/dist/{chunk-U7EUN5FZ.js → chunk-6F5OKJTW.js} +4 -4
  7. package/dist/{chunk-KLMLQ35Y.cjs → chunk-A2IRUIUJ.cjs} +37 -37
  8. package/dist/{chunk-KLMLQ35Y.cjs.map → chunk-A2IRUIUJ.cjs.map} +1 -1
  9. package/dist/{chunk-KPLRIPEH.cjs → chunk-B62DI2SC.cjs} +15 -15
  10. package/dist/{chunk-KPLRIPEH.cjs.map → chunk-B62DI2SC.cjs.map} +1 -1
  11. package/dist/{chunk-PUXTMLKC.cjs → chunk-BF5BXKM7.cjs} +62 -62
  12. package/dist/{chunk-PUXTMLKC.cjs.map → chunk-BF5BXKM7.cjs.map} +1 -1
  13. package/dist/{chunk-PAF4EFRR.js → chunk-DUHM44CQ.js} +2 -2
  14. package/dist/{chunk-YYF2AYM3.js → chunk-HFK33T6A.js} +4 -4
  15. package/dist/{chunk-J7WFE7WL.js → chunk-JSL3VXGJ.js} +2 -2
  16. package/dist/{chunk-BXXXBZYY.cjs → chunk-KUDLV25M.cjs} +190 -154
  17. package/dist/chunk-KUDLV25M.cjs.map +1 -0
  18. package/dist/{chunk-OBMYKFBO.js → chunk-LHM2VUTO.js} +2 -2
  19. package/dist/{chunk-AMUTZJZX.cjs → chunk-LWC2U5OC.cjs} +9 -9
  20. package/dist/{chunk-AMUTZJZX.cjs.map → chunk-LWC2U5OC.cjs.map} +1 -1
  21. package/dist/{chunk-2AN4EHN6.cjs → chunk-O3FSSWV4.cjs} +7 -7
  22. package/dist/{chunk-2AN4EHN6.cjs.map → chunk-O3FSSWV4.cjs.map} +1 -1
  23. package/dist/{chunk-PHJ6335O.cjs → chunk-PO4PXWZN.cjs} +5 -5
  24. package/dist/{chunk-PHJ6335O.cjs.map → chunk-PO4PXWZN.cjs.map} +1 -1
  25. package/dist/{chunk-ORDDVXDS.cjs → chunk-RGKGRS2T.cjs} +59 -59
  26. package/dist/{chunk-ORDDVXDS.cjs.map → chunk-RGKGRS2T.cjs.map} +1 -1
  27. package/dist/{chunk-5DTE27A4.cjs → chunk-ULUH2X53.cjs} +11 -11
  28. package/dist/{chunk-5DTE27A4.cjs.map → chunk-ULUH2X53.cjs.map} +1 -1
  29. package/dist/{chunk-NIS5VXZJ.cjs → chunk-WAQH35VG.cjs} +26 -26
  30. package/dist/{chunk-NIS5VXZJ.cjs.map → chunk-WAQH35VG.cjs.map} +1 -1
  31. package/dist/{chunk-G4W5D7L4.js → chunk-WQEEGFDM.js} +2 -2
  32. package/dist/{chunk-GC4355MW.js → chunk-XFYQV2K2.js} +2 -2
  33. package/dist/{chunk-3AJOKFJ4.js → chunk-YJDVAT7J.js} +66 -30
  34. package/dist/chunk-YJDVAT7J.js.map +1 -0
  35. package/dist/{chunk-4OZLRBOH.js → chunk-YUMFRC3D.js} +4 -4
  36. package/dist/{chunk-NDYJ37LY.cjs → chunk-YZQNKMFQ.cjs} +31 -31
  37. package/dist/{chunk-NDYJ37LY.cjs.map → chunk-YZQNKMFQ.cjs.map} +1 -1
  38. package/dist/components/case-studies/index.cjs +8 -8
  39. package/dist/components/case-studies/index.js +2 -2
  40. package/dist/components/chat/chat-input.d.ts.map +1 -1
  41. package/dist/components/chat/embeddable-chat.d.ts +9 -0
  42. package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
  43. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  44. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +1 -1
  45. package/dist/components/chat/index.cjs +2 -2
  46. package/dist/components/chat/index.js +1 -1
  47. package/dist/components/chat/types/api.types.d.ts +10 -0
  48. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  49. package/dist/components/contact/index.cjs +3 -3
  50. package/dist/components/contact/index.js +2 -2
  51. package/dist/components/docs/index.cjs +5 -5
  52. package/dist/components/docs/index.js +4 -4
  53. package/dist/components/embeds/index.cjs +3 -3
  54. package/dist/components/embeds/index.js +2 -2
  55. package/dist/components/faq/index.cjs +3 -3
  56. package/dist/components/faq/index.js +2 -2
  57. package/dist/components/features/index.cjs +2 -2
  58. package/dist/components/features/index.js +1 -1
  59. package/dist/components/help-center-pages/index.cjs +20 -20
  60. package/dist/components/help-center-pages/index.js +11 -11
  61. package/dist/components/index.cjs +128 -128
  62. package/dist/components/index.js +9 -9
  63. package/dist/components/navigation/index.cjs +2 -2
  64. package/dist/components/navigation/index.js +1 -1
  65. package/dist/components/onboarding-guides/index.cjs +5 -5
  66. package/dist/components/onboarding-guides/index.js +4 -4
  67. package/dist/components/related-content/index.cjs +3 -3
  68. package/dist/components/related-content/index.js +2 -2
  69. package/dist/components/tabs.d.ts +10 -0
  70. package/dist/components/tabs.d.ts.map +1 -1
  71. package/dist/components/tickets/index.cjs +5 -5
  72. package/dist/components/tickets/index.js +4 -4
  73. package/dist/components/ui/dashboard-info-card.d.ts +26 -12
  74. package/dist/components/ui/dashboard-info-card.d.ts.map +1 -1
  75. package/dist/components/ui/index.cjs +2 -2
  76. package/dist/components/ui/index.js +1 -1
  77. package/dist/components/ui/tab-navigation.d.ts.map +1 -1
  78. package/dist/components/ui/tabs.d.ts +11 -0
  79. package/dist/components/ui/tabs.d.ts.map +1 -1
  80. package/dist/index.cjs +2 -2
  81. package/dist/index.js +1 -1
  82. package/package.json +1 -1
  83. package/src/components/chat/chat-input.tsx +5 -1
  84. package/src/components/chat/embeddable-chat.tsx +17 -3
  85. package/src/components/chat/hooks/use-nats-chat-adapter.ts +52 -3
  86. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +25 -3
  87. package/src/components/chat/types/api.types.ts +10 -0
  88. package/src/components/tabs.tsx +10 -0
  89. package/src/components/ui/dashboard-info-card.tsx +70 -45
  90. package/src/components/ui/tab-navigation.tsx +5 -2
  91. package/src/components/ui/tabs.tsx +11 -0
  92. package/src/stories/ApprovalRequestMessageClient.stories.tsx +89 -0
  93. package/src/stories/ChatMessageEnhanced.stories.tsx +219 -0
  94. package/src/stories/ChatTypingIndicator.stories.tsx +51 -0
  95. package/src/stories/ContextCompactionDisplay.stories.tsx +44 -0
  96. package/src/stories/DashboardInfoCard.stories.tsx +94 -9
  97. package/src/stories/EmbeddableChat.stories.tsx +159 -0
  98. package/src/stories/ErrorMessageDisplay.stories.tsx +66 -0
  99. package/src/stories/ThinkingDisplay.stories.tsx +63 -0
  100. package/src/stories/TicketCard.stories.tsx +245 -0
  101. package/dist/chunk-3AJOKFJ4.js.map +0 -1
  102. package/dist/chunk-BXXXBZYY.cjs.map +0 -1
  103. /package/dist/{chunk-T55A3BQD.js.map → chunk-3BGWK5QI.js.map} +0 -0
  104. /package/dist/{chunk-ZZFLE5LG.js.map → chunk-4BWQYGIY.js.map} +0 -0
  105. /package/dist/{chunk-KEWTMPHB.js.map → chunk-4TMMYZTY.js.map} +0 -0
  106. /package/dist/{chunk-U7EUN5FZ.js.map → chunk-6F5OKJTW.js.map} +0 -0
  107. /package/dist/{chunk-PAF4EFRR.js.map → chunk-DUHM44CQ.js.map} +0 -0
  108. /package/dist/{chunk-YYF2AYM3.js.map → chunk-HFK33T6A.js.map} +0 -0
  109. /package/dist/{chunk-J7WFE7WL.js.map → chunk-JSL3VXGJ.js.map} +0 -0
  110. /package/dist/{chunk-OBMYKFBO.js.map → chunk-LHM2VUTO.js.map} +0 -0
  111. /package/dist/{chunk-G4W5D7L4.js.map → chunk-WQEEGFDM.js.map} +0 -0
  112. /package/dist/{chunk-GC4355MW.js.map → chunk-XFYQV2K2.js.map} +0 -0
  113. /package/dist/{chunk-4OZLRBOH.js.map → chunk-YUMFRC3D.js.map} +0 -0
@@ -80,6 +80,12 @@ export function useRealtimeChunkProcessor(
80
80
  // by the host flag (optimistic) or by an in-order DIRECT_MESSAGE chunk.
81
81
  const directModeFlagRef = useRef(isDirectMode)
82
82
  const sawDirectMessageRef = useRef(false)
83
+ // One-shot teardown guard for the barrier. Busy state can be asserted
84
+ // OUTSIDE an open stream (onAgentBusy on tool/approval chunks), so the
85
+ // teardown must fire once even when isInStreamRef is false — otherwise a
86
+ // human takeover right after an approval leaves the consumer's composer
87
+ // locked forever (all releasing AI chunks get dropped by the barrier).
88
+ const directTeardownFiredRef = useRef(false)
83
89
  useEffect(() => {
84
90
  directModeFlagRef.current = isDirectMode
85
91
  }, [isDirectMode])
@@ -110,10 +116,14 @@ export function useRealtimeChunkProcessor(
110
116
  if (action.action === 'direct_message') sawDirectMessageRef.current = true
111
117
 
112
118
  if ((directModeFlagRef.current || sawDirectMessageRef.current) && !DIRECT_MODE_ALLOWED.has(action.action)) {
113
- // Hard stop: tear down an open AI stream exactly once so the typing
114
- // indicator and half-rendered bubble clear, then drop the chunk.
115
- if (isInStreamRef.current) {
119
+ // Hard stop: tear down AI activity exactly once so the typing
120
+ // indicator, composer lock, and half-rendered bubble clear, then drop
121
+ // the chunk. Fires even when no stream is open — an onAgentBusy lock
122
+ // (approved commands executing post-MESSAGE_END) has no other release
123
+ // once the barrier starts dropping the continuation chunks.
124
+ if (isInStreamRef.current || !directTeardownFiredRef.current) {
116
125
  isInStreamRef.current = false
126
+ directTeardownFiredRef.current = true
117
127
  callbacks.onStreamEnd?.()
118
128
  accumulator.resetSegments()
119
129
  }
@@ -164,6 +174,12 @@ export function useRealtimeChunkProcessor(
164
174
  }
165
175
 
166
176
  case 'tool_execution': {
177
+ // A starting tool run means the agent's turn is in progress even
178
+ // when this lands after MESSAGE_END (approved commands execute
179
+ // between the approval bubble and the continuation stream).
180
+ if (action.segment.data.type === 'EXECUTING_TOOL') {
181
+ callbacks.onAgentBusy?.()
182
+ }
167
183
  // Post-MESSAGE_END tool chunks (cancellations / async batch
168
184
  // results for a batch in a prior bubble) flow only through the
169
185
  // cross-message updater. Skipping the accumulator avoids
@@ -254,6 +270,11 @@ export function useRealtimeChunkProcessor(
254
270
 
255
271
  case 'approval_result': {
256
272
  const { requestId, approved, approvalType, resolvedByName } = action
273
+ // Approved → the agent resumes to execute the command(s); surface
274
+ // busy immediately so the composer locks before EXECUTING_TOOL
275
+ // lands. Rejection keeps the input free — the user may want to
276
+ // type a correction right away.
277
+ if (approved) callbacks.onAgentBusy?.()
257
278
  const escalatedData = pendingEscalatedRef.current.get(requestId)
258
279
  const status: ChatApprovalStatus = approved ? 'approved' : 'rejected'
259
280
 
@@ -397,6 +418,7 @@ export function useRealtimeChunkProcessor(
397
418
  pendingEscalatedRef.current.clear()
398
419
  hasInitializedWithData.current = false
399
420
  sawDirectMessageRef.current = false
421
+ directTeardownFiredRef.current = false
400
422
  }, [])
401
423
 
402
424
  const updateApprovalStatus = useCallback(
@@ -199,6 +199,16 @@ export interface RealtimeChunkCallbacks {
199
199
  * outlived its message scope. Idempotent.
200
200
  */
201
201
  onToolExecuted?: (segment: ToolExecutionSegment) => void
202
+ /**
203
+ * Called when a chunk implies the agent is actively working OUTSIDE an open
204
+ * MESSAGE_START/MESSAGE_END window: an `EXECUTING_TOOL` chunk, or an
205
+ * `APPROVAL_RESULT` that approved execution. Approved commands run between
206
+ * the approval bubble's MESSAGE_END and the continuation stream, so without
207
+ * this signal a phase keyed on stream boundaries reads as idle while tools
208
+ * execute. The busy window is closed by the continuation's MESSAGE_END, an
209
+ * ERROR chunk, or a user stop.
210
+ */
211
+ onAgentBusy?: () => void
202
212
  /** Called when a DIALOG_CLOSED chunk is received */
203
213
  onDialogClosed?: () => void
204
214
  }
@@ -5,8 +5,16 @@ import * as TabsPrimitive from "@radix-ui/react-tabs"
5
5
 
6
6
  import { cn } from "../utils/cn"
7
7
 
8
+ /**
9
+ * @deprecated Use `TabNavigation` from `components/ui/tab-navigation` — the
10
+ * single canonical tab component (ODS tokens, URL-sync, overflow scroll
11
+ * shadows). This Radix-based `Tabs` set styles itself with legacy shadcn tokens
12
+ * (`bg-muted`, `text-foreground`, `ring-ring`) that don't exist in ODS and is
13
+ * kept only for un-migrated call sites. Do not use in new code.
14
+ */
8
15
  const Tabs = TabsPrimitive.Root
9
16
 
17
+ /** @deprecated Use `TabNavigation` from `components/ui/tab-navigation`. See {@link Tabs}. */
10
18
  const TabsList = React.forwardRef<
11
19
  React.ElementRef<typeof TabsPrimitive.List>,
12
20
  React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
@@ -22,6 +30,7 @@ const TabsList = React.forwardRef<
22
30
  ))
23
31
  TabsList.displayName = TabsPrimitive.List.displayName
24
32
 
33
+ /** @deprecated Use `TabNavigation` from `components/ui/tab-navigation`. See {@link Tabs}. */
25
34
  const TabsTrigger = React.forwardRef<
26
35
  React.ElementRef<typeof TabsPrimitive.Trigger>,
27
36
  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
@@ -37,6 +46,7 @@ const TabsTrigger = React.forwardRef<
37
46
  ))
38
47
  TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
39
48
 
49
+ /** @deprecated Use `TabNavigation` from `components/ui/tab-navigation`. See {@link Tabs}. */
40
50
  const TabsContent = React.forwardRef<
41
51
  React.ElementRef<typeof TabsPrimitive.Content>,
42
52
  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
@@ -17,11 +17,12 @@ import { Tag } from './tag'
17
17
  export type DashboardInfoCardPercentageDisplay = 'auto' | 'plain' | 'tag'
18
18
 
19
19
  /**
20
- * Size of the progress ring in px. A single number applies at all breakpoints.
21
- * The responsive form sets `base` (mobile) plus optional `md` / `lg` overrides
22
- * that take effect from the Tailwind `md` (768px) / `lg` (1024px) breakpoints
23
- * up — e.g. `{ base: 24, md: 56 }` is 24px on mobile and 56px on tablet and
24
- * desktop, while `{ base: 24, lg: 56 }` keeps the smaller ring through tablet.
20
+ * Size of the progress ring in px. A single number sets the ring size from the
21
+ * Tailwind `md` (800px) breakpoint up on mobile the ring always shrinks to
22
+ * the 24px Figma spec. The responsive form sets `base` (mobile) explicitly,
23
+ * plus optional `md` / `lg` (1280px) overrides — e.g. `{ base: 24, lg: 56 }`
24
+ * keeps the small ring through tablet, and `{ base: 40 }` pins 40px at every
25
+ * breakpoint.
25
26
  */
26
27
  export type DashboardInfoCardProgressSize =
27
28
  | number
@@ -31,6 +32,19 @@ export interface DashboardInfoCardProps {
31
32
  title?: string
32
33
  /** Node rendered in place of the title text. Takes precedence over `title`. */
33
34
  titleSlot?: React.ReactNode
35
+ /**
36
+ * Tag rendered in the title row, after the title (Figma "status" variant) —
37
+ * e.g. `<Tag variant="outline" label="AI-Assistance" />` or a TicketStatusTag.
38
+ * Works with or without a `title`.
39
+ */
40
+ titleTag?: React.ReactNode
41
+ /**
42
+ * Icon or image shown in a bordered square slot on the left (Figma "icon"
43
+ * variant): 32px box / 16px content on mobile, 56px box / 24px content from
44
+ * `md` up. The passed node is stretched to fill the content area, so plain
45
+ * icon components and `<img>` both work.
46
+ */
47
+ icon?: React.ReactNode
34
48
  value: string | number
35
49
  percentage?: number
36
50
  showProgress?: boolean
@@ -47,11 +61,11 @@ export interface DashboardInfoCardProps {
47
61
  */
48
62
  progressOverflow?: CircularProgressOverflow
49
63
  /**
50
- * Size of the circular progress ring. Defaults to `CircularProgress`'s own
51
- * default (56px) at all breakpoints. Pass a number for a fixed size, or
52
- * `{ base, md?, lg? }` for a responsive ring (e.g. `{ base: 24, md: 56 }` for
53
- * the Figma spec 24px on mobile, 56px on tablet and desktop). Stroke width
54
- * scales proportionally with the size.
64
+ * Size of the circular progress ring. Defaults to the Figma spec
65
+ * `{ base: 24, md: 56 }` 24px on mobile, 56px on tablet and desktop. A
66
+ * number changes only the tablet/desktop size (mobile stays 24px); use
67
+ * `{ base, md?, lg? }` for full control. Stroke width scales proportionally
68
+ * with the size.
55
69
  */
56
70
  progressSize?: DashboardInfoCardProgressSize
57
71
  className?: string
@@ -62,7 +76,7 @@ export interface DashboardInfoCardProps {
62
76
  href?: string
63
77
  /** Tooltip content shown on a question-mark icon next to the value */
64
78
  tooltip?: React.ReactNode
65
- /** Override the value text className (default: text-h2) */
79
+ /** Override the value text className (default: `text-h3 md:text-h2` per Figma) */
66
80
  valueClassName?: string
67
81
  /** Secondary text rendered beside the value (e.g. an entry/item count). */
68
82
  subValue?: React.ReactNode
@@ -71,6 +85,8 @@ export interface DashboardInfoCardProps {
71
85
  export function DashboardInfoCard({
72
86
  title,
73
87
  titleSlot,
88
+ titleTag,
89
+ icon,
74
90
  value,
75
91
  percentage,
76
92
  showProgress = false,
@@ -125,34 +141,26 @@ export function DashboardInfoCard({
125
141
  showLabel: false,
126
142
  }
127
143
 
128
- // Default: preserve the original 56px ring exactly.
129
- if (progressSize === undefined) {
130
- return <CircularProgress {...common} />
131
- }
132
-
133
- if (typeof progressSize === 'number') {
134
- return <CircularProgress {...common} size={progressSize} strokeWidth={strokeFor(progressSize)} />
135
- }
144
+ // Figma spec: the mobile ring is always 24px. The default is 56px from
145
+ // `md` (800px) up; a numeric `progressSize` overrides only that md+ size.
146
+ const size = typeof progressSize === 'number'
147
+ ? { base: 24, md: progressSize }
148
+ : progressSize ?? { base: 24, md: 56 }
136
149
 
137
- // Responsive: `base` (mobile) plus optional `md` / `lg` overrides. Each ring
138
- // is toggled with Tailwind display utilities so only one shows per breakpoint.
139
- // NOTE: the class names must stay static string literals — Tailwind's JIT
140
- // scanner can't see dynamically-built names (e.g. `${bp}:hidden`).
141
- const { base, md, lg } = progressSize
150
+ // Responsive: one ring per provided breakpoint, toggled with Tailwind
151
+ // display utilities so only one shows at a time. The class names must stay
152
+ // static string literals — Tailwind's JIT scanner can't see
153
+ // dynamically-built names (e.g. `${bp}:hidden`).
154
+ const { base, md, lg } = size
142
155
 
143
- const rings: Array<{ key: string; size: number; className: string }> = []
144
- if (md !== undefined && lg !== undefined) {
145
- rings.push({ key: 'base', size: base, className: 'md:hidden' })
146
- rings.push({ key: 'md', size: md, className: 'hidden md:block lg:hidden' })
147
- rings.push({ key: 'lg', size: lg, className: 'hidden lg:block' })
148
- } else if (md !== undefined) {
149
- rings.push({ key: 'base', size: base, className: 'md:hidden' })
150
- rings.push({ key: 'md', size: md, className: 'hidden md:block' })
151
- } else if (lg !== undefined) {
152
- rings.push({ key: 'base', size: base, className: 'lg:hidden' })
156
+ const rings: Array<{ key: string; size: number; className: string }> = [
157
+ { key: 'base', size: base, className: md !== undefined ? 'md:hidden' : lg !== undefined ? 'lg:hidden' : '' },
158
+ ]
159
+ if (md !== undefined) {
160
+ rings.push({ key: 'md', size: md, className: lg !== undefined ? 'hidden md:block lg:hidden' : 'hidden md:block' })
161
+ }
162
+ if (lg !== undefined) {
153
163
  rings.push({ key: 'lg', size: lg, className: 'hidden lg:block' })
154
- } else {
155
- rings.push({ key: 'base', size: base, className: '' })
156
164
  }
157
165
 
158
166
  return (
@@ -172,18 +180,31 @@ export function DashboardInfoCard({
172
180
 
173
181
  const cardContent = (
174
182
  <>
183
+ {/* Icon slot (Figma "icon" variant): 32px box / 16px content on mobile,
184
+ 56px box / 24px content from `md` up. */}
185
+ {icon && (
186
+ <div className="shrink-0 size-8 md:size-14 p-[var(--spacing-system-xxs)] bg-ods-bg border border-ods-border rounded-sm flex items-center justify-center text-ods-text-primary">
187
+ <span className="size-4 md:size-6 flex items-center justify-center [&>*]:size-full">
188
+ {icon}
189
+ </span>
190
+ </div>
191
+ )}
192
+
175
193
  {/* Content section */}
176
- <div className="flex-1 flex flex-col">
177
- {/* Title */}
178
- {titleSlot ?? (
179
- <p className="text-h5 text-ods-text-secondary">
180
- {title}
181
- </p>
182
- )}
194
+ <div className="flex-1 min-w-0 flex flex-col">
195
+ {/* Title row: caption and/or tag (Figma "status" variant) */}
196
+ <div className="flex items-center gap-[var(--spacing-system-xxs)]">
197
+ {titleSlot ?? (title !== undefined && (
198
+ <p className="text-h5 text-ods-text-secondary truncate">
199
+ {title}
200
+ </p>
201
+ ))}
202
+ {titleTag}
203
+ </div>
183
204
 
184
205
  {/* Value and percentage */}
185
206
  <div className="flex items-center gap-[var(--spacing-system-xs)]">
186
- <p className={cn("text-h2 text-ods-text-primary", valueClassName)}>
207
+ <p className={cn("text-h3 md:text-h2 text-ods-text-primary truncate", valueClassName)}>
187
208
  {formattedValue}
188
209
  </p>
189
210
  {subValue && (
@@ -207,7 +228,11 @@ export function DashboardInfoCard({
207
228
  </>
208
229
  )
209
230
 
210
- const baseClassName = 'bg-ods-card border border-ods-border rounded-sm p-[var(--spacing-system-m)] flex gap-[var(--spacing-system-s)] items-center transition-all'
231
+ // Figma spec: mobile 8px padding / 8px gap / exactly 64px tall; tablet+ 16px
232
+ // padding / 16px gap / exactly 104px tall; 6px radius. Fixed height (not
233
+ // min-h) — with a 32px titleTag the natural content height slightly exceeds
234
+ // the spec, and Figma's frame is fixed; items-center absorbs the difference.
235
+ const baseClassName = 'bg-ods-card border border-ods-border rounded-md p-[var(--spacing-system-xsf)] md:p-[var(--spacing-system-m)] h-16 md:h-[104px] flex gap-[var(--spacing-system-s)] md:gap-[var(--spacing-system-m)] items-center transition-all'
211
236
 
212
237
  if (href) {
213
238
  return (
@@ -228,9 +228,12 @@ export function TabNavigation({
228
228
  "flex gap-[var(--spacing-system-xxs)] items-center justify-center p-[var(--spacing-system-m)] relative shrink-0 cursor-pointer",
229
229
  "transition-all duration-200 bg-transparent border-none outline-none",
230
230
  stretchTabs && 'flex-1',
231
+ // Known limitation: ODS color vars hold hex values, so Tailwind
232
+ // alpha modifiers (to-ods-accent/10) silently produce no CSS.
233
+ // color-mix() accepts hex, so we derive the 10%-alpha stop from the token.
231
234
  isActive
232
- ? 'bg-gradient-to-b from-[rgba(255,255,255,0)] to-[rgba(255,255,255,0.1)]'
233
- : 'hover:bg-gradient-to-b hover:from-[rgba(255,255,255,0)] hover:to-[rgba(255,255,255,0.1)]'
235
+ ? 'bg-gradient-to-b from-transparent to-[color-mix(in_srgb,var(--color-accent-primary)_10%,transparent)]'
236
+ : 'hover:bg-gradient-to-b hover:from-transparent hover:to-[color-mix(in_srgb,var(--color-accent-primary)_10%,transparent)]'
234
237
  )}
235
238
  >
236
239
  {tab.icon ? (
@@ -5,8 +5,17 @@ import * as TabsPrimitive from "@radix-ui/react-tabs"
5
5
 
6
6
  import { cn } from "../../utils/cn"
7
7
 
8
+ /**
9
+ * @deprecated Use `TabNavigation` from `components/ui/tab-navigation` — the
10
+ * single canonical tab component (ODS tokens, URL-sync, overflow scroll
11
+ * shadows). This Radix-based `Tabs` set (a duplicate of `components/tabs.tsx`)
12
+ * styles itself with legacy shadcn tokens (`bg-muted`, `text-foreground`,
13
+ * `ring-ring`) that don't exist in ODS and is kept only for un-migrated call
14
+ * sites. Do not use in new code.
15
+ */
8
16
  const Tabs = TabsPrimitive.Root
9
17
 
18
+ /** @deprecated Use `TabNavigation` from `components/ui/tab-navigation`. See {@link Tabs}. */
10
19
  const TabsList = React.forwardRef<
11
20
  React.ElementRef<typeof TabsPrimitive.List>,
12
21
  React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
@@ -22,6 +31,7 @@ const TabsList = React.forwardRef<
22
31
  ))
23
32
  TabsList.displayName = TabsPrimitive.List.displayName
24
33
 
34
+ /** @deprecated Use `TabNavigation` from `components/ui/tab-navigation`. See {@link Tabs}. */
25
35
  const TabsTrigger = React.forwardRef<
26
36
  React.ElementRef<typeof TabsPrimitive.Trigger>,
27
37
  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
@@ -37,6 +47,7 @@ const TabsTrigger = React.forwardRef<
37
47
  ))
38
48
  TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
39
49
 
50
+ /** @deprecated Use `TabNavigation` from `components/ui/tab-navigation`. See {@link Tabs}. */
40
51
  const TabsContent = React.forwardRef<
41
52
  React.ElementRef<typeof TabsPrimitive.Content>,
42
53
  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
@@ -0,0 +1,89 @@
1
+ import type { Meta, StoryObj } from "@storybook/nextjs-vite";
2
+ import type React from "react";
3
+
4
+ import { ApprovalRequestMessage } from "../components/chat/approval-request-message";
5
+ import type { ApprovalRequestData } from "../components/chat/types";
6
+
7
+ /**
8
+ * CLIENT (end-user Fae desktop app) variant of the single-command approval
9
+ * card — Figma node 203-11947 ("fae-approval-block").
10
+ *
11
+ * Same component and props as the Admin card, driven by `variant="client"`.
12
+ * The end client sees ONLY the BE-generated title (`explanation`) plus the
13
+ * Approve/Reject buttons, or the full-text resolved pill
14
+ * ("APPROVED BY JOHN SMITH"). The raw command is never rendered.
15
+ */
16
+
17
+ const baseData: ApprovalRequestData = {
18
+ command: 'brew install --cask slack',
19
+ requestId: "req-client-1",
20
+ approvalType: "CLIENT",
21
+ // BE-generated human-readable title — the ONLY content the client sees.
22
+ explanation: "Install Slack via Homebrew Cask on macOS",
23
+ };
24
+
25
+ const plainDecorator =
26
+ (width?: number) => (Story: React.ComponentType) => (
27
+ <div style={{ maxWidth: width ?? 400, background: "var(--color-bg)" }}>
28
+ <Story />
29
+ </div>
30
+ );
31
+
32
+ const meta = {
33
+ title: "Chat/Client/ApprovalRequestMessage",
34
+ component: ApprovalRequestMessage,
35
+ tags: ["autodocs"],
36
+ args: { variant: "client" },
37
+ parameters: {
38
+ docs: {
39
+ description: {
40
+ component:
41
+ "CLIENT (Fae end-user) single-command approval card — title-only with Approve/Reject or a full-text status pill ('APPROVED BY {NAME}'). The raw command is never shown. Gated by variant='client' (Figma 203-11947).",
42
+ },
43
+ },
44
+ },
45
+ decorators: [plainDecorator()],
46
+ } satisfies Meta<typeof ApprovalRequestMessage>;
47
+
48
+ export default meta;
49
+ type Story = StoryObj<typeof meta>;
50
+
51
+ /** Pending — BE title + Approve / Reject buttons. No command block. */
52
+ export const Pending: Story = {
53
+ args: {
54
+ data: baseData,
55
+ status: "pending",
56
+ onApprove: () => {},
57
+ onReject: () => {},
58
+ },
59
+ };
60
+
61
+ /** Approved with resolver — green "APPROVED BY JOHN SMITH" pill. */
62
+ export const ApprovedByUser: Story = {
63
+ args: { data: baseData, status: "approved", resolvedByName: "John Smith" },
64
+ };
65
+
66
+ /** Approved without a resolver name — plain "APPROVED" pill (system action). */
67
+ export const Approved: Story = {
68
+ args: { data: baseData, status: "approved" },
69
+ };
70
+
71
+ /** Rejected with resolver — red "REJECTED BY JOHN SMITH" pill. */
72
+ export const RejectedByUser: Story = {
73
+ args: { data: baseData, status: "rejected", resolvedByName: "John Smith" },
74
+ };
75
+
76
+ /** Cancelled — grey status pill. */
77
+ export const Cancelled: Story = {
78
+ args: { data: baseData, status: "cancelled" },
79
+ };
80
+
81
+ /** Missing BE title — falls back to the "Approval required" placeholder. */
82
+ export const NoTitleFallback: Story = {
83
+ args: {
84
+ data: { ...baseData, explanation: undefined },
85
+ status: "pending",
86
+ onApprove: () => {},
87
+ onReject: () => {},
88
+ },
89
+ };
@@ -0,0 +1,219 @@
1
+ import type { Meta, StoryObj } from "@storybook/nextjs-vite";
2
+ import type React from "react";
3
+
4
+ import { ChatMessageEnhanced } from "../components/chat/chat-message-enhanced";
5
+ import { ChatTypingIndicator } from "../components/chat/chat-typing-indicator";
6
+ import type { MessageSegment } from "../components/chat/types";
7
+
8
+ /**
9
+ * `<ChatMessageEnhanced>` — THE chat message renderer used by
10
+ * ChatMessageList / EmbeddableChat. Renders the avatar + name + timestamp
11
+ * header (Fae pink / Mingo cyan / user grey) and every message-segment type:
12
+ * text (markdown), thinking, tool_execution, approval_request/batch, error,
13
+ * context_compaction.
14
+ *
15
+ * The Fae conversation scenes reproduce the Fae desktop-app flow: greeting →
16
+ * user request → approval block → typing dots (Figma "fae-approval-block").
17
+ */
18
+
19
+ // Fixed time-of-day so the header always reads "2:47 PM" — messages dated
20
+ // today render time-only (see formatMessageTimestamp).
21
+ const at247 = () => {
22
+ const d = new Date();
23
+ d.setHours(14, 47, 0, 0);
24
+ return d;
25
+ };
26
+
27
+ const panelDecorator = (Story: React.ComponentType) => (
28
+ <div
29
+ style={{ maxWidth: 640, background: "var(--color-bg)" }}
30
+ className="p-[var(--spacing-system-m)]"
31
+ >
32
+ <Story />
33
+ </div>
34
+ );
35
+
36
+ const meta = {
37
+ title: "Chat/ChatMessageEnhanced",
38
+ component: ChatMessageEnhanced,
39
+ tags: ["autodocs"],
40
+ parameters: {
41
+ docs: {
42
+ description: {
43
+ component:
44
+ "Single chat message row — avatar + colored author name + timestamp header, plus the full segment renderer (text/thinking/tool/approval/error/compaction). Used by ChatMessageList in both the admin (Mingo) and client (Fae) chats.",
45
+ },
46
+ },
47
+ },
48
+ decorators: [panelDecorator],
49
+ } satisfies Meta<typeof ChatMessageEnhanced>;
50
+
51
+ export default meta;
52
+ type Story = StoryObj<typeof meta>;
53
+
54
+ /** Assistant (Fae) text message — pink mono name + timestamp. */
55
+ export const AssistantFae: Story = {
56
+ args: {
57
+ role: "assistant",
58
+ name: "Fae",
59
+ content: "Good morning! How can I help you today?",
60
+ timestamp: at247(),
61
+ },
62
+ };
63
+
64
+ /** Assistant (Mingo) text message — cyan mono name. */
65
+ export const AssistantMingo: Story = {
66
+ args: {
67
+ role: "assistant",
68
+ name: "Mingo",
69
+ assistantType: "mingo",
70
+ content: "I've pulled the device list for Acme Corp — 3 endpoints are offline.",
71
+ timestamp: at247(),
72
+ },
73
+ };
74
+
75
+ /** User message — grey mono name, no avatar block until user avatars ship. */
76
+ export const UserMessage: Story = {
77
+ args: {
78
+ role: "user",
79
+ name: "John Smith",
80
+ content: "hey can u install slack on my mac",
81
+ timestamp: at247(),
82
+ },
83
+ };
84
+
85
+ /**
86
+ * Fae flow, step 1 — greeting, user request, and the assistant "typing"
87
+ * row (empty message + `<ChatTypingIndicator>`, as the Fae desktop host
88
+ * renders it while the reply streams in).
89
+ */
90
+ export const FaeConversationTyping: Story = {
91
+ args: { role: "assistant", content: "" },
92
+ render: () => (
93
+ <div className="flex flex-col">
94
+ <ChatMessageEnhanced
95
+ role="assistant"
96
+ name="Fae"
97
+ content="Good morning! How can I help you today?"
98
+ timestamp={at247()}
99
+ />
100
+ <ChatMessageEnhanced
101
+ role="user"
102
+ name="John Smith"
103
+ content="hey can u install slack on my mac"
104
+ timestamp={at247()}
105
+ />
106
+ <ChatMessageEnhanced role="assistant" name="Fae" content="" isTyping timestamp={at247()} />
107
+ <ChatTypingIndicator size="sm" />
108
+ </div>
109
+ ),
110
+ };
111
+
112
+ const approvedSegments: MessageSegment[] = [
113
+ { type: "text", text: "On it! Just need your quick approval here." },
114
+ {
115
+ type: "approval_request",
116
+ data: {
117
+ command: "brew install --cask slack",
118
+ requestId: "req-1",
119
+ approvalType: "CLIENT",
120
+ explanation: "Install Slack via Homebrew Cask on macOS",
121
+ },
122
+ status: "approved",
123
+ resolvedByName: "John Smith",
124
+ },
125
+ ];
126
+
127
+ /**
128
+ * Fae flow, step 2 — the reply carries an `approval_request` segment
129
+ * rendered as the CLIENT card with the resolved "APPROVED BY JOHN SMITH"
130
+ * pill (`approvalVariant="client"`), then Fae keeps typing.
131
+ */
132
+ export const FaeConversationApproved: Story = {
133
+ args: { role: "assistant", content: "" },
134
+ render: () => (
135
+ <div className="flex flex-col">
136
+ <ChatMessageEnhanced
137
+ role="assistant"
138
+ name="Fae"
139
+ content="Good morning! How can I help you today?"
140
+ timestamp={at247()}
141
+ />
142
+ <ChatMessageEnhanced
143
+ role="user"
144
+ name="John Smith"
145
+ content="hey can u install slack on my mac"
146
+ timestamp={at247()}
147
+ />
148
+ <ChatMessageEnhanced
149
+ role="assistant"
150
+ name="Fae"
151
+ content={approvedSegments}
152
+ approvalVariant="client"
153
+ timestamp={at247()}
154
+ />
155
+ <ChatMessageEnhanced role="assistant" name="Fae" content="" isTyping timestamp={at247()} />
156
+ <ChatTypingIndicator size="sm" />
157
+ </div>
158
+ ),
159
+ };
160
+
161
+ /** Pending approval inside a message — Approve/Reject buttons (client card). */
162
+ export const PendingApprovalSegment: Story = {
163
+ args: {
164
+ role: "assistant",
165
+ name: "Fae",
166
+ approvalVariant: "client",
167
+ timestamp: at247(),
168
+ content: [
169
+ { type: "text", text: "On it! Just need your quick approval here." },
170
+ {
171
+ type: "approval_request",
172
+ data: {
173
+ command: "brew install --cask slack",
174
+ requestId: "req-2",
175
+ approvalType: "CLIENT",
176
+ explanation: "Install Slack via Homebrew Cask on macOS",
177
+ },
178
+ status: "pending",
179
+ onApprove: () => {},
180
+ onReject: () => {},
181
+ },
182
+ ] satisfies MessageSegment[],
183
+ },
184
+ };
185
+
186
+ /** Every remaining segment type in one message — thinking, tool execution, context compaction, error. */
187
+ export const AllSegmentTypes: Story = {
188
+ args: {
189
+ role: "assistant",
190
+ name: "Fae",
191
+ timestamp: at247(),
192
+ content: [
193
+ {
194
+ type: "thinking",
195
+ text: "The user wants Slack installed. Homebrew is present on the endpoint, so `brew install --cask slack` is the cleanest path.",
196
+ },
197
+ { type: "text", text: "Installing Slack now." },
198
+ {
199
+ type: "tool_execution",
200
+ data: {
201
+ type: "EXECUTED_TOOL",
202
+ integratedToolType: "OPENFRAME_RMM",
203
+ toolFunction: "run_script",
204
+ toolTitle: "Install Slack via Homebrew Cask",
205
+ parameters: { command: "brew install --cask slack" },
206
+ result: "🍺 slack was successfully installed!",
207
+ success: true,
208
+ },
209
+ },
210
+ { type: "context_compaction", status: "completed" },
211
+ {
212
+ type: "error",
213
+ title: "Notification delivery failed",
214
+ details: "Slack webhook returned 429 — the completion ping was skipped. The install itself succeeded.",
215
+ },
216
+ { type: "text", text: "Done — Slack is installed and ready to use." },
217
+ ] satisfies MessageSegment[],
218
+ },
219
+ };