@flamingo-stack/openframe-frontend-core 0.0.467 → 0.0.468-snapshot.20260723140929
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/decode.d.ts +58 -0
- package/dist/chat-protocol/decode.d.ts.map +1 -0
- package/dist/chat-protocol/encode.d.ts +44 -0
- package/dist/chat-protocol/encode.d.ts.map +1 -0
- package/dist/chat-protocol/env-flag.d.ts +35 -0
- package/dist/chat-protocol/env-flag.d.ts.map +1 -0
- package/dist/chat-protocol/events.d.ts +192 -0
- package/dist/chat-protocol/events.d.ts.map +1 -0
- package/dist/chat-protocol/frames.d.ts +206 -0
- package/dist/chat-protocol/frames.d.ts.map +1 -0
- package/dist/chat-protocol/index.cjs +493 -0
- package/dist/chat-protocol/index.cjs.map +1 -0
- package/dist/chat-protocol/index.d.ts +18 -0
- package/dist/chat-protocol/index.d.ts.map +1 -0
- package/dist/chat-protocol/index.js +477 -0
- package/dist/chat-protocol/index.js.map +1 -0
- package/dist/chat-protocol/ip-normalize.d.ts +44 -0
- package/dist/chat-protocol/ip-normalize.d.ts.map +1 -0
- package/dist/chat-protocol/nats-decoder.d.ts +26 -0
- package/dist/chat-protocol/nats-decoder.d.ts.map +1 -0
- package/dist/{chunk-BIDOIPIT.js → chunk-2BJSRTKU.js} +2 -2
- package/dist/{chunk-4DFPQKKJ.js → chunk-33FDAZDB.js} +2 -2
- package/dist/{chunk-LOWJWKM5.js → chunk-4QDFNDVW.js} +4 -4
- package/dist/{chunk-72NEN4G4.cjs → chunk-4UFRQT7Z.cjs} +15 -15
- package/dist/{chunk-72NEN4G4.cjs.map → chunk-4UFRQT7Z.cjs.map} +1 -1
- package/dist/{chunk-WWC75D3J.cjs → chunk-4W54HUP7.cjs} +6853 -4609
- package/dist/chunk-4W54HUP7.cjs.map +1 -0
- package/dist/{chunk-6DZ5QOUM.cjs → chunk-5HUTT3FD.cjs} +11 -11
- package/dist/{chunk-6DZ5QOUM.cjs.map → chunk-5HUTT3FD.cjs.map} +1 -1
- package/dist/{chunk-GXXLJHQX.js → chunk-7I2QMUEZ.js} +2 -2
- package/dist/{chunk-QJ7NIL6A.js → chunk-AJSNF5YF.js} +6 -5
- package/dist/chunk-AJSNF5YF.js.map +1 -0
- package/dist/{chunk-NYOQNYSI.js → chunk-AW3LVWVV.js} +2 -2
- package/dist/{chunk-25YYEVSE.js → chunk-DG7TX763.js} +2 -2
- package/dist/{chunk-CZFPPNYV.js → chunk-E6O6XATC.js} +11899 -9655
- package/dist/chunk-E6O6XATC.js.map +1 -0
- package/dist/{chunk-ZYPHZ3R3.cjs → chunk-GLKULDQN.cjs} +9 -9
- package/dist/{chunk-ZYPHZ3R3.cjs.map → chunk-GLKULDQN.cjs.map} +1 -1
- package/dist/{chunk-TZSPLSDU.js → chunk-GYEZV4GK.js} +2 -2
- package/dist/{chunk-G5D7NRH5.cjs → chunk-LRESZO4L.cjs} +26 -26
- package/dist/{chunk-G5D7NRH5.cjs.map → chunk-LRESZO4L.cjs.map} +1 -1
- package/dist/{chunk-ABU37QSG.js → chunk-MMF6HJB7.js} +4 -4
- package/dist/{chunk-O7LQYYUA.cjs → chunk-MTLBBSLP.cjs} +5 -5
- package/dist/{chunk-O7LQYYUA.cjs.map → chunk-MTLBBSLP.cjs.map} +1 -1
- package/dist/{chunk-UWW4PQQS.cjs → chunk-OVIUQJ2H.cjs} +28 -27
- package/dist/chunk-OVIUQJ2H.cjs.map +1 -0
- package/dist/{chunk-3L44PPMF.js → chunk-RPAOSHBN.js} +2 -2
- package/dist/{chunk-VERK32MO.cjs → chunk-TOFOK7G5.cjs} +37 -37
- package/dist/{chunk-VERK32MO.cjs.map → chunk-TOFOK7G5.cjs.map} +1 -1
- package/dist/{chunk-QITN43HN.cjs → chunk-U2KPMWO6.cjs} +7 -7
- package/dist/{chunk-QITN43HN.cjs.map → chunk-U2KPMWO6.cjs.map} +1 -1
- package/dist/{chunk-YGW7T57C.cjs → chunk-V5YWOQBP.cjs} +31 -31
- package/dist/{chunk-YGW7T57C.cjs.map → chunk-V5YWOQBP.cjs.map} +1 -1
- package/dist/{chunk-ILZ66CS6.js → chunk-VUCYS36H.js} +10 -10
- package/dist/chunk-VUCYS36H.js.map +1 -0
- package/dist/{chunk-SGDXS7VT.cjs → chunk-WHWDGCQV.cjs} +60 -60
- package/dist/chunk-WHWDGCQV.cjs.map +1 -0
- package/dist/{chunk-G2H4AKL3.cjs → chunk-YXNWEVER.cjs} +62 -62
- package/dist/{chunk-G2H4AKL3.cjs.map → chunk-YXNWEVER.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/chat-composer.d.ts +8 -1
- package/dist/components/chat/chat-composer.d.ts.map +1 -1
- package/dist/components/chat/chat-context-memory-bar.d.ts +37 -0
- package/dist/components/chat/chat-context-memory-bar.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/embeddable-chat.d.ts +14 -6
- package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
- package/dist/components/chat/hooks/index.d.ts +0 -1
- package/dist/components/chat/hooks/index.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +15 -48
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +3 -47
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +12 -2
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.d.ts +2 -0
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/index.js +23 -13
- package/dist/components/chat/stream/chat-dialog-store.d.ts +184 -0
- package/dist/components/chat/stream/chat-dialog-store.d.ts.map +1 -0
- package/dist/components/chat/stream/chat-stream-reducer.d.ts +367 -0
- package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -0
- package/dist/components/chat/stream/delta-batcher.d.ts +51 -0
- package/dist/components/chat/stream/delta-batcher.d.ts.map +1 -0
- package/dist/components/chat/stream/index.d.ts +17 -0
- package/dist/components/chat/stream/index.d.ts.map +1 -0
- package/dist/components/chat/stream/message-mutations.d.ts +77 -0
- package/dist/components/chat/stream/message-mutations.d.ts.map +1 -0
- package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +22 -0
- package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +1 -0
- package/dist/components/chat/types/api.types.d.ts +1 -81
- package/dist/components/chat/types/api.types.d.ts.map +1 -1
- package/dist/components/chat/types/processing.types.d.ts +2 -90
- package/dist/components/chat/types/processing.types.d.ts.map +1 -1
- package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +34 -6
- package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
- package/dist/components/chat/utils/index.d.ts +2 -3
- package/dist/components/chat/utils/index.d.ts.map +1 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts +59 -11
- 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 +20 -20
- package/dist/components/help-center-pages/index.js +11 -11
- package/dist/components/index.cjs +174 -130
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +65 -21
- package/dist/components/index.js.map +1 -1
- 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/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +5 -5
- package/dist/components/tickets/index.js +4 -4
- package/dist/components/ui/index.cjs +46 -2
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.d.ts +1 -2
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +57 -13
- package/dist/components/ui/markdown/base-components.d.ts +56 -0
- package/dist/components/ui/markdown/base-components.d.ts.map +1 -0
- package/dist/components/ui/markdown/engine.d.ts +99 -0
- package/dist/components/ui/markdown/engine.d.ts.map +1 -0
- package/dist/components/ui/markdown/heading-ids.d.ts +65 -0
- package/dist/components/ui/markdown/heading-ids.d.ts.map +1 -0
- package/dist/components/ui/markdown/index.d.ts +18 -0
- package/dist/components/ui/markdown/index.d.ts.map +1 -0
- package/dist/components/ui/markdown/mermaid-diagram.d.ts +79 -0
- package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +1 -0
- package/dist/components/ui/markdown/rich/embed-overrides.d.ts +14 -0
- package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -0
- package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +40 -0
- package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +1 -0
- package/dist/components/ui/markdown/rich/shortcodes.d.ts +8 -0
- package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -0
- package/dist/components/ui/markdown/sanitize.d.ts +153 -0
- package/dist/components/ui/markdown/sanitize.d.ts.map +1 -0
- package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +23 -0
- package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +1 -0
- package/dist/components/ui/markdown/streaming.d.ts +78 -0
- package/dist/components/ui/markdown/streaming.d.ts.map +1 -0
- package/dist/components/ui/markdown/text-size.d.ts +34 -0
- package/dist/components/ui/markdown/text-size.d.ts.map +1 -0
- package/dist/index.cjs +62 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +73 -13
- package/dist/utils/index.cjs +169 -40
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +162 -41
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/markdown-fences.d.ts +42 -0
- package/dist/utils/markdown-fences.d.ts.map +1 -0
- package/dist/utils/markdown-heading-id.d.ts +85 -0
- package/dist/utils/markdown-heading-id.d.ts.map +1 -0
- package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
- package/package.json +7 -1
- package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +307 -0
- package/src/chat-protocol/__tests__/chat-protocol.test.ts +515 -0
- package/src/chat-protocol/__tests__/env-flag.test.ts +52 -0
- package/src/chat-protocol/__tests__/ip-normalize.test.ts +136 -0
- package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +228 -0
- package/src/chat-protocol/decode.ts +277 -0
- package/src/chat-protocol/encode.ts +71 -0
- package/src/chat-protocol/env-flag.ts +39 -0
- package/src/chat-protocol/events.ts +228 -0
- package/src/chat-protocol/frames.ts +238 -0
- package/src/chat-protocol/index.ts +21 -0
- package/src/chat-protocol/ip-normalize.ts +146 -0
- package/src/chat-protocol/nats-decoder.ts +252 -0
- package/src/components/chat/__tests__/chat-message-list.test.tsx +293 -5
- package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +207 -0
- package/src/components/chat/__tests__/chat-pending-turn.test.tsx +209 -0
- package/src/components/chat/chat-composer.tsx +32 -5
- package/src/components/chat/chat-context-memory-bar.tsx +159 -0
- package/src/components/chat/chat-message-enhanced.tsx +111 -17
- package/src/components/chat/chat-message-list.tsx +358 -109
- package/src/components/chat/embeddable-chat.tsx +23 -15
- package/src/components/chat/hooks/.index.md +1 -1
- package/src/components/chat/hooks/.use-nats-chat-adapter.md +1 -1
- package/src/components/chat/hooks/__tests__/__snapshots__/persisted-state-v1-golden.test.ts.snap +297 -0
- package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
- package/src/components/chat/hooks/__tests__/persisted-state-v1-golden.test.ts +272 -0
- package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +318 -0
- package/src/components/chat/hooks/index.ts +0 -1
- package/src/components/chat/hooks/use-chat.ts +8 -0
- package/src/components/chat/hooks/use-nats-chat-adapter.ts +243 -822
- package/src/components/chat/hooks/use-sse-chat-adapter.ts +318 -666
- package/src/components/chat/index.ts +6 -0
- package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +937 -0
- package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +756 -0
- package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +460 -0
- package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +894 -0
- package/src/components/chat/stream/__tests__/delta-batcher.test.ts +255 -0
- package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +98 -0
- package/src/components/chat/stream/chat-dialog-store.ts +536 -0
- package/src/components/chat/stream/chat-stream-reducer.ts +1796 -0
- package/src/components/chat/stream/delta-batcher.ts +159 -0
- package/src/components/chat/stream/index.ts +55 -0
- package/src/components/chat/stream/message-mutations.ts +338 -0
- package/src/components/chat/stream/use-chat-stream-reducer.ts +126 -0
- package/src/components/chat/thinking-display.tsx +1 -1
- package/src/components/chat/types/.api.types.md +1 -3
- package/src/components/chat/types/.processing.types.md +6 -23
- package/src/components/chat/types/api.types.ts +6 -73
- package/src/components/chat/types/processing.types.ts +11 -52
- package/src/components/chat/utils/.index.md +0 -5
- package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +418 -0
- package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +599 -0
- package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +317 -0
- package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +270 -0
- package/src/components/chat/utils/extract-incomplete-message-state.ts +63 -5
- package/src/components/chat/utils/index.ts +5 -8
- package/src/components/chat/utils/process-historical-messages.ts +352 -383
- package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +22 -4
- package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
- package/src/components/shared/product-release/release-detail-page.tsx +19 -4
- package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +2420 -0
- package/src/components/ui/__tests__/markdown-parity.test.tsx +2528 -0
- package/src/components/ui/index.ts +1 -2
- package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +115 -0
- package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +95 -0
- package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +309 -0
- package/src/components/ui/markdown/__tests__/streaming.test.tsx +388 -0
- package/src/components/ui/markdown/base-components.tsx +341 -0
- package/src/components/ui/markdown/engine.tsx +315 -0
- package/src/components/ui/markdown/heading-ids.ts +239 -0
- package/src/components/ui/markdown/index.ts +49 -0
- package/src/components/ui/markdown/mermaid-diagram.tsx +291 -0
- package/src/components/ui/markdown/rich/embed-overrides.tsx +199 -0
- package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +184 -0
- package/src/components/ui/markdown/rich/shortcodes.ts +170 -0
- package/src/components/ui/markdown/sanitize.ts +3499 -0
- package/src/components/ui/markdown/simple-markdown-renderer.tsx +28 -0
- package/src/components/ui/markdown/streaming.ts +362 -0
- package/src/components/ui/markdown/text-size.ts +106 -0
- package/src/components/ui/release-changelog-section.tsx +1 -1
- package/src/components/ui/tab-selector.tsx +8 -8
- package/src/components/ui/ticket-info-section.tsx +1 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/markdown-fences.ts +127 -0
- package/src/utils/markdown-heading-id.ts +348 -0
- package/src/utils/markdown-section-extractor.ts +41 -56
- package/dist/chunk-CZFPPNYV.js.map +0 -1
- package/dist/chunk-ILZ66CS6.js.map +0 -1
- package/dist/chunk-QJ7NIL6A.js.map +0 -1
- package/dist/chunk-SGDXS7VT.cjs.map +0 -1
- package/dist/chunk-UWW4PQQS.cjs.map +0 -1
- package/dist/chunk-WWC75D3J.cjs.map +0 -1
- package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +0 -6
- package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +0 -1
- package/dist/components/chat/utils/chunk-parser.d.ts +0 -25
- package/dist/components/chat/utils/chunk-parser.d.ts.map +0 -1
- package/dist/components/ui/rich-markdown-renderer.d.ts +0 -34
- package/dist/components/ui/rich-markdown-renderer.d.ts.map +0 -1
- package/dist/components/ui/simple-markdown-renderer.d.ts +0 -73
- package/dist/components/ui/simple-markdown-renderer.d.ts.map +0 -1
- package/src/components/chat/hooks/.use-realtime-chunk-processor.md +0 -50
- package/src/components/chat/hooks/use-realtime-chunk-processor.ts +0 -479
- package/src/components/chat/utils/.chunk-parser.md +0 -51
- package/src/components/chat/utils/chunk-parser.ts +0 -255
- package/src/components/ui/.simple-markdown-renderer.md +0 -40
- package/src/components/ui/rich-markdown-renderer.tsx +0 -1223
- package/src/components/ui/simple-markdown-renderer.tsx +0 -965
- /package/dist/{chunk-BIDOIPIT.js.map → chunk-2BJSRTKU.js.map} +0 -0
- /package/dist/{chunk-4DFPQKKJ.js.map → chunk-33FDAZDB.js.map} +0 -0
- /package/dist/{chunk-LOWJWKM5.js.map → chunk-4QDFNDVW.js.map} +0 -0
- /package/dist/{chunk-GXXLJHQX.js.map → chunk-7I2QMUEZ.js.map} +0 -0
- /package/dist/{chunk-NYOQNYSI.js.map → chunk-AW3LVWVV.js.map} +0 -0
- /package/dist/{chunk-25YYEVSE.js.map → chunk-DG7TX763.js.map} +0 -0
- /package/dist/{chunk-TZSPLSDU.js.map → chunk-GYEZV4GK.js.map} +0 -0
- /package/dist/{chunk-ABU37QSG.js.map → chunk-MMF6HJB7.js.map} +0 -0
- /package/dist/{chunk-3L44PPMF.js.map → chunk-RPAOSHBN.js.map} +0 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending-turn + hidden-row contracts.
|
|
3
|
+
*
|
|
4
|
+
* Two reader-visible defects this pins against:
|
|
5
|
+
*
|
|
6
|
+
* 1. Approving a tool call minted an author-label row with NO body
|
|
7
|
+
* ("Michael Assraf:" then nothing). The approval decision is
|
|
8
|
+
* out-of-band metadata, so the reducer must not materialize a user
|
|
9
|
+
* row for it at all — fixing it at render would leave dead state.
|
|
10
|
+
* 2. While the model was thinking, the transcript showed a second,
|
|
11
|
+
* empty "Mingo:" row ABOVE the footer progress indicator — two
|
|
12
|
+
* owners for one pending turn, and an `aria-live` log announcing a
|
|
13
|
+
* bare "Mingo". Exactly one affordance must be present.
|
|
14
|
+
*
|
|
15
|
+
* Plus the two idioms that must SURVIVE both fixes: a hidden row that
|
|
16
|
+
* carries real text (auto-continuation directive) stays in state while
|
|
17
|
+
* never rendering, and the SYSTEM name-only row still renders.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { render } from '@testing-library/react'
|
|
21
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
22
|
+
import { createChatStreamReducer } from '../stream/chat-stream-reducer'
|
|
23
|
+
import {
|
|
24
|
+
AUTO_CONTINUATION_DIRECTIVE_PREFIX,
|
|
25
|
+
buildAutoContinuationDirective,
|
|
26
|
+
} from '../utils/auto-continuation-directive'
|
|
27
|
+
import type { Message } from '../types/message.types'
|
|
28
|
+
|
|
29
|
+
// ─── Render harness (mirrors chat-message-list.test.tsx) ────────────────────
|
|
30
|
+
|
|
31
|
+
const scrollToBottom = vi.fn()
|
|
32
|
+
const stopScroll = vi.fn()
|
|
33
|
+
|
|
34
|
+
function makeRef() {
|
|
35
|
+
const fn = ((el: HTMLElement | null) => {
|
|
36
|
+
;(fn as { current: HTMLElement | null }).current = el
|
|
37
|
+
}) as ((el: HTMLElement | null) => void) & { current: HTMLElement | null }
|
|
38
|
+
fn.current = null
|
|
39
|
+
return fn
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
vi.mock('use-stick-to-bottom', () => ({
|
|
43
|
+
useStickToBottom: () => ({
|
|
44
|
+
scrollRef: makeRef(),
|
|
45
|
+
contentRef: makeRef(),
|
|
46
|
+
scrollToBottom,
|
|
47
|
+
stopScroll,
|
|
48
|
+
}),
|
|
49
|
+
}))
|
|
50
|
+
|
|
51
|
+
// Row stub that renders the author label the way the real component does
|
|
52
|
+
// (`Name:`), so "labeled row with no body" is directly assertable.
|
|
53
|
+
vi.mock('../chat-message-enhanced', () => ({
|
|
54
|
+
ChatMessageEnhanced: ({ name, content }: { name?: string; content: unknown }) => (
|
|
55
|
+
<div data-testid="row">
|
|
56
|
+
<span data-testid="row-name">{name ?? ''}</span>
|
|
57
|
+
<span data-testid="row-body">
|
|
58
|
+
{typeof content === 'string'
|
|
59
|
+
? content
|
|
60
|
+
: Array.isArray(content)
|
|
61
|
+
? content
|
|
62
|
+
.map((s) => (s && (s as { type?: string }).type === 'text' ? (s as { text: string }).text : '[seg]'))
|
|
63
|
+
.join('')
|
|
64
|
+
: ''}
|
|
65
|
+
</span>
|
|
66
|
+
</div>
|
|
67
|
+
),
|
|
68
|
+
}))
|
|
69
|
+
|
|
70
|
+
import { ChatMessageList } from '../chat-message-list'
|
|
71
|
+
|
|
72
|
+
class ObserverStub {
|
|
73
|
+
observe() {}
|
|
74
|
+
unobserve() {}
|
|
75
|
+
disconnect() {}
|
|
76
|
+
}
|
|
77
|
+
vi.stubGlobal('IntersectionObserver', ObserverStub)
|
|
78
|
+
vi.stubGlobal('ResizeObserver', ObserverStub)
|
|
79
|
+
|
|
80
|
+
const names = (c: HTMLElement) =>
|
|
81
|
+
Array.from(c.querySelectorAll('[data-testid="row-name"]')).map((n) => n.textContent)
|
|
82
|
+
const bodies = (c: HTMLElement) =>
|
|
83
|
+
Array.from(c.querySelectorAll('[data-testid="row-body"]')).map((n) => n.textContent)
|
|
84
|
+
|
|
85
|
+
// ─── State-level contracts ─────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
describe('approval sends mint no visible user row', () => {
|
|
88
|
+
it('beginSseSend with empty text pushes only the assistant placeholder', () => {
|
|
89
|
+
const r = createChatStreamReducer({ transport: 'sse' })
|
|
90
|
+
// What `cardApprove` triggers: ('', { hidden: true, approvalAction }).
|
|
91
|
+
r.beginSseSend({ text: '', hidden: true, assistantName: 'Mingo AI' })
|
|
92
|
+
|
|
93
|
+
expect(r.state.messages).toHaveLength(1)
|
|
94
|
+
expect(r.state.messages[0].role).toBe('assistant')
|
|
95
|
+
expect(r.state.messages.some((m) => m.role === 'user')).toBe(false)
|
|
96
|
+
expect(r.state.streamingPhase).toBe('thinking')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('pushOptimisticSend with empty text likewise mints no user row', () => {
|
|
100
|
+
const r = createChatStreamReducer({ transport: 'nats' })
|
|
101
|
+
r.pushOptimisticSend('', true)
|
|
102
|
+
|
|
103
|
+
expect(r.state.messages).toHaveLength(1)
|
|
104
|
+
expect(r.state.messages[0].role).toBe('assistant')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('a normal send still mints the user row', () => {
|
|
108
|
+
const r = createChatStreamReducer({ transport: 'sse' })
|
|
109
|
+
r.beginSseSend({ text: 'hello', assistantName: 'Mingo AI' })
|
|
110
|
+
|
|
111
|
+
expect(r.state.messages).toHaveLength(2)
|
|
112
|
+
expect(r.state.messages[0]).toMatchObject({ role: 'user', content: 'hello' })
|
|
113
|
+
expect(r.state.messages[0].hidden).toBeUndefined()
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('hidden rows that carry text', () => {
|
|
118
|
+
it('an auto-continuation directive IS state (the model sees it) but is flagged hidden', () => {
|
|
119
|
+
const directive = buildAutoContinuationDirective('create_ticket', { ticketId: '42' })
|
|
120
|
+
expect(directive.startsWith(AUTO_CONTINUATION_DIRECTIVE_PREFIX)).toBe(true)
|
|
121
|
+
|
|
122
|
+
const r = createChatStreamReducer({ transport: 'sse' })
|
|
123
|
+
r.beginSseSend({ text: directive, hidden: true, assistantName: 'Mingo AI' })
|
|
124
|
+
|
|
125
|
+
const userRow = r.state.messages.find((m) => m.role === 'user')
|
|
126
|
+
expect(userRow?.content).toBe(directive)
|
|
127
|
+
expect(userRow?.hidden).toBe(true)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('never renders — the raw directive text must not reach the transcript', () => {
|
|
131
|
+
const directive = buildAutoContinuationDirective('create_ticket', { ticketId: '42' })
|
|
132
|
+
const messages: Message[] = [
|
|
133
|
+
{ id: 'u1', role: 'user', name: 'Michael Assraf', content: 'my laptop is dead' },
|
|
134
|
+
{ id: 'h1', role: 'user', name: 'Michael Assraf', content: directive, hidden: true },
|
|
135
|
+
{ id: 'a1', role: 'assistant', name: 'Mingo', content: 'Opened a ticket.' },
|
|
136
|
+
]
|
|
137
|
+
const { container } = render(<ChatMessageList dialogId="d" messages={messages} />)
|
|
138
|
+
|
|
139
|
+
expect(container.textContent).not.toContain(AUTO_CONTINUATION_DIRECTIVE_PREFIX)
|
|
140
|
+
expect(bodies(container)).toEqual(['my laptop is dead', 'Opened a ticket.'])
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// ─── Render-level contracts ────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
describe('a thinking turn has exactly ONE pending affordance', () => {
|
|
147
|
+
const pending: Message[] = [
|
|
148
|
+
{ id: 'u1', role: 'user', name: 'Michael Assraf', content: 'what is broken?' },
|
|
149
|
+
{ id: 'a1', role: 'assistant', name: 'Mingo', content: '', segments: [] },
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
it('does not render the empty labeled assistant row while typing', () => {
|
|
153
|
+
const { container } = render(
|
|
154
|
+
<ChatMessageList dialogId="d" messages={pending} isTyping />,
|
|
155
|
+
)
|
|
156
|
+
// The bare "Mingo" label must be gone from the transcript entirely —
|
|
157
|
+
// removed from the DOM, not visually hidden (the list is an aria-live
|
|
158
|
+
// log; a zero-height node would still be announced).
|
|
159
|
+
expect(names(container)).toEqual(['Michael Assraf'])
|
|
160
|
+
expect(bodies(container)).toEqual(['what is broken?'])
|
|
161
|
+
// …and the single remaining owner is the polite status indicator.
|
|
162
|
+
expect(container.querySelectorAll('[role="status"]')).toHaveLength(1)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('renders the assistant row as soon as it carries text', () => {
|
|
166
|
+
const withText: Message[] = [
|
|
167
|
+
pending[0],
|
|
168
|
+
{ id: 'a1', role: 'assistant', name: 'Mingo', content: [{ type: 'text', text: 'Chec' }] },
|
|
169
|
+
]
|
|
170
|
+
const { container } = render(
|
|
171
|
+
<ChatMessageList dialogId="d" messages={withText} isTyping />,
|
|
172
|
+
)
|
|
173
|
+
expect(names(container)).toEqual(['Michael Assraf', 'Mingo'])
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('renders the assistant row when it carries a NON-text segment (tool call)', () => {
|
|
177
|
+
const withTool: Message[] = [
|
|
178
|
+
pending[0],
|
|
179
|
+
{
|
|
180
|
+
id: 'a1',
|
|
181
|
+
role: 'assistant',
|
|
182
|
+
name: 'Mingo',
|
|
183
|
+
content: [
|
|
184
|
+
{ type: 'tool_execution', toolExecutionRequestId: 'e1', status: 'executing' },
|
|
185
|
+
] as Message['content'],
|
|
186
|
+
},
|
|
187
|
+
]
|
|
188
|
+
const { container } = render(
|
|
189
|
+
<ChatMessageList dialogId="d" messages={withTool} isTyping />,
|
|
190
|
+
)
|
|
191
|
+
expect(names(container)).toEqual(['Michael Assraf', 'Mingo'])
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('renders a completed empty-ish assistant row when NOT typing', () => {
|
|
195
|
+
const { container } = render(<ChatMessageList dialogId="d" messages={pending} />)
|
|
196
|
+
expect(names(container)).toEqual(['Michael Assraf', 'Mingo'])
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
describe('the SYSTEM name-only row is a legitimate label-without-body', () => {
|
|
201
|
+
it('still renders', () => {
|
|
202
|
+
const messages: Message[] = [
|
|
203
|
+
{ id: 'u1', role: 'user', name: 'Michael Assraf', content: 'hi' },
|
|
204
|
+
{ id: 's1', role: 'user', name: 'Technician joined', content: '', authorType: 'system' },
|
|
205
|
+
]
|
|
206
|
+
const { container } = render(<ChatMessageList dialogId="d" messages={messages} isTyping />)
|
|
207
|
+
expect(names(container)).toContain('Technician joined')
|
|
208
|
+
})
|
|
209
|
+
})
|
|
@@ -4,6 +4,7 @@ import * as React from 'react'
|
|
|
4
4
|
import { ChatFooter } from './chat-container'
|
|
5
5
|
import { ChatInput } from './chat-input'
|
|
6
6
|
import { ChatAttachmentAddButton } from './chat-attachment-bar'
|
|
7
|
+
import { ChatContextMemoryBar } from './chat-context-memory-bar'
|
|
7
8
|
import {
|
|
8
9
|
ChatComposerPlusMenu,
|
|
9
10
|
ChatContextChipStrip,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
} from './chat-context-picker'
|
|
12
13
|
import { ModelDisplay } from './model-display'
|
|
13
14
|
import { BoxArchiveIcon } from '../icons-v2-generated'
|
|
15
|
+
import { cn } from '../../utils/cn'
|
|
14
16
|
import type { ChatInputRef, ModelDisplayProps } from './types/component.types'
|
|
15
17
|
import type {
|
|
16
18
|
ChatContextItem,
|
|
@@ -49,6 +51,13 @@ export interface ChatComposerProps {
|
|
|
49
51
|
onToggleContextItem?: (item: ChatContextItem) => void
|
|
50
52
|
/** Remove an item (chip ×). */
|
|
51
53
|
onRemoveContextItem?: (item: ChatContextItem) => void
|
|
54
|
+
/** Ambient CONTEXT MEMORY (Figma 271:38656) — the entities the host collected
|
|
55
|
+
* from the user's navigation history and sends with every message. Rendered
|
|
56
|
+
* as a summary strip at the top of the composer card, above the assigned-item
|
|
57
|
+
* chips. Empty/omitted → no strip. */
|
|
58
|
+
contextMemoryItems?: ChatContextItem[]
|
|
59
|
+
/** Drop one entity from the context memory (dropdown row ×). */
|
|
60
|
+
onRemoveContextMemoryItem?: (item: ChatContextItem) => void
|
|
52
61
|
/** Controlled picker open state. */
|
|
53
62
|
contextPickerOpen?: boolean
|
|
54
63
|
/** Open the picker (the `+` menu's "Assign Item"). */
|
|
@@ -94,6 +103,8 @@ export function ChatComposer({
|
|
|
94
103
|
selectedContextItems,
|
|
95
104
|
onToggleContextItem,
|
|
96
105
|
onRemoveContextItem,
|
|
106
|
+
contextMemoryItems,
|
|
107
|
+
onRemoveContextMemoryItem,
|
|
97
108
|
contextPickerOpen = false,
|
|
98
109
|
onOpenContextPicker,
|
|
99
110
|
onCloseContextPicker,
|
|
@@ -104,6 +115,7 @@ export function ChatComposer({
|
|
|
104
115
|
}: ChatComposerProps) {
|
|
105
116
|
const contextEnabled = !!contextPicker && !archived
|
|
106
117
|
const selected = selectedContextItems ?? []
|
|
118
|
+
const memory = contextMemoryItems ?? []
|
|
107
119
|
|
|
108
120
|
// type → icon lookup so the chips (composer + bubble) lead with their
|
|
109
121
|
// entity-type glyph without the parent threading an extra resolver. Keyed on
|
|
@@ -184,23 +196,38 @@ export function ChatComposer({
|
|
|
184
196
|
</p>
|
|
185
197
|
</div>
|
|
186
198
|
) : contextEnabled ? (
|
|
187
|
-
// Unified card (Figma 1:6073):
|
|
188
|
-
// (`bg-ods-bg`,
|
|
189
|
-
//
|
|
190
|
-
//
|
|
199
|
+
// Unified card (Figma 1:6073 / 271:38656): the context-memory
|
|
200
|
+
// summary strip and the assigned-chip strip (both `bg-ods-bg`,
|
|
201
|
+
// `border-b`) stack flush above the input; the card owns the border
|
|
202
|
+
// / radius / focus ring. No `overflow-hidden` so the picker and the
|
|
203
|
+
// memory popover can float above the input (whichever strip comes
|
|
204
|
+
// first gets `rounded-t`).
|
|
191
205
|
// `has-[[data-editor]:focus]` (not `focus-within`) so the accent
|
|
192
206
|
// border reacts ONLY to the composer editor — not the picker's search
|
|
193
207
|
// input or item buttons, which also live inside this card. The editor
|
|
194
208
|
// is a `contentEditable` div (`data-editor`), not a `<textarea>`, so we
|
|
195
209
|
// target its data attribute rather than the element name.
|
|
196
210
|
<div className="rounded-md border border-ods-border bg-ods-card transition-colors has-[[data-editor]:focus]:border-ods-accent">
|
|
211
|
+
{/* Ambient navigation-history context — always the same items, so
|
|
212
|
+
it sits furthest from the textarea. Self-hides when empty. */}
|
|
213
|
+
<ChatContextMemoryBar
|
|
214
|
+
items={memory}
|
|
215
|
+
onRemove={onRemoveContextMemoryItem}
|
|
216
|
+
resolveIcon={resolveContextIcon}
|
|
217
|
+
disabled={sending}
|
|
218
|
+
className="rounded-t-md"
|
|
219
|
+
/>
|
|
197
220
|
{selected.length > 0 && (
|
|
198
221
|
<ChatContextChipStrip
|
|
199
222
|
items={selected}
|
|
200
223
|
onRemove={onRemoveContextItem}
|
|
201
224
|
resolveIcon={resolveContextIcon}
|
|
202
225
|
disabled={sending}
|
|
203
|
-
className=
|
|
226
|
+
className={cn(
|
|
227
|
+
'border-b border-ods-border bg-ods-bg p-2',
|
|
228
|
+
// Only the TOP strip rounds into the card corner.
|
|
229
|
+
memory.length === 0 && 'rounded-t-md',
|
|
230
|
+
)}
|
|
204
231
|
/>
|
|
205
232
|
)}
|
|
206
233
|
{/* Picker is rendered inside the `+` button (its `dropdown` prop)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `<ChatContextMemoryBar>` — the composer's CONTEXT MEMORY strip
|
|
5
|
+
* (Figma node 271:38656).
|
|
6
|
+
*
|
|
7
|
+
* A one-line summary row pinned to the top of the composer card ("5 recently
|
|
8
|
+
* viewed items in context") with a `⋯` trigger that opens a dropdown listing
|
|
9
|
+
* every remembered entity, each removable via its `×`.
|
|
10
|
+
*
|
|
11
|
+
* This is the AMBIENT half of the composer's context UI — the entities the host
|
|
12
|
+
* accumulated from the user's navigation history and sends along with every
|
|
13
|
+
* message — as opposed to `<ChatContextChipStrip>`, which shows the items the
|
|
14
|
+
* user ASSIGNED to the message they're typing. Both sit above the input; memory
|
|
15
|
+
* on top (passive, always the same items), assigned chips closest to the
|
|
16
|
+
* textarea (active selection for this message).
|
|
17
|
+
*
|
|
18
|
+
* The lib owns only the chrome: items and removal are host-fed
|
|
19
|
+
* (`EmbeddableChat`'s `contextMemory` prop), and lead glyphs come from the
|
|
20
|
+
* picker's entity-type icon map via `resolveIcon`. Renders `null` when the host
|
|
21
|
+
* has nothing remembered, so it has no permanent footprint.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type * as React from 'react'
|
|
25
|
+
import { useEffect, useRef, useState } from 'react'
|
|
26
|
+
import { Ellipsis01Icon } from '../icons-v2-generated/interface/ellipsis-01-icon'
|
|
27
|
+
import { XmarkCircleIcon } from '../icons-v2-generated/signs-and-symbols/xmark-circle-icon'
|
|
28
|
+
import { cn } from '../../utils/cn'
|
|
29
|
+
import { CONTEXT_ICON_CLASS, CONTEXT_LABEL_CLASS, CONTEXT_ROW_CLASS } from './context-items-list'
|
|
30
|
+
import type { ChatContextItem } from './types/context-item.types'
|
|
31
|
+
|
|
32
|
+
const itemKey = (item: { type: string; id: string }) => `${item.type}:${item.id}`
|
|
33
|
+
|
|
34
|
+
export interface ChatContextMemoryBarProps {
|
|
35
|
+
/** Remembered entities, most-recent-first. Empty → the bar renders nothing. */
|
|
36
|
+
items: ChatContextItem[]
|
|
37
|
+
/** Drop one entity from the host's memory (dropdown row `×`). When omitted the
|
|
38
|
+
* list is read-only. */
|
|
39
|
+
onRemove?: (item: ChatContextItem) => void
|
|
40
|
+
/** Lead-glyph resolver — the composer passes its entity-type icon map, so the
|
|
41
|
+
* rows match the picker and the assigned chips. */
|
|
42
|
+
resolveIcon?: (item: ChatContextItem) => React.ReactNode
|
|
43
|
+
/** External disable (e.g. while a message is streaming). */
|
|
44
|
+
disabled?: boolean
|
|
45
|
+
className?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function ChatContextMemoryBar({
|
|
49
|
+
items,
|
|
50
|
+
onRemove,
|
|
51
|
+
resolveIcon,
|
|
52
|
+
disabled = false,
|
|
53
|
+
className,
|
|
54
|
+
}: ChatContextMemoryBarProps) {
|
|
55
|
+
const [open, setOpen] = useState(false)
|
|
56
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
57
|
+
|
|
58
|
+
// Removing the last remembered entity unmounts the bar; close first so a
|
|
59
|
+
// stale popover can't outlive its trigger.
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (items.length === 0) setOpen(false)
|
|
62
|
+
}, [items.length])
|
|
63
|
+
|
|
64
|
+
// Outside-press close. Registered only while open. The root wraps BOTH the
|
|
65
|
+
// trigger and the popover, so a press on the `⋯` (inside root) is ignored here
|
|
66
|
+
// and handled by the button's own toggle.
|
|
67
|
+
//
|
|
68
|
+
// CAPTURE phase (the `true` 3rd arg): the chat lives inside a resizable drawer
|
|
69
|
+
// whose panel captures pointer events and whose composer sits on a
|
|
70
|
+
// `contentEditable`; a bubble-phase `document` listener can be pre-empted by
|
|
71
|
+
// an ancestor's `stopPropagation`, which is why a press outside the popover
|
|
72
|
+
// wasn't closing it. Capture runs top-down before any of that, so the handler
|
|
73
|
+
// always fires. `touchstart` covers tap-to-dismiss on touch devices.
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!open) return
|
|
76
|
+
const onOutside = (e: Event) => {
|
|
77
|
+
if (rootRef.current?.contains(e.target as Node)) return
|
|
78
|
+
setOpen(false)
|
|
79
|
+
}
|
|
80
|
+
document.addEventListener('mousedown', onOutside, true)
|
|
81
|
+
document.addEventListener('touchstart', onOutside, true)
|
|
82
|
+
return () => {
|
|
83
|
+
document.removeEventListener('mousedown', onOutside, true)
|
|
84
|
+
document.removeEventListener('touchstart', onOutside, true)
|
|
85
|
+
}
|
|
86
|
+
}, [open])
|
|
87
|
+
|
|
88
|
+
if (items.length === 0) return null
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
// `relative` anchors the popover to this strip; `bottom-full` floats it
|
|
92
|
+
// above, matching the context picker's placement over the input.
|
|
93
|
+
<div
|
|
94
|
+
ref={rootRef}
|
|
95
|
+
className={cn(
|
|
96
|
+
'relative flex items-center gap-[var(--spacing-system-xs)] border-b border-ods-border bg-ods-bg p-[var(--spacing-system-xs)]',
|
|
97
|
+
className,
|
|
98
|
+
)}
|
|
99
|
+
onKeyDown={e => {
|
|
100
|
+
if (e.key === 'Escape' && open) {
|
|
101
|
+
e.preventDefault()
|
|
102
|
+
setOpen(false)
|
|
103
|
+
}
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
<p className="min-w-0 flex-1 truncate text-h6 text-ods-text-secondary">
|
|
107
|
+
{items.length} recently viewed {items.length === 1 ? 'item' : 'items'} in context
|
|
108
|
+
</p>
|
|
109
|
+
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
aria-label="Show remembered context"
|
|
113
|
+
aria-expanded={open}
|
|
114
|
+
title="Show remembered context"
|
|
115
|
+
onClick={() => setOpen(o => !o)}
|
|
116
|
+
className={cn(
|
|
117
|
+
'flex size-4 shrink-0 items-center justify-center rounded outline-none transition-colors [&_svg]:size-4',
|
|
118
|
+
open ? 'text-ods-text-primary' : 'text-ods-text-secondary hover:text-ods-text-primary',
|
|
119
|
+
'focus-visible:text-ods-text-primary',
|
|
120
|
+
)}
|
|
121
|
+
>
|
|
122
|
+
<Ellipsis01Icon size={16} />
|
|
123
|
+
</button>
|
|
124
|
+
|
|
125
|
+
{open && (
|
|
126
|
+
<div
|
|
127
|
+
role="list"
|
|
128
|
+
aria-label="Remembered context"
|
|
129
|
+
// Mirrors `ChatContextPicker`'s popover chrome (border / card bg /
|
|
130
|
+
// shadow-md) and its scroll cap, so both dropdowns over the composer
|
|
131
|
+
// read as one component family.
|
|
132
|
+
className="absolute bottom-full right-0 z-50 mb-1 max-h-[min(340px,45vh)] w-[320px] max-w-[90vw] overflow-y-auto overscroll-contain rounded-md border border-ods-border bg-ods-card text-ods-text-primary shadow-md"
|
|
133
|
+
>
|
|
134
|
+
{items.map(item => (
|
|
135
|
+
// Not a `<button>`: the row itself is inert (only the `×` acts), so
|
|
136
|
+
// the remove control isn't nested inside another button.
|
|
137
|
+
<div key={itemKey(item)} role="listitem" className={CONTEXT_ROW_CLASS}>
|
|
138
|
+
{resolveIcon && <span className={CONTEXT_ICON_CLASS}>{resolveIcon(item)}</span>}
|
|
139
|
+
<span className={CONTEXT_LABEL_CLASS} title={item.label}>
|
|
140
|
+
{item.label}
|
|
141
|
+
</span>
|
|
142
|
+
{onRemove && (
|
|
143
|
+
<button
|
|
144
|
+
type="button"
|
|
145
|
+
onClick={() => onRemove(item)}
|
|
146
|
+
disabled={disabled}
|
|
147
|
+
aria-label={`Remove ${item.label} from context`}
|
|
148
|
+
className="flex size-4 shrink-0 items-center justify-center text-ods-text-secondary outline-none transition-colors hover:text-ods-text-primary focus-visible:text-ods-text-primary disabled:cursor-not-allowed disabled:opacity-40 md:size-6"
|
|
149
|
+
>
|
|
150
|
+
<XmarkCircleIcon className="size-4 shrink-0 md:size-6" />
|
|
151
|
+
</button>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
))}
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import React, { forwardRef, memo, useMemo, useRef } from "react"
|
|
3
|
+
import React, { forwardRef, memo, useEffect, useMemo, useRef } from "react"
|
|
4
4
|
import { cn } from "../../utils/cn"
|
|
5
5
|
import { isToday } from "../../utils/date-utils"
|
|
6
6
|
import { formatDate, formatTime } from "../../utils/format-date"
|
|
@@ -11,7 +11,7 @@ import { ApprovalBatchMessage } from "./approval-batch-message"
|
|
|
11
11
|
import { ErrorMessageDisplay } from "./error-message-display"
|
|
12
12
|
import { ContextCompactionDisplay } from "./context-compaction-display"
|
|
13
13
|
import { ThinkingDisplay } from "./thinking-display"
|
|
14
|
-
import { SimpleMarkdownRenderer } from "../ui/simple-markdown-renderer"
|
|
14
|
+
import { SimpleMarkdownRenderer } from "../ui/markdown/simple-markdown-renderer"
|
|
15
15
|
import type { ChatRef } from "./chat-ref.types"
|
|
16
16
|
import { remarkCardLinks } from "./remark-card-links"
|
|
17
17
|
import { remarkMentionChips } from "./remark-mention-chips"
|
|
@@ -163,9 +163,9 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
|
|
|
163
163
|
| { kind: 'text'; text: string }
|
|
164
164
|
| { kind: 'block'; key: string; node: React.ReactNode }
|
|
165
165
|
const partsBySegment = new Map<number, SegmentPart[]>()
|
|
166
|
-
if (!render) return { inlineByKey, partsBySegment }
|
|
167
|
-
const cache = renderedCardNodeCache.current
|
|
168
166
|
const usedKeys = new Set<string>()
|
|
167
|
+
if (!render) return { inlineByKey, partsBySegment, usedKeys }
|
|
168
|
+
const cache = renderedCardNodeCache.current
|
|
169
169
|
// Card keys already emitted as a hoisted block (`b-<key>`). The same
|
|
170
170
|
// marker can legitimately appear twice in one message (LLM references
|
|
171
171
|
// the same entity twice); we hoist the FIRST occurrence and skip the
|
|
@@ -275,20 +275,99 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
|
|
|
275
275
|
partsBySegment.set(segIdx, parts)
|
|
276
276
|
}
|
|
277
277
|
})
|
|
278
|
-
|
|
279
|
-
// grow unbounded as a long message's markers change.
|
|
280
|
-
if (cache.size > usedKeys.size) {
|
|
281
|
-
for (const k of cache.keys()) {
|
|
282
|
-
if (!usedKeys.has(k)) cache.delete(k)
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
return { inlineByKey, partsBySegment }
|
|
278
|
+
return { inlineByKey, partsBySegment, usedKeys }
|
|
286
279
|
}, [hasMarkerSupport, chatRefs, renderEntityCard, segments])
|
|
287
280
|
|
|
281
|
+
// Drop cached nodes for markers no longer present so the cache can't grow
|
|
282
|
+
// unbounded as a long message's markers change. Deliberately an EFFECT,
|
|
283
|
+
// not part of the memo above: pruning is a mutation, and doing it in the
|
|
284
|
+
// render phase is non-idempotent (StrictMode's double-invoke, or a render
|
|
285
|
+
// React throws away, would evict nodes the committed tree still uses and
|
|
286
|
+
// remount the cards it was built to preserve).
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
const cache = renderedCardNodeCache.current
|
|
289
|
+
const usedKeys = renderingPlan?.usedKeys
|
|
290
|
+
if (!usedKeys) return
|
|
291
|
+
if (cache.size <= usedKeys.size) return
|
|
292
|
+
for (const k of [...cache.keys()]) {
|
|
293
|
+
if (!usedKeys.has(k)) cache.delete(k)
|
|
294
|
+
}
|
|
295
|
+
}, [renderingPlan])
|
|
296
|
+
|
|
297
|
+
// The plan is read through a REF inside the `<a>` override, not captured
|
|
298
|
+
// in the override's closure. `renderingPlan` is rebuilt on every text
|
|
299
|
+
// delta (its `segments` dep is a fresh array per delta), so depending on
|
|
300
|
+
// it here would give `cardComponentOverrides` a new identity every token
|
|
301
|
+
// — which re-creates the markdown engine's `components` memo and defeats
|
|
302
|
+
// its per-block `StreamingBlockRenderer` memoization for the WHOLE
|
|
303
|
+
// message, on every token. The ref keeps the override identity stable
|
|
304
|
+
// while still reading the current plan at call time.
|
|
305
|
+
const renderingPlanRef = useRef(renderingPlan)
|
|
306
|
+
renderingPlanRef.current = renderingPlan
|
|
307
|
+
const chatRefsRef = useRef(chatRefs)
|
|
308
|
+
chatRefsRef.current = chatRefs
|
|
309
|
+
|
|
310
|
+
// Ref-SET token. The override reads `chatRefsRef` at CALL time, but the
|
|
311
|
+
// markdown engine memoizes completed blocks on the override map's
|
|
312
|
+
// identity — so on a COMPLETED (no longer streaming) message, a ref that
|
|
313
|
+
// resolves late would never be re-read: nothing else about the renderer's
|
|
314
|
+
// props changes, the engine's memo bails, and the pill stays stuck on its
|
|
315
|
+
// fallback (`refMatch.title`, or the raw cardId) forever. Streaming is
|
|
316
|
+
// covered because `card://` blocks are excluded from the block cache; the
|
|
317
|
+
// completed path has no other escape.
|
|
318
|
+
//
|
|
319
|
+
// The token fingerprints VALUES, not just the key set: a later refs frame
|
|
320
|
+
// REPLACES the whole map for a send index, and the enrichment case that
|
|
321
|
+
// matters most — a `title` going from the raw cardId to the resolved
|
|
322
|
+
// document title — keeps the key set IDENTICAL. A key-only token would be
|
|
323
|
+
// unchanged, the override map would keep its identity, the engine's
|
|
324
|
+
// completed-block memo would bail, and the pill would stay on its fallback
|
|
325
|
+
// forever. Every `ChatRef` field is included because the host's
|
|
326
|
+
// `renderEntityCard` receives the whole ref and may render any of them.
|
|
327
|
+
//
|
|
328
|
+
// Still STABLE per text delta (the refs map doesn't change while tokens
|
|
329
|
+
// stream), and the cost is one pass over a handful of refs per CHANGED
|
|
330
|
+
// `chatRefs` identity — the dep list is unchanged.
|
|
331
|
+
//
|
|
332
|
+
// Fields are joined by `JSON.stringify`, NOT by a separator character.
|
|
333
|
+
// `title`, `preview` and `url` are free-form HOST strings, so any
|
|
334
|
+
// delimiter we pick is one the data may also contain: joined with a plain
|
|
335
|
+
// space, `title: 'a b'` + empty `url` fingerprints identically to
|
|
336
|
+
// `title: 'a'` + `url: 'b'`, silently reintroducing the stale-pill memo
|
|
337
|
+
// bail this token exists to fix. (An earlier revision used a literal NUL
|
|
338
|
+
// byte as the separator: collision-free in practice, but it made the
|
|
339
|
+
// source file itself binary to grep and diff tooling.) `JSON.stringify`
|
|
340
|
+
// escapes every field, so no value can forge a boundary.
|
|
341
|
+
//
|
|
342
|
+
// Caveat worth stating: `JSON.stringify` on `metadata` is key-ORDER
|
|
343
|
+
// dependent: two structurally equal objects built in different insertion
|
|
344
|
+
// orders fingerprint differently. Acceptable here because every refs map
|
|
345
|
+
// arrives from ONE decoder with a fixed field order, and the failure mode
|
|
346
|
+
// is a redundant re-render, never a stale pill.
|
|
347
|
+
const refsKey = useMemo(
|
|
348
|
+
() =>
|
|
349
|
+
Object.entries(chatRefs ?? {})
|
|
350
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
351
|
+
.map(([k, v]) =>
|
|
352
|
+
JSON.stringify([
|
|
353
|
+
k,
|
|
354
|
+
v.type,
|
|
355
|
+
v.sourceRepo ?? null,
|
|
356
|
+
v.id,
|
|
357
|
+
v.title,
|
|
358
|
+
v.url ?? null,
|
|
359
|
+
v.targetPlatform ?? null,
|
|
360
|
+
v.date ?? null,
|
|
361
|
+
v.preview ?? null,
|
|
362
|
+
v.metadata ?? null,
|
|
363
|
+
]),
|
|
364
|
+
)
|
|
365
|
+
.join('|'),
|
|
366
|
+
[chatRefs],
|
|
367
|
+
)
|
|
368
|
+
|
|
288
369
|
const cardComponentOverrides = useMemo(() => {
|
|
289
370
|
if (!hasMarkerSupport && !hasMentionSupport) return undefined
|
|
290
|
-
const refs = chatRefs ?? {}
|
|
291
|
-
const inlineByKey = renderingPlan?.inlineByKey
|
|
292
371
|
return {
|
|
293
372
|
// Override `<a>` to detect `card://` URLs emitted by `remarkCardLinks`.
|
|
294
373
|
// The render result was pre-computed in `renderingPlan` so block-level
|
|
@@ -316,7 +395,7 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
|
|
|
316
395
|
const cardType = stripped.slice(0, sepIdx)
|
|
317
396
|
const cardId = stripped.slice(sepIdx + 1)
|
|
318
397
|
const key = `${cardType}:${cardId}`
|
|
319
|
-
const inline = inlineByKey
|
|
398
|
+
const inline = renderingPlanRef.current?.inlineByKey.get(key)
|
|
320
399
|
if (inline != null) return inline
|
|
321
400
|
// Three fallback cases — keep them DISTINCT, never blur them
|
|
322
401
|
// together. Mixing them up (which the old code did by reaching
|
|
@@ -340,7 +419,7 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
|
|
|
340
419
|
// title from an unrelated ref — that hides the bug and
|
|
341
420
|
// deceives the reader into thinking they're looking at
|
|
342
421
|
// a real card.
|
|
343
|
-
const refMatch: ChatRef | undefined =
|
|
422
|
+
const refMatch: ChatRef | undefined = chatRefsRef.current?.[key]
|
|
344
423
|
if (refMatch) {
|
|
345
424
|
return <span className="text-ods-text-primary">{refMatch.title}</span>
|
|
346
425
|
}
|
|
@@ -382,7 +461,13 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
|
|
|
382
461
|
)
|
|
383
462
|
},
|
|
384
463
|
}
|
|
385
|
-
|
|
464
|
+
// DEPS ARE DELIBERATELY MINIMAL — every one of them is stable across a
|
|
465
|
+
// streaming turn (booleans + host-stable fn/component identities, both
|
|
466
|
+
// enforced by this file's `memo` comparator). The rendering plan and the
|
|
467
|
+
// refs MAP are read through refs above precisely so they do NOT appear
|
|
468
|
+
// here; `refsKey` is the one ref-derived dep, and it only moves when the
|
|
469
|
+
// ref set OR any ref VALUE does (see its definition).
|
|
470
|
+
}, [hasMarkerSupport, hasMentionSupport, renderMention, NavLinkAnchor, refsKey])
|
|
386
471
|
|
|
387
472
|
const getAvatarProps = () => {
|
|
388
473
|
const displayName = name || (isUser ? "User" : assistantType === 'mingo' ? "Mingo" : "Fae")
|
|
@@ -493,6 +578,13 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
|
|
|
493
578
|
"min-w-0 w-full break-words text-h4",
|
|
494
579
|
isError ? "text-ods-error" : "text-ods-text-primary",
|
|
495
580
|
)
|
|
581
|
+
// The engine's streaming path (atomic-block memoization +
|
|
582
|
+
// fence tail-completion + aria-live) applies ONLY to the
|
|
583
|
+
// actively streaming segment: last segment of a message
|
|
584
|
+
// that is still typing. On completion `isTyping` flips
|
|
585
|
+
// false and the engine does one authoritative
|
|
586
|
+
// whole-document parse.
|
|
587
|
+
const segmentIsStreaming = index === segments.length - 1 && !!isTyping
|
|
496
588
|
// No block markers in this segment → single
|
|
497
589
|
// SimpleMarkdownRenderer call (existing behaviour
|
|
498
590
|
// preserved for the vast majority of messages).
|
|
@@ -504,6 +596,7 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
|
|
|
504
596
|
textSize="compact"
|
|
505
597
|
additionalRemarkPlugins={cardRemarkPlugins}
|
|
506
598
|
componentOverrides={cardComponentOverrides}
|
|
599
|
+
streaming={segmentIsStreaming}
|
|
507
600
|
/>
|
|
508
601
|
</div>
|
|
509
602
|
)
|
|
@@ -531,6 +624,7 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
|
|
|
531
624
|
textSize="compact"
|
|
532
625
|
additionalRemarkPlugins={cardRemarkPlugins}
|
|
533
626
|
componentOverrides={cardComponentOverrides}
|
|
627
|
+
streaming={segmentIsStreaming && pIdx === parts.length - 1}
|
|
534
628
|
/>
|
|
535
629
|
)
|
|
536
630
|
}
|