@flamingo-stack/openframe-frontend-core 0.0.518 → 0.0.520
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.
- package/dist/chat-protocol/events.d.ts +15 -1
- package/dist/chat-protocol/events.d.ts.map +1 -1
- package/dist/chat-protocol/index.cjs +25 -0
- package/dist/chat-protocol/index.cjs.map +1 -1
- package/dist/chat-protocol/index.js +25 -1
- package/dist/chat-protocol/index.js.map +1 -1
- package/dist/chat-protocol/nats-decoder.d.ts +5 -0
- package/dist/chat-protocol/nats-decoder.d.ts.map +1 -1
- package/dist/{chunk-PJ4WHNJH.cjs → chunk-2HL3BXWV.cjs} +4 -4
- package/dist/{chunk-PJ4WHNJH.cjs.map → chunk-2HL3BXWV.cjs.map} +1 -1
- package/dist/{chunk-2ZHVOTSA.js → chunk-2Y4REKE5.js} +2 -2
- package/dist/{chunk-LY7IT5QX.cjs → chunk-37OBDW56.cjs} +9 -9
- package/dist/{chunk-LY7IT5QX.cjs.map → chunk-37OBDW56.cjs.map} +1 -1
- package/dist/{chunk-2EU3TWCW.cjs → chunk-4NEP3KY7.cjs} +26 -26
- package/dist/{chunk-2EU3TWCW.cjs.map → chunk-4NEP3KY7.cjs.map} +1 -1
- package/dist/{chunk-CV624KM6.js → chunk-5PT2RQRU.js} +6 -6
- package/dist/{chunk-SYQ4KUXW.cjs → chunk-5ZQKEGL3.cjs} +1179 -880
- package/dist/chunk-5ZQKEGL3.cjs.map +1 -0
- package/dist/{chunk-7SPVAAWE.js → chunk-AQHR5D7X.js} +6251 -5952
- package/dist/chunk-AQHR5D7X.js.map +1 -0
- package/dist/{chunk-V3K5H4I2.js → chunk-DMITCSFH.js} +2 -2
- package/dist/{chunk-YHAYMOXR.js → chunk-HI3TQS4D.js} +2 -2
- package/dist/{chunk-BBI7GD2Q.js → chunk-JKICCXLC.js} +2 -2
- package/dist/{chunk-2AHSX63M.cjs → chunk-JZC5BBAF.cjs} +5 -5
- package/dist/{chunk-2AHSX63M.cjs.map → chunk-JZC5BBAF.cjs.map} +1 -1
- package/dist/{chunk-QOUKB6OJ.js → chunk-LB66ECD7.js} +2 -2
- package/dist/{chunk-QPCPYCU2.js → chunk-P7AQ5XKO.js} +2 -2
- package/dist/{chunk-262OTU4W.cjs → chunk-PA6L55NF.cjs} +14 -14
- package/dist/{chunk-262OTU4W.cjs.map → chunk-PA6L55NF.cjs.map} +1 -1
- package/dist/{chunk-6EAACE7G.js → chunk-QICUWGET.js} +2 -2
- package/dist/{chunk-IPHH3GKC.cjs → chunk-R3Y65KPT.cjs} +3 -3
- package/dist/{chunk-IPHH3GKC.cjs.map → chunk-R3Y65KPT.cjs.map} +1 -1
- package/dist/{chunk-XDADNUC5.cjs → chunk-RRCSGTT5.cjs} +31 -31
- package/dist/{chunk-XDADNUC5.cjs.map → chunk-RRCSGTT5.cjs.map} +1 -1
- package/dist/{chunk-MY7663CW.js → chunk-RT4IGTJ6.js} +2 -2
- package/dist/{chunk-FISSR67K.js → chunk-RTJ6Q7JA.js} +5 -5
- package/dist/{chunk-WBEF37M5.cjs → chunk-S5HO5E6R.cjs} +7 -7
- package/dist/{chunk-WBEF37M5.cjs.map → chunk-S5HO5E6R.cjs.map} +1 -1
- package/dist/{chunk-U6NODSTY.cjs → chunk-SVYXGHJG.cjs} +26 -26
- package/dist/{chunk-U6NODSTY.cjs.map → chunk-SVYXGHJG.cjs.map} +1 -1
- package/dist/{chunk-FAOL7EI2.cjs → chunk-TRIU3RJD.cjs} +11 -11
- package/dist/{chunk-FAOL7EI2.cjs.map → chunk-TRIU3RJD.cjs.map} +1 -1
- package/dist/{chunk-J3DRYWFP.cjs → chunk-UDPOAWKH.cjs} +61 -61
- package/dist/{chunk-J3DRYWFP.cjs.map → chunk-UDPOAWKH.cjs.map} +1 -1
- package/dist/{chunk-J2JLAW2J.js → chunk-UOMUR62H.js} +2 -2
- package/dist/{chunk-VCA3HNJ2.js → chunk-W3UAL5GU.js} +4 -4
- package/dist/{chunk-V76TAQ3O.js → chunk-Y43UWXF7.js} +4 -4
- package/dist/{chunk-QNJCZGHH.cjs → chunk-YD7JM53S.cjs} +37 -37
- package/dist/{chunk-QNJCZGHH.cjs.map → chunk-YD7JM53S.cjs.map} +1 -1
- package/dist/{chunk-S3C4RT4O.cjs → chunk-YPHKO6C5.cjs} +87 -87
- package/dist/{chunk-S3C4RT4O.cjs.map → chunk-YPHKO6C5.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chat/ask-display.d.ts +24 -0
- package/dist/components/chat/ask-display.d.ts.map +1 -0
- package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
- package/dist/components/chat/chat-message-list.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +4 -2
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.d.ts +1 -0
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/index.js +3 -1
- package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -1
- package/dist/components/chat/types/component.types.d.ts +25 -1
- package/dist/components/chat/types/component.types.d.ts.map +1 -1
- package/dist/components/chat/types/message.types.d.ts +32 -2
- package/dist/components/chat/types/message.types.d.ts.map +1 -1
- package/dist/components/chat/utils/message-segment-accumulator.d.ts +8 -1
- package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +5 -5
- package/dist/components/docs/index.js +4 -4
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/help-center-pages/index.cjs +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +134 -132
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +13 -11
- package/dist/components/index.js.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +34 -34
- package/dist/components/meeting-scheduler/index.js +3 -3
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/index.cjs +4 -2
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +3 -1
- package/dist/components/ui/modal-v2.d.ts.map +1 -1
- package/dist/index.cjs +4 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +3 -1
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +30 -0
- package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +23 -0
- package/src/chat-protocol/events.ts +20 -0
- package/src/chat-protocol/nats-decoder.ts +32 -0
- package/src/components/chat/__tests__/ask-display.test.tsx +101 -0
- package/src/components/chat/__tests__/chat-message-list-ask.test.tsx +100 -0
- package/src/components/chat/ask-display.tsx +151 -0
- package/src/components/chat/chat-message-enhanced.tsx +39 -2
- package/src/components/chat/chat-message-list.tsx +18 -1
- package/src/components/chat/embeddable-chat.tsx +22 -0
- package/src/components/chat/index.ts +1 -0
- package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +72 -0
- package/src/components/chat/stream/chat-stream-reducer.ts +27 -0
- package/src/components/chat/types/component.types.ts +28 -1
- package/src/components/chat/types/message.types.ts +35 -1
- package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +74 -0
- package/src/components/chat/utils/message-segment-accumulator.ts +15 -0
- package/src/components/chat/utils/process-historical-messages.ts +26 -0
- package/src/components/ui/modal-v2.tsx +95 -1
- package/dist/chunk-7SPVAAWE.js.map +0 -1
- package/dist/chunk-SYQ4KUXW.cjs.map +0 -1
- /package/dist/{chunk-2ZHVOTSA.js.map → chunk-2Y4REKE5.js.map} +0 -0
- /package/dist/{chunk-CV624KM6.js.map → chunk-5PT2RQRU.js.map} +0 -0
- /package/dist/{chunk-V3K5H4I2.js.map → chunk-DMITCSFH.js.map} +0 -0
- /package/dist/{chunk-YHAYMOXR.js.map → chunk-HI3TQS4D.js.map} +0 -0
- /package/dist/{chunk-BBI7GD2Q.js.map → chunk-JKICCXLC.js.map} +0 -0
- /package/dist/{chunk-QOUKB6OJ.js.map → chunk-LB66ECD7.js.map} +0 -0
- /package/dist/{chunk-QPCPYCU2.js.map → chunk-P7AQ5XKO.js.map} +0 -0
- /package/dist/{chunk-6EAACE7G.js.map → chunk-QICUWGET.js.map} +0 -0
- /package/dist/{chunk-MY7663CW.js.map → chunk-RT4IGTJ6.js.map} +0 -0
- /package/dist/{chunk-FISSR67K.js.map → chunk-RTJ6Q7JA.js.map} +0 -0
- /package/dist/{chunk-J2JLAW2J.js.map → chunk-UOMUR62H.js.map} +0 -0
- /package/dist/{chunk-VCA3HNJ2.js.map → chunk-W3UAL5GU.js.map} +0 -0
- /package/dist/{chunk-V76TAQ3O.js.map → chunk-Y43UWXF7.js.map} +0 -0
package/package.json
CHANGED
|
@@ -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
|
)
|