@flamingo-stack/openframe-frontend-core 0.0.518 → 0.0.519

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 (138) hide show
  1. package/dist/chat-protocol/events.d.ts +15 -1
  2. package/dist/chat-protocol/events.d.ts.map +1 -1
  3. package/dist/chat-protocol/index.cjs +25 -0
  4. package/dist/chat-protocol/index.cjs.map +1 -1
  5. package/dist/chat-protocol/index.js +25 -1
  6. package/dist/chat-protocol/index.js.map +1 -1
  7. package/dist/chat-protocol/nats-decoder.d.ts +5 -0
  8. package/dist/chat-protocol/nats-decoder.d.ts.map +1 -1
  9. package/dist/{chunk-262OTU4W.cjs → chunk-5PMRHRMM.cjs} +14 -14
  10. package/dist/{chunk-262OTU4W.cjs.map → chunk-5PMRHRMM.cjs.map} +1 -1
  11. package/dist/{chunk-SYQ4KUXW.cjs → chunk-6FLRFCK4.cjs} +1104 -879
  12. package/dist/chunk-6FLRFCK4.cjs.map +1 -0
  13. package/dist/{chunk-V76TAQ3O.js → chunk-6WQN7NLD.js} +4 -4
  14. package/dist/{chunk-FAOL7EI2.cjs → chunk-BFCBR277.cjs} +11 -11
  15. package/dist/{chunk-FAOL7EI2.cjs.map → chunk-BFCBR277.cjs.map} +1 -1
  16. package/dist/{chunk-V3K5H4I2.js → chunk-BUZOZOQ2.js} +2 -2
  17. package/dist/{chunk-U6NODSTY.cjs → chunk-CCK2MFIQ.cjs} +26 -26
  18. package/dist/{chunk-U6NODSTY.cjs.map → chunk-CCK2MFIQ.cjs.map} +1 -1
  19. package/dist/{chunk-J2JLAW2J.js → chunk-CRJRVUWB.js} +2 -2
  20. package/dist/{chunk-XDADNUC5.cjs → chunk-FWCENA3G.cjs} +31 -31
  21. package/dist/{chunk-XDADNUC5.cjs.map → chunk-FWCENA3G.cjs.map} +1 -1
  22. package/dist/{chunk-J3DRYWFP.cjs → chunk-GLFQSFYV.cjs} +61 -61
  23. package/dist/{chunk-J3DRYWFP.cjs.map → chunk-GLFQSFYV.cjs.map} +1 -1
  24. package/dist/{chunk-CV624KM6.js → chunk-HVXGSQ4V.js} +6 -6
  25. package/dist/{chunk-2ZHVOTSA.js → chunk-HWA3NZPK.js} +2 -2
  26. package/dist/{chunk-QOUKB6OJ.js → chunk-JMGVYERE.js} +2 -2
  27. package/dist/{chunk-WBEF37M5.cjs → chunk-KCDKQXE5.cjs} +7 -7
  28. package/dist/{chunk-WBEF37M5.cjs.map → chunk-KCDKQXE5.cjs.map} +1 -1
  29. package/dist/{chunk-QPCPYCU2.js → chunk-MEWHQVOU.js} +2 -2
  30. package/dist/{chunk-IPHH3GKC.cjs → chunk-OBJSXQ67.cjs} +3 -3
  31. package/dist/{chunk-IPHH3GKC.cjs.map → chunk-OBJSXQ67.cjs.map} +1 -1
  32. package/dist/{chunk-VCA3HNJ2.js → chunk-OQHHJDUB.js} +4 -4
  33. package/dist/{chunk-2EU3TWCW.cjs → chunk-QEGGWVFV.cjs} +26 -26
  34. package/dist/{chunk-2EU3TWCW.cjs.map → chunk-QEGGWVFV.cjs.map} +1 -1
  35. package/dist/{chunk-2AHSX63M.cjs → chunk-QGLEI6T2.cjs} +5 -5
  36. package/dist/{chunk-2AHSX63M.cjs.map → chunk-QGLEI6T2.cjs.map} +1 -1
  37. package/dist/{chunk-PJ4WHNJH.cjs → chunk-QQZWLTYU.cjs} +4 -4
  38. package/dist/{chunk-PJ4WHNJH.cjs.map → chunk-QQZWLTYU.cjs.map} +1 -1
  39. package/dist/{chunk-BBI7GD2Q.js → chunk-S4OAWVKF.js} +2 -2
  40. package/dist/{chunk-MY7663CW.js → chunk-SAPKMN4W.js} +2 -2
  41. package/dist/{chunk-FISSR67K.js → chunk-UBT2KBK7.js} +5 -5
  42. package/dist/{chunk-S3C4RT4O.cjs → chunk-V3MAG3G5.cjs} +87 -87
  43. package/dist/{chunk-S3C4RT4O.cjs.map → chunk-V3MAG3G5.cjs.map} +1 -1
  44. package/dist/{chunk-YHAYMOXR.js → chunk-VH7BOHRG.js} +2 -2
  45. package/dist/{chunk-QNJCZGHH.cjs → chunk-WEPJKF65.cjs} +37 -37
  46. package/dist/{chunk-QNJCZGHH.cjs.map → chunk-WEPJKF65.cjs.map} +1 -1
  47. package/dist/{chunk-6EAACE7G.js → chunk-YACGPO7I.js} +2 -2
  48. package/dist/{chunk-7SPVAAWE.js → chunk-ZA5PFNHR.js} +5975 -5750
  49. package/dist/chunk-ZA5PFNHR.js.map +1 -0
  50. package/dist/{chunk-LY7IT5QX.cjs → chunk-ZGGI4XMF.cjs} +9 -9
  51. package/dist/{chunk-LY7IT5QX.cjs.map → chunk-ZGGI4XMF.cjs.map} +1 -1
  52. package/dist/components/case-studies/index.cjs +8 -8
  53. package/dist/components/case-studies/index.js +2 -2
  54. package/dist/components/chat/ask-display.d.ts +24 -0
  55. package/dist/components/chat/ask-display.d.ts.map +1 -0
  56. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  57. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  58. package/dist/components/chat/index.cjs +4 -2
  59. package/dist/components/chat/index.cjs.map +1 -1
  60. package/dist/components/chat/index.d.ts +1 -0
  61. package/dist/components/chat/index.d.ts.map +1 -1
  62. package/dist/components/chat/index.js +3 -1
  63. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -1
  64. package/dist/components/chat/types/component.types.d.ts +25 -1
  65. package/dist/components/chat/types/component.types.d.ts.map +1 -1
  66. package/dist/components/chat/types/message.types.d.ts +32 -2
  67. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  68. package/dist/components/chat/utils/message-segment-accumulator.d.ts +8 -1
  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 +3 -3
  72. package/dist/components/contact/index.js +2 -2
  73. package/dist/components/docs/index.cjs +5 -5
  74. package/dist/components/docs/index.js +4 -4
  75. package/dist/components/embeds/index.cjs +3 -3
  76. package/dist/components/embeds/index.js +2 -2
  77. package/dist/components/faq/index.cjs +3 -3
  78. package/dist/components/faq/index.js +2 -2
  79. package/dist/components/features/index.cjs +2 -2
  80. package/dist/components/features/index.js +1 -1
  81. package/dist/components/help-center-pages/index.cjs +22 -22
  82. package/dist/components/help-center-pages/index.js +13 -13
  83. package/dist/components/index.cjs +134 -132
  84. package/dist/components/index.cjs.map +1 -1
  85. package/dist/components/index.js +13 -11
  86. package/dist/components/index.js.map +1 -1
  87. package/dist/components/meeting-scheduler/index.cjs +34 -34
  88. package/dist/components/meeting-scheduler/index.js +3 -3
  89. package/dist/components/navigation/index.cjs +2 -2
  90. package/dist/components/navigation/index.js +1 -1
  91. package/dist/components/onboarding-guides/index.cjs +5 -5
  92. package/dist/components/onboarding-guides/index.js +4 -4
  93. package/dist/components/related-content/index.cjs +3 -3
  94. package/dist/components/related-content/index.js +2 -2
  95. package/dist/components/tickets/index.cjs +6 -6
  96. package/dist/components/tickets/index.js +5 -5
  97. package/dist/components/ui/index.cjs +4 -2
  98. package/dist/components/ui/index.cjs.map +1 -1
  99. package/dist/components/ui/index.js +3 -1
  100. package/dist/index.cjs +4 -2
  101. package/dist/index.cjs.map +1 -1
  102. package/dist/index.js +3 -1
  103. package/dist/utils/index.cjs.map +1 -1
  104. package/dist/utils/index.js.map +1 -1
  105. package/package.json +1 -1
  106. package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +30 -0
  107. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +23 -0
  108. package/src/chat-protocol/events.ts +20 -0
  109. package/src/chat-protocol/nats-decoder.ts +32 -0
  110. package/src/components/chat/__tests__/ask-display.test.tsx +101 -0
  111. package/src/components/chat/__tests__/chat-message-list-ask.test.tsx +100 -0
  112. package/src/components/chat/ask-display.tsx +151 -0
  113. package/src/components/chat/chat-message-enhanced.tsx +39 -2
  114. package/src/components/chat/chat-message-list.tsx +18 -1
  115. package/src/components/chat/embeddable-chat.tsx +22 -0
  116. package/src/components/chat/index.ts +1 -0
  117. package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +72 -0
  118. package/src/components/chat/stream/chat-stream-reducer.ts +27 -0
  119. package/src/components/chat/types/component.types.ts +28 -1
  120. package/src/components/chat/types/message.types.ts +35 -1
  121. package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +74 -0
  122. package/src/components/chat/utils/message-segment-accumulator.ts +15 -0
  123. package/src/components/chat/utils/process-historical-messages.ts +26 -0
  124. package/dist/chunk-7SPVAAWE.js.map +0 -1
  125. package/dist/chunk-SYQ4KUXW.cjs.map +0 -1
  126. /package/dist/{chunk-V76TAQ3O.js.map → chunk-6WQN7NLD.js.map} +0 -0
  127. /package/dist/{chunk-V3K5H4I2.js.map → chunk-BUZOZOQ2.js.map} +0 -0
  128. /package/dist/{chunk-J2JLAW2J.js.map → chunk-CRJRVUWB.js.map} +0 -0
  129. /package/dist/{chunk-CV624KM6.js.map → chunk-HVXGSQ4V.js.map} +0 -0
  130. /package/dist/{chunk-2ZHVOTSA.js.map → chunk-HWA3NZPK.js.map} +0 -0
  131. /package/dist/{chunk-QOUKB6OJ.js.map → chunk-JMGVYERE.js.map} +0 -0
  132. /package/dist/{chunk-QPCPYCU2.js.map → chunk-MEWHQVOU.js.map} +0 -0
  133. /package/dist/{chunk-VCA3HNJ2.js.map → chunk-OQHHJDUB.js.map} +0 -0
  134. /package/dist/{chunk-BBI7GD2Q.js.map → chunk-S4OAWVKF.js.map} +0 -0
  135. /package/dist/{chunk-MY7663CW.js.map → chunk-SAPKMN4W.js.map} +0 -0
  136. /package/dist/{chunk-FISSR67K.js.map → chunk-UBT2KBK7.js.map} +0 -0
  137. /package/dist/{chunk-YHAYMOXR.js.map → chunk-VH7BOHRG.js.map} +0 -0
  138. /package/dist/{chunk-6EAACE7G.js.map → chunk-YACGPO7I.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.518",
3
+ "version": "0.0.519",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -112,6 +112,36 @@ exports[`decodeNatsChunk — golden corpus > maps the full recorded corpus to no
112
112
  "status": "rejected",
113
113
  "type": "approval-resolved",
114
114
  },
115
+ "ask": {
116
+ "options": [
117
+ {
118
+ "description": "How scripting works and how to set it up",
119
+ "label": "Find documentation",
120
+ },
121
+ {
122
+ "description": "List, edit or run the scripts in your workspace",
123
+ "label": "Your scripts",
124
+ },
125
+ ],
126
+ "question": "What do you want to work on?",
127
+ "text": "Do you want the OpenFrame docs on scripts, or the scripts in your own workspace?",
128
+ "type": "ask",
129
+ },
130
+ "ask_blank_question": null,
131
+ "ask_missing_question": null,
132
+ "ask_no_options": null,
133
+ "ask_options_not_array": null,
134
+ "ask_unusable_option_rows": null,
135
+ "ask_without_intro": {
136
+ "options": [
137
+ {
138
+ "description": undefined,
139
+ "label": "Docs",
140
+ },
141
+ ],
142
+ "question": "Which one?",
143
+ "type": "ask",
144
+ },
115
145
  "context_compaction_end": {
116
146
  "phase": "end",
117
147
  "summary": "Compacted 34 messages",
@@ -29,6 +29,29 @@ const CORPUS: Record<string, unknown> = {
29
29
  guide_empty_string: { type: 'GUIDE', text: '' },
30
30
  guide_missing_text: { type: 'GUIDE' },
31
31
 
32
+ // ASK — the guide-routing clarification card. `text` is the intro sentence
33
+ // riding the same chunk; a card without a question or without usable options
34
+ // is dropped (nothing the user could answer).
35
+ ask: {
36
+ type: 'ASK',
37
+ text: 'Do you want the OpenFrame docs on scripts, or the scripts in your own workspace?',
38
+ question: 'What do you want to work on?',
39
+ options: [
40
+ { label: 'Find documentation', description: 'How scripting works and how to set it up' },
41
+ { label: 'Your scripts', description: 'List, edit or run the scripts in your workspace' },
42
+ ],
43
+ },
44
+ ask_without_intro: { type: 'ASK', question: 'Which one?', options: [{ label: 'Docs' }] },
45
+ ask_missing_question: { type: 'ASK', options: [{ label: 'Docs' }] },
46
+ ask_blank_question: { type: 'ASK', question: ' ', options: [{ label: 'Docs' }] },
47
+ ask_no_options: { type: 'ASK', question: 'Which one?', options: [] },
48
+ ask_options_not_array: { type: 'ASK', question: 'Which one?', options: 'Docs' },
49
+ ask_unusable_option_rows: {
50
+ type: 'ASK',
51
+ question: 'Which one?',
52
+ options: [{ label: ' ' }, { description: 'no label' }, null, 'Docs'],
53
+ },
54
+
32
55
  ai_metadata_full: {
33
56
  type: 'AI_METADATA',
34
57
  modelDisplayName: 'Claude Sonnet',
@@ -15,6 +15,11 @@ import type {
15
15
  DecisionResolvedFrame,
16
16
  UsageTelemetry,
17
17
  } from './frames'
18
+ // The ask card's option shape is the SEGMENT's shape — the decoder hands the
19
+ // rows straight to the accumulator, so restating them here would be two
20
+ // declarations of one wire contract. Type-only import from a React-free
21
+ // module; `nats-decoder.ts` already depends on the same file for MESSAGE_TYPE.
22
+ import type { AskOptionData } from '../components/chat/types/message.types'
18
23
 
19
24
  /** Optional envelope on every event. `seq` carries the transport's
20
25
  * stream sequence (JetStream `streamSeq` on NATS; unused on SSE). */
@@ -65,6 +70,20 @@ export interface GuideDeltaEvent extends ChatStreamEventBase {
65
70
  text: string
66
71
  }
67
72
 
73
+ /** Clarification card (NATS `ASK` chunk) — the assistant asking which reading
74
+ * of an ambiguous question to answer. NOT a delta: the chunk carries the
75
+ * finished card, so consumers push it whole instead of coalescing. `text` is
76
+ * the intro sentence the same chunk rides along with; consumers render it as
77
+ * ordinary answer text BEFORE the card. NATS-only — the SSE frame grammar has
78
+ * no ask frame — but it lives in the shared union because the reducer is
79
+ * transport-agnostic. */
80
+ export interface AskEvent extends ChatStreamEventBase {
81
+ type: 'ask'
82
+ text?: string
83
+ question: string
84
+ options: AskOptionData[]
85
+ }
86
+
68
87
  export interface StatusEvent extends ChatStreamEventBase {
69
88
  type: 'status'
70
89
  phase: 'thinking'
@@ -241,6 +260,7 @@ export type ChatStreamEvent =
241
260
  | TextDeltaEvent
242
261
  | ThinkingDeltaEvent
243
262
  | GuideDeltaEvent
263
+ | AskEvent
244
264
  | StatusEvent
245
265
  | ToolExecutionEvent
246
266
  | ApprovalRequestEvent
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import { MESSAGE_TYPE } from '../components/chat/types/message.types'
20
+ import type { AskOptionData } from '../components/chat/types/message.types'
20
21
  import type { ApprovalToolCall, ChatStreamEvent } from './events'
21
22
 
22
23
  /** Minimal structural view of a NATS chunk (see `ChunkData` in
@@ -45,6 +46,20 @@ function normalizeToolCalls(raw: unknown): ApprovalToolCall[] {
45
46
  }))
46
47
  }
47
48
 
49
+ /** Coerce the wire's ask `options[]` into the card's row shape. Rows without a
50
+ * usable `label` are dropped: the label is what gets SENT when the row is
51
+ * picked, so a blank one would post an empty reply. */
52
+ export function normalizeAskOptions(raw: unknown): AskOptionData[] {
53
+ if (!Array.isArray(raw)) return []
54
+ return raw
55
+ .filter((item): item is Record<string, any> => !!item && typeof item === 'object')
56
+ .map((item) => ({
57
+ label: typeof item.label === 'string' ? item.label.trim() : '',
58
+ description: typeof item.description === 'string' ? item.description : undefined,
59
+ }))
60
+ .filter((option) => option.label.length > 0)
61
+ }
62
+
48
63
  /**
49
64
  * Parse one raw NATS chunk into a normalized event. Returns `null` for
50
65
  * unknown/malformed chunks — an unrecognized `type`, a missing required
@@ -101,6 +116,23 @@ export function decodeNatsChunk(chunk: unknown): ChatStreamEvent | null {
101
116
  }
102
117
  return null
103
118
 
119
+ // An ask card is only an ask card with something to pick: a question and at
120
+ // least one option. Anything less is dropped rather than rendered as an
121
+ // empty card the user can't answer — the backend already falls back to a
122
+ // complete hardcoded card when the model returns a partial one.
123
+ case MESSAGE_TYPE.ASK: {
124
+ const question = typeof data.question === 'string' ? data.question.trim() : ''
125
+ const options = normalizeAskOptions(data.options)
126
+ if (!question || options.length === 0) return null
127
+ return {
128
+ type: 'ask',
129
+ ...(typeof data.text === 'string' && data.text ? { text: data.text } : {}),
130
+ question,
131
+ options,
132
+ ...seq,
133
+ }
134
+ }
135
+
104
136
  case MESSAGE_TYPE.EXECUTING_TOOL:
105
137
  return {
106
138
  type: 'tool-execution',
@@ -0,0 +1,101 @@
1
+ /**
2
+ * `AskDisplay` — the guide-routing clarification card.
3
+ *
4
+ * Pins the two contracts a host depends on: the picked option's label reaches
5
+ * `onSelect` VERBATIM (the backend resolves the user's reply against the labels
6
+ * it offered, so a reworded send breaks routing), and the card is inert without
7
+ * a handler (replayed history / observer surfaces must not offer a button that
8
+ * would post into someone else's dialog).
9
+ */
10
+
11
+ import { fireEvent, render, screen } from '@testing-library/react'
12
+ import { describe, expect, it, vi } from 'vitest'
13
+
14
+ import { AskDisplay } from '../ask-display'
15
+ import type { AskSegment } from '../types/message.types'
16
+
17
+ const card = (question: string): AskSegment => ({
18
+ type: 'ask',
19
+ question,
20
+ options: [
21
+ { label: 'Find documentation', description: 'How the feature works and how to set it up' },
22
+ { label: 'Work with workspace data' },
23
+ ],
24
+ })
25
+
26
+ describe('AskDisplay', () => {
27
+ it('renders the question, numbered options and their descriptions', () => {
28
+ render(<AskDisplay cards={[card('What do you want to work on?')]} />)
29
+
30
+ expect(screen.getByText('What do you want to work on?')).toBeInTheDocument()
31
+ expect(screen.getByText('1.')).toBeInTheDocument()
32
+ expect(screen.getByText('Find documentation')).toBeInTheDocument()
33
+ expect(screen.getByText('How the feature works and how to set it up')).toBeInTheDocument()
34
+ expect(screen.getByText('2.')).toBeInTheDocument()
35
+ expect(screen.getByText('Work with workspace data')).toBeInTheDocument()
36
+ })
37
+
38
+ it('sends the picked option label verbatim', () => {
39
+ const onSelect = vi.fn()
40
+ render(<AskDisplay cards={[card('Which one?')]} onSelect={onSelect} />)
41
+
42
+ fireEvent.click(screen.getByText('Find documentation'))
43
+
44
+ expect(onSelect).toHaveBeenCalledWith('Find documentation')
45
+ })
46
+
47
+ it('renders locked without a handler: options disabled and veiled', () => {
48
+ const { container } = render(<AskDisplay cards={[card('Which one?')]} />)
49
+
50
+ // Kept as buttons on purpose — assistive tech should hear "dimmed button",
51
+ // not read the options as prose that was never actionable.
52
+ const options = screen.getAllByRole('button')
53
+ expect(options).toHaveLength(2)
54
+ for (const option of options) expect(option).toBeDisabled()
55
+
56
+ // The veil carries the visible lock: it dims the block, owns the pointer
57
+ // (not-allowed cursor anywhere over it) and explains itself on hover.
58
+ const veil = container.querySelector('[title]')
59
+ expect(veil).toBeTruthy()
60
+ expect(veil?.className).toContain('cursor-not-allowed')
61
+ })
62
+
63
+ it('tells assistive tech WHY the options are disabled', () => {
64
+ const { container, rerender } = render(<AskDisplay cards={[card('Which one?')]} />)
65
+
66
+ // The veil is decorative (aria-hidden), so its `title` never reaches a
67
+ // screen reader — the group's description is what does.
68
+ const describedBy = screen.getByRole('group').getAttribute('aria-describedby')
69
+ expect(describedBy).toBeTruthy()
70
+ expect(container.querySelector(`#${CSS.escape(describedBy!)}`)?.textContent).toBe(
71
+ 'This question was already answered',
72
+ )
73
+
74
+ // An answerable card describes nothing — there is no lock to explain.
75
+ rerender(<AskDisplay cards={[card('Which one?')]} onSelect={vi.fn()} />)
76
+ expect(screen.getByRole('group')).not.toHaveAttribute('aria-describedby')
77
+ })
78
+
79
+ it('hides the pager for a single card', () => {
80
+ render(<AskDisplay cards={[card('Which one?')]} onSelect={vi.fn()} />)
81
+
82
+ expect(screen.queryByText(/of 1/)).not.toBeInTheDocument()
83
+ expect(screen.queryByLabelText('Next question')).not.toBeInTheDocument()
84
+ })
85
+
86
+ it('pages through a run of cards, one question at a time', () => {
87
+ render(<AskDisplay cards={[card('First?'), card('Second?')]} onSelect={vi.fn()} />)
88
+
89
+ expect(screen.getByText('1 of 2')).toBeInTheDocument()
90
+ expect(screen.getByText('First?')).toBeInTheDocument()
91
+ expect(screen.queryByText('Second?')).not.toBeInTheDocument()
92
+ // At the first page there is nowhere back to go.
93
+ expect(screen.getByLabelText('Previous question')).toBeDisabled()
94
+
95
+ fireEvent.click(screen.getByLabelText('Next question'))
96
+
97
+ expect(screen.getByText('2 of 2')).toBeInTheDocument()
98
+ expect(screen.getByText('Second?')).toBeInTheDocument()
99
+ expect(screen.getByLabelText('Next question')).toBeDisabled()
100
+ })
101
+ })
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Ask cards go stale once answered.
3
+ *
4
+ * The clarification card belongs to ONE turn: as soon as the user replies —
5
+ * by picking an option or by typing their own answer — the question is settled
6
+ * and re-clicking it would send a stale label into a conversation that already
7
+ * moved on. `ChatMessageList` therefore hands `onAskSelect` only to messages
8
+ * that no user message follows, which also locks every card in replayed
9
+ * history (each one is followed by the reply it produced).
10
+ */
11
+
12
+ import { render } from '@testing-library/react'
13
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
14
+
15
+ import type { Message } from '../types/message.types'
16
+
17
+ // The library hands back CALLBACK refs and the list invokes them on attach —
18
+ // object refs would throw "scrollRef is not a function" during layout effects.
19
+ const noopRef = () => {}
20
+ vi.mock('use-stick-to-bottom', () => ({
21
+ useStickToBottom: () => ({
22
+ scrollRef: noopRef,
23
+ contentRef: noopRef,
24
+ scrollToBottom: vi.fn(),
25
+ stopScroll: vi.fn(),
26
+ isAtBottom: true,
27
+ }),
28
+ }))
29
+
30
+ /** Records the `onAskSelect` each row received, keyed by message id. */
31
+ const askHandlers = new Map<string, unknown>()
32
+ vi.mock('../chat-message-enhanced', async () => {
33
+ const { forwardRef } = await import('react')
34
+ return {
35
+ ChatMessageEnhanced: forwardRef<
36
+ HTMLDivElement,
37
+ { name?: string; content: unknown; onAskSelect?: (label: string) => void }
38
+ >(({ name, onAskSelect }, ref) => {
39
+ askHandlers.set(name ?? '', onAskSelect)
40
+ return <div ref={ref} />
41
+ }),
42
+ }
43
+ })
44
+
45
+ import { ChatMessageList } from '../chat-message-list'
46
+
47
+ class ObserverStub {
48
+ observe() {}
49
+ unobserve() {}
50
+ disconnect() {}
51
+ }
52
+ vi.stubGlobal('IntersectionObserver', ObserverStub)
53
+ vi.stubGlobal('ResizeObserver', ObserverStub)
54
+
55
+ const askMessage = (id: string): Message => ({
56
+ id,
57
+ name: id,
58
+ role: 'assistant',
59
+ content: [{ type: 'ask', question: 'What would you like to do with tickets?', options: [{ label: 'Ticket features' }] }],
60
+ })
61
+
62
+ const userMessage = (id: string): Message => ({ id, name: id, role: 'user', content: 'Ticket features' })
63
+
64
+ describe('ChatMessageList — ask cards lock after a reply', () => {
65
+ beforeEach(() => askHandlers.clear())
66
+
67
+ it('keeps the trailing card interactive while nothing follows it', () => {
68
+ const onAskSelect = vi.fn()
69
+ render(<ChatMessageList messages={[userMessage('u1'), askMessage('a1')]} onAskSelect={onAskSelect} />)
70
+
71
+ expect(askHandlers.get('a1')).toBe(onAskSelect)
72
+ })
73
+
74
+ it('locks a card once the user has replied after it', () => {
75
+ const onAskSelect = vi.fn()
76
+ render(
77
+ <ChatMessageList
78
+ messages={[askMessage('a1'), userMessage('u1'), { id: 'a2', name: 'a2', role: 'assistant', content: 'answer' }]}
79
+ onAskSelect={onAskSelect}
80
+ />,
81
+ )
82
+
83
+ expect(askHandlers.get('a1')).toBeUndefined()
84
+ // The turn AFTER the reply is live again — a fresh card there is clickable.
85
+ expect(askHandlers.get('a2')).toBe(onAskSelect)
86
+ })
87
+
88
+ it('locks every card in replayed history', () => {
89
+ const onAskSelect = vi.fn()
90
+ render(
91
+ <ChatMessageList
92
+ messages={[askMessage('a1'), userMessage('u1'), askMessage('a2'), userMessage('u2')]}
93
+ onAskSelect={onAskSelect}
94
+ />,
95
+ )
96
+
97
+ expect(askHandlers.get('a1')).toBeUndefined()
98
+ expect(askHandlers.get('a2')).toBeUndefined()
99
+ })
100
+ })
@@ -0,0 +1,151 @@
1
+ "use client"
2
+
3
+ import { forwardRef, useEffect, useId, useState } from "react"
4
+
5
+ import { cn } from "../../utils/cn"
6
+ import { Chevron02LeftIcon, Chevron02RightIcon } from "../icons-v2-generated"
7
+ import type { AskDisplayProps } from "./types"
8
+
9
+ /** Why the card is locked. Surfaced twice on purpose: as the veil's `title`
10
+ * (mouse) and as the group's `aria-describedby` text (keyboard / screen
11
+ * reader) — a disabled control that doesn't say why reads as a bug. */
12
+ const LOCKED_HINT = "This question was already answered"
13
+
14
+ /**
15
+ * `ASK` segment — the assistant asking WHICH reading of an ambiguous question it
16
+ * should answer, as a card of numbered options instead of prose bullets
17
+ * (Figma 1:13775).
18
+ *
19
+ * Picking a row sends its `label` verbatim as the user's next message: the
20
+ * backend's guide classifier resolves that reply against the labels it offered,
21
+ * so the row text IS the protocol.
22
+ *
23
+ * Without `onSelect` the card renders LOCKED — dimmed under a veil, rows
24
+ * `disabled`, not-allowed cursor. That is the normal end state: `ChatMessageList`
25
+ * withholds the handler as soon as any user message follows the card, because
26
+ * the question it asked has been answered and re-clicking it would send a stale
27
+ * label into a conversation that moved on. Same state on observer surfaces,
28
+ * where a click would post into someone else's dialog.
29
+ *
30
+ * A run of consecutive `ask` segments is one card with a pager ("1 of 2"), so a
31
+ * turn that asks twice reads as a single block. With one card the pager is
32
+ * absent entirely — there is nothing to page through.
33
+ */
34
+ const AskDisplay = forwardRef<HTMLDivElement, AskDisplayProps>(
35
+ ({ className, cards, onSelect, ...props }, ref) => {
36
+ const [index, setIndex] = useState(0)
37
+ const lockedHintId = useId()
38
+
39
+ // A shrinking run (segments replaced mid-stream) must not strand the pager
40
+ // past the end — clamp instead of rendering an undefined card.
41
+ useEffect(() => {
42
+ setIndex((current) => (current < cards.length ? current : Math.max(0, cards.length - 1)))
43
+ }, [cards.length])
44
+
45
+ const active = cards[Math.min(index, cards.length - 1)]
46
+ if (!active) return null
47
+
48
+ const showPager = cards.length > 1
49
+ const isInteractive = !!onSelect
50
+
51
+ return (
52
+ <div
53
+ ref={ref}
54
+ role="group"
55
+ aria-label={active.question}
56
+ // A disabled control must say WHY. The veil's `title` is a mouse-only
57
+ // affordance, so the same sentence is also carried as group description
58
+ // text — that is what a screen-reader user hears on entering the card,
59
+ // instead of two silently "dimmed" buttons.
60
+ aria-describedby={isInteractive ? undefined : lockedHintId}
61
+ className={cn(
62
+ "flex flex-col gap-[var(--spacing-system-s)] rounded-md border border-ods-border bg-ods-bg p-[var(--spacing-system-s)]",
63
+ className
64
+ )}
65
+ {...props}
66
+ >
67
+ <div className="flex items-center gap-[var(--spacing-system-m)]">
68
+ <p className="min-w-0 flex-1 break-words text-h4 text-ods-text-primary">{active.question}</p>
69
+ {showPager && (
70
+ <div className="flex shrink-0 items-center gap-[var(--spacing-system-xxs)]">
71
+ <button
72
+ type="button"
73
+ aria-label="Previous question"
74
+ disabled={index === 0}
75
+ onClick={() => setIndex((current) => Math.max(0, current - 1))}
76
+ className="text-ods-text-secondary transition-colors hover:text-ods-text-primary disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-ods-text-secondary"
77
+ >
78
+ <Chevron02LeftIcon size={16} />
79
+ </button>
80
+ <span className="whitespace-nowrap text-h6 text-ods-text-secondary">
81
+ {index + 1} of {cards.length}
82
+ </span>
83
+ <button
84
+ type="button"
85
+ aria-label="Next question"
86
+ disabled={index === cards.length - 1}
87
+ onClick={() => setIndex((current) => Math.min(cards.length - 1, current + 1))}
88
+ className="text-ods-text-secondary transition-colors hover:text-ods-text-primary disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-ods-text-secondary"
89
+ >
90
+ <Chevron02RightIcon size={16} />
91
+ </button>
92
+ </div>
93
+ )}
94
+ </div>
95
+
96
+ {/* Locked state = the rows' own `disabled` (assistive tech hears
97
+ "dimmed button", focus skips them, clicks can't fire) PLUS a veil
98
+ over the whole block. The veil is what makes it READ as locked: it
99
+ greys the answers down to caption weight without touching their
100
+ markup, and — sitting above the rows — it owns the pointer, so the
101
+ not-allowed cursor and the "why" tooltip show anywhere over the
102
+ list, not just on a row. The pager stays live above it: paging is
103
+ reading, not answering. */}
104
+ <div className="relative">
105
+ <div className="flex flex-col overflow-hidden rounded-md border border-ods-border">
106
+ {/* Rows carry the divider on their TOP edge except the first, so the
107
+ list never ends on a hanging border (the design's trailing rule
108
+ belongs to the custom-answer input, which this card does not have). */}
109
+ {active.options.map((option, optionIndex) => (
110
+ <button
111
+ key={`${option.label}-${optionIndex}`}
112
+ type="button"
113
+ disabled={!isInteractive}
114
+ onClick={() => onSelect?.(option.label)}
115
+ className={cn(
116
+ "flex w-full items-start gap-[var(--spacing-system-s)] bg-ods-card p-[var(--spacing-system-s)] text-left transition-colors",
117
+ optionIndex > 0 && "border-t border-ods-border",
118
+ "hover:bg-ods-bg-hover disabled:cursor-not-allowed disabled:hover:bg-ods-card"
119
+ )}
120
+ >
121
+ <span className="shrink-0 text-h4 text-ods-text-secondary">{optionIndex + 1}.</span>
122
+ <span className="flex min-w-0 flex-1 flex-col">
123
+ <span className="break-words text-h4 text-ods-text-primary">{option.label}</span>
124
+ {option.description && (
125
+ <span className="break-words text-h6 text-ods-text-secondary">{option.description}</span>
126
+ )}
127
+ </span>
128
+ </button>
129
+ ))}
130
+ </div>
131
+ {!isInteractive && (
132
+ <>
133
+ <span id={lockedHintId} className="sr-only">
134
+ {LOCKED_HINT}
135
+ </span>
136
+ <div
137
+ aria-hidden="true"
138
+ title={LOCKED_HINT}
139
+ className="absolute inset-0 cursor-not-allowed rounded-md bg-ods-bg/40"
140
+ />
141
+ </>
142
+ )}
143
+ </div>
144
+ </div>
145
+ )
146
+ }
147
+ )
148
+
149
+ AskDisplay.displayName = "AskDisplay"
150
+
151
+ export { AskDisplay }
@@ -12,6 +12,7 @@ import { ErrorMessageDisplay } from "./error-message-display"
12
12
  import { ContextCompactionDisplay } from "./context-compaction-display"
13
13
  import { ThinkingDisplay } from "./thinking-display"
14
14
  import { GuideDisplay } from "./guide-display"
15
+ import { AskDisplay } from "./ask-display"
15
16
  import { SimpleMarkdownRenderer } from "../ui/markdown/simple-markdown-renderer"
16
17
  import type { ChatRef } from "./chat-ref.types"
17
18
  import { remarkCardLinks } from "./remark-card-links"
@@ -19,7 +20,7 @@ import { remarkStripCitations } from "./remark-strip-citations"
19
20
  import { remarkMentionChips } from "./remark-mention-chips"
20
21
  import { BlockCard, type BlockCardProps } from "./entity-cards/block-card"
21
22
  import { ChatContextChipStrip } from "./chat-context-picker"
22
- import type { MessageSegment, MessageContent, ChatMessageEnhancedProps } from "./types"
23
+ import type { AskSegment, MessageSegment, MessageContent, ChatMessageEnhancedProps } from "./types"
23
24
 
24
25
  /** Inline `@marker:id` mention token in the message body (sibling of the
25
26
  * `[card://]` grammar) — used to filter out items rendered inline from the
@@ -55,8 +56,32 @@ function normalizeContent(content: MessageContent): MessageSegment[] {
55
56
  return content
56
57
  }
57
58
 
59
+ /**
60
+ * A RUN of consecutive `ask` segments is one paged card, not a stack of
61
+ * near-identical ones. Returns the run keyed by the index of its FIRST segment;
62
+ * the render draws the card there and skips every later index of the same run
63
+ * (an `ask` index missing from this map is a tail member).
64
+ */
65
+ function groupAskRuns(segments: MessageSegment[]): Map<number, AskSegment[]> {
66
+ const runs = new Map<number, AskSegment[]>()
67
+ let headIndex = -1
68
+ segments.forEach((segment, index) => {
69
+ if (segment.type !== 'ask') {
70
+ headIndex = -1
71
+ return
72
+ }
73
+ if (headIndex === -1) {
74
+ headIndex = index
75
+ runs.set(index, [segment])
76
+ return
77
+ }
78
+ runs.get(headIndex)?.push(segment)
79
+ })
80
+ return runs
81
+ }
82
+
58
83
  const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>(
59
- ({ className, role, content, name, avatar, isTyping = false, timestamp, showAvatar = true, assistantType, approvalVariant, authorType: authorTypeProp, assistantIcon, chatRefs, contextItems, resolveContextIcon, renderContextItem, renderMention, renderEntityCard, NavLinkAnchor, ...props }, ref) => {
84
+ ({ className, role, content, name, avatar, isTyping = false, timestamp, showAvatar = true, assistantType, approvalVariant, authorType: authorTypeProp, assistantIcon, chatRefs, contextItems, resolveContextIcon, renderContextItem, renderMention, renderEntityCard, onAskSelect, NavLinkAnchor, ...props }, ref) => {
60
85
  const isUser = role === 'user'
61
86
  const isError = role === 'error'
62
87
  const authorType = authorTypeProp ?? (isUser ? 'user' : assistantType === 'mingo' ? 'mingo' : 'fae')
@@ -78,6 +103,8 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
78
103
 
79
104
  const segments = useMemo(() => normalizeContent(content), [content])
80
105
 
106
+ const askRuns = useMemo(() => groupAskRuns(segments), [segments])
107
+
81
108
  // Inline `@marker:id` mentions: the composer commits these tokens when the
82
109
  // user picks context via the `@`-flow, and the ASSISTANT routinely echoes
83
110
  // the same `@device:machineId` token back in its reply. The lib detects the
@@ -657,6 +684,12 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
657
684
  {renderSegmentBody(index, segment.text, segmentIsStreaming)}
658
685
  </GuideDisplay>
659
686
  )
687
+ } else if (segment.type === 'ask') {
688
+ // Only the run's head draws — the tail segments are pages of
689
+ // the card already rendered above them.
690
+ const run = askRuns.get(index)
691
+ if (!run) return null
692
+ return <AskDisplay key={index} cards={run} onSelect={onAskSelect} />
660
693
  } else if (segment.type === 'tool_execution') {
661
694
  return (
662
695
  <ToolExecutionDisplay
@@ -767,6 +800,10 @@ const MemoizedChatMessageEnhanced = memo(ChatMessageEnhanced, (prevProps, nextPr
767
800
  // equality holds across streaming chunks.
768
801
  prevProps.renderMention === nextProps.renderMention &&
769
802
  prevProps.renderEntityCard === nextProps.renderEntityCard &&
803
+ // Same stability contract as the renderers above: hosts pass a `useCallback`
804
+ // (EmbeddableChat passes its memoized `handleSend`), so this holds across
805
+ // streaming chunks instead of re-rendering every ask card per chunk.
806
+ prevProps.onAskSelect === nextProps.onAskSelect &&
770
807
  prevProps.NavLinkAnchor === nextProps.NavLinkAnchor
771
808
  )
772
809
  })
@@ -1,6 +1,6 @@
1
1
  "use client"
2
2
 
3
- import { useRef, useState, useCallback, useEffect, useLayoutEffect, useImperativeHandle, forwardRef } from "react"
3
+ import { useRef, useState, useCallback, useEffect, useLayoutEffect, useImperativeHandle, useMemo, forwardRef } from "react"
4
4
  import { useStickToBottom } from "use-stick-to-bottom"
5
5
  import { cn } from "../../utils/cn"
6
6
  import { ChatMessageEnhanced } from "./chat-message-enhanced"
@@ -129,6 +129,7 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
129
129
  resolveContextIcon,
130
130
  renderContextItem,
131
131
  renderMention,
132
+ onAskSelect,
132
133
  NavLinkAnchor,
133
134
  ...props
134
135
  },
@@ -148,6 +149,21 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
148
149
  initial: false,
149
150
  })
150
151
 
152
+ // ---- Ask cards go stale once answered ------------------------------
153
+ // A clarification card asks about the turn it belongs to. The moment the
154
+ // user says ANYTHING after it — clicking an option, or typing their own
155
+ // reply in the composer — the question is answered and the card is
156
+ // history: clicking it again would send a stale label into a conversation
157
+ // that already moved on. So the handler reaches only messages that no user
158
+ // message follows, which also locks every card in replayed history for
159
+ // free (each one is followed by the reply it produced).
160
+ const lastUserMessageIndex = useMemo(() => {
161
+ for (let i = messages.length - 1; i >= 0; i--) {
162
+ if (messages[i].role === 'user') return i
163
+ }
164
+ return -1
165
+ }, [messages])
166
+
151
167
  // ---- Overlay guard: don't move the ground under an open menu -------
152
168
  // A ⋯ menu inside a bubble stays anchored to its trigger. While a reply
153
169
  // streams in, the follow-the-bottom scroll carries that trigger upward and
@@ -984,6 +1000,7 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
984
1000
  renderContextItem={renderContextItem}
985
1001
  renderMention={renderMention}
986
1002
  renderEntityCard={renderEntityCard}
1003
+ onAskSelect={index > lastUserMessageIndex ? onAskSelect : undefined}
987
1004
  NavLinkAnchor={NavLinkAnchor}
988
1005
  />
989
1006
  )