diff --git a/src/app/(app)/help-center/help-center-content-href.test.ts b/src/app/(app)/help-center/help-center-content-href.test.ts index 0b71ea0a..7752d267 100644 --- a/src/app/(app)/help-center/help-center-content-href.test.ts +++ b/src/app/(app)/help-center/help-center-content-href.test.ts @@ -41,11 +41,19 @@ describe('composeOpenframeInAppContentUrl', () => { it('builds ticket deep links through the lib SSOT, on OUR tickets surface', () => { // `&search=` is what makes a ticket outside the first page of the list open // at all; `/help-center/tickets` is ours, `/tickets` is the ticket board. - for (const type of ['hubspot_ticket', 'hubspot_ticket_anon', 'hubspot_ticket_self']) { + for (const type of ['hubspot_ticket', 'hubspot_ticket_self']) { expect(href({ type, identifier: 'T-1' })).toBe('/help-center/tickets?ticket=T-1&search=T-1#ticket-T-1'); } }); + it('does not re-home anonymized known-issue tickets onto the viewer tickets list', () => { + // They belong to other customers, so `/help-center/tickets` cannot find them. + // No host override → the lib's `noComposedHref` leaves the card unlinked. + const composed = composeOpenframeInAppContentUrl({ type: 'hubspot_ticket_anon', identifier: 'T-1' }); + expect(composed.hostOverride).toBeUndefined(); + expect(composed.href).not.toContain('/help-center/tickets'); + }); + it('marks the overrides as an explicit host decision, through the in-app wrapper', () => { // A fetch-mode chat card ranks `hostOverride` above the url the content host // minted for its own surface. Rebuilding the result object anywhere in this diff --git a/src/app/(app)/help-center/help-center-content-href.ts b/src/app/(app)/help-center/help-center-content-href.ts index 5d23b74a..b966e36b 100644 --- a/src/app/(app)/help-center/help-center-content-href.ts +++ b/src/app/(app)/help-center/help-center-content-href.ts @@ -120,12 +120,17 @@ const composeLibContentUrl = makeComposeContentUrl({ // Mingo entity cards with a real in-app destination → soft-nav in the chat // (and same-origin nav on the pages) instead of bouncing to the content hub. // A HubSpot-ticket card opens the Help Center tickets list with that ticket - // pre-opened (every variant the RAG can emit); a FAQ card deep-links to its - // specific question via the `#faq-item-` hash the FAQ page dispatches on - // (same anchor the hub uses) — `faqItemAnchor` is the lib's SSOT for it. Both - // live under `/help-center`, so `isInAppHelpCenterHref` already covers them. + // pre-opened; a FAQ card deep-links to its specific question via the + // `#faq-item-` hash the FAQ page dispatches on (same anchor the hub + // uses) — `faqItemAnchor` is the lib's SSOT for it. Both live under + // `/help-center`, so `isInAppHelpCenterHref` already covers them. + // + // `hubspot_ticket_anon` is deliberately absent: it is a cross-customer known + // issue (`/known-issues-tickets`), not the viewer's ticket, so our session- + // scoped tickets list answers "No tickets found". The hub mints it with + // `url: null` on purpose; without an override the lib's `noComposedHref` + // keeps the card unlinked and "Ask Mingo" is its only action. hubspot_ticket: helpCenterTicketHref, - hubspot_ticket_anon: helpCenterTicketHref, hubspot_ticket_self: helpCenterTicketHref, faq: id => ({ href: `${HELP_CENTER_BASE}/faqs#${faqItemAnchor(id)}`, targetPlatform: null }), }, diff --git a/src/app/(app)/mingo/hooks/use-mingo-chat.ts b/src/app/(app)/mingo/hooks/use-mingo-chat.ts index 8e8e68e0..9522a2a5 100644 --- a/src/app/(app)/mingo/hooks/use-mingo-chat.ts +++ b/src/app/(app)/mingo/hooks/use-mingo-chat.ts @@ -1,6 +1,6 @@ 'use client'; -import type { AuthorType, MessageSegment } from '@flamingo-stack/openframe-frontend-core'; +import type { MessageSegment } from '@flamingo-stack/openframe-frontend-core'; import type { ChatContextItem } from '@flamingo-stack/openframe-frontend-core/components/chat'; import { useToast } from '@flamingo-stack/openframe-frontend-core/hooks'; import { useQueryClient } from '@tanstack/react-query'; @@ -26,26 +26,25 @@ export interface MingoSendContext { recentViews?: Array<{ type: string; id: string }>; } -interface ProcessedMessage { - id: string; - content: string | MessageSegment[]; - role: 'user' | 'assistant' | 'error'; +/** + * A reducer row, ready to render. + * + * An INTERSECTION with the lib's own `Message`, never a re-declaration of the + * fields this hook happens to touch. Everything the reducer and the history + * decoder stamp on a row is owned by the lib — `hidden`, `streamSeq`, + * `scrollAnchor`, and (Guide Mode V3) the answer's source/card/video metadata — + * and a hand-listed shape silently drops whatever it does not name. That has + * already cost a release once: `hidden` was missing here, so an + * auto-continuation directive the reader must never see rendered as a bubble. + * + * What this adds is only what THIS hook guarantees beyond the lib's optional + * fields: a resolved display name and a real timestamp. + */ +export type ProcessedMessage = CoreMessage & { name: string; - /** Entity-context chips for user bubbles (optimistic send only). */ - contextItems?: ChatContextItem[]; - /** Author avatar, resolved to a full/absolute URL (relative `imageUrl`s from - * GraphQL/the auth store are prefixed via `getFullImageUrl`). */ - avatar?: string | null; - authorType?: AuthorType; assistantType?: 'fae' | 'mingo'; timestamp: Date; - /** Synthetic row the model must see but the reader must not (e.g. an - * auto-continuation directive). Part of the conversation, never rendered — - * the lib's message list skips it. Every field-by-field seam between the - * reducer and the lib has to forward this or the raw directive text (or a - * bare author label) leaks into the transcript. */ - hidden?: boolean; -} +}; interface UseMingoChat { // Messages @@ -80,22 +79,34 @@ function isContentEqual(a: ProcessedMessage['content'], b: ProcessedMessage['con return JSON.stringify(a) === JSON.stringify(b); } +/** Keys compared by a dedicated rule above, and therefore skipped by the + * catch-all sweep. */ +const STRUCTURALLY_COMPARED_KEYS: ReadonlySet = new Set(['content', 'timestamp']); + /** Whether two processed messages render identically — drives reference reuse - * so the lib's reference-equality memo can skip unchanged messages. */ + * so the lib's reference-equality memo can skip unchanged messages. + * + * The remaining fields are swept generically rather than listed. A list has to + * be extended for every field the lib adds to a row, and forgetting to is + * invisible: the pair compares equal, the previous object is reused, and the + * new field never reaches the screen — which is the same class of bug as the + * `hidden` omission this type's doc-comment describes, one step later in the + * pipeline. The sweep compares by reference, exactly as the `contextItems` + * rule it replaces did (that value is set once on the optimistic send and + * never mutated), so a field the reducer rebuilds per chunk costs a re-render + * rather than a stale bubble — the safe direction of the two. */ function isSameProcessedMessage(a: ProcessedMessage, b: ProcessedMessage): boolean { - return ( - a.role === b.role && - a.name === b.name && - a.avatar === b.avatar && - a.authorType === b.authorType && - a.assistantType === b.assistantType && - a.hidden === b.hidden && - a.timestamp.getTime() === b.timestamp.getTime() && - // Reference equality — contextItems is set once on the optimistic send and - // never mutated, so a stable reference means the chips are unchanged. - a.contextItems === b.contextItems && - isContentEqual(a.content, b.content) - ); + if (a.timestamp.getTime() !== b.timestamp.getTime()) return false; + if (!isContentEqual(a.content, b.content)) return false; + + return shallowEqualExcept(a, b, STRUCTURALLY_COMPARED_KEYS); +} + +/** Own-key shallow equality, minus the keys the caller compares itself. */ +function shallowEqualExcept(a: T, b: T, skip: ReadonlySet): boolean { + const keys = Object.keys(a); + if (keys.length !== Object.keys(b).length) return false; + return keys.every(key => skip.has(key) || a[key as keyof T] === b[key as keyof T]); } export function useMingoChat(dialogId: string | null): UseMingoChat { @@ -140,11 +151,12 @@ export function useMingoChat(dialogId: string | null): UseMingoChat { const processed: ProcessedMessage[] = []; for (const msg of stripPendingApprovals(currentMessages)) { + // SPREAD FIRST, then override. The row already carries everything the lib + // stamped on it; this hook only resolves the two fields it owns. Listing + // the fields to copy instead is what drops the lib's own metadata (see + // `ProcessedMessage`). processed.push({ - id: msg.id, - content: msg.content, - role: msg.role, - authorType: msg.authorType, + ...msg, name: msg.name || 'Unknown', // `msg.avatar` is a relative `imageUrl` (GraphQL owner image or the // optimistic auth-store avatar); resolve to a full URL once here so @@ -152,9 +164,6 @@ export function useMingoChat(dialogId: string | null): UseMingoChat { avatar: getFullImageUrl(msg.avatar) ?? null, assistantType: msg.assistantType as 'fae' | 'mingo' | undefined, timestamp: msg.timestamp || new Date(), - contextItems: msg.contextItems, - // Carry the invisible-but-real flag through (see ProcessedMessage). - ...(msg.hidden ? { hidden: true as const } : {}), }); } diff --git a/src/app/(app)/mingo/hooks/use-mingo-dialog-selection.ts b/src/app/(app)/mingo/hooks/use-mingo-dialog-selection.ts index 60015170..d9a91d64 100644 --- a/src/app/(app)/mingo/hooks/use-mingo-dialog-selection.ts +++ b/src/app/(app)/mingo/hooks/use-mingo-dialog-selection.ts @@ -18,7 +18,11 @@ import { featureFlags } from '@/lib/feature-flags'; import type { ApprovalStatus } from '../../tickets/constants'; import { APPROVAL_STATUS, ASSISTANT_CONFIG, CHAT_TYPE, MESSAGE_TYPE } from '../../tickets/constants'; import { extractGraphQlData } from '../../tickets/utils/graphql'; -import { GET_MINGO_DIALOG_QUERY, getMingoDialogMessagesQuery } from '../queries/dialogs-queries'; +import { + GET_MINGO_DIALOG_QUERY, + getMingoDialogMessagesQuery, + normalizeAskMessageData, +} from '../queries/dialogs-queries'; import { useApproveRequestMutation, useRejectRequestMutation } from '../services/mingo-api-service'; import { useMingoMessagesStore } from '../stores/mingo-messages-store'; import type { DialogResponse, Message, MessagePage, MessagesResponse } from '../types'; @@ -232,7 +236,16 @@ export function useMingoDialogSelection() { const { edges, pageInfo } = response.data.data.messages; const allMessages = edges.map(edge => edge.node); - const adminMessages = allMessages.filter(msg => msg.chatType === CHAT_TYPE.ADMIN); + // The ONE parse point, so the ask-intro alias is undone before any reader + // sees a row (see `ASK_INTRO_ALIAS`). `normalizeAskMessageData` returns its + // input by reference when there is nothing to rename, so a page without ASK + // rows is not copied. + const adminMessages = allMessages + .filter(msg => msg.chatType === CHAT_TYPE.ADMIN) + .map(msg => { + const messageData = normalizeAskMessageData(msg.messageData); + return messageData === msg.messageData ? msg : { ...msg, messageData }; + }); return { messages: adminMessages, pageInfo }; }, diff --git a/src/app/(app)/mingo/hooks/use-mingo-unified-chat-state.test.ts b/src/app/(app)/mingo/hooks/use-mingo-unified-chat-state.test.ts index 97746a03..1698c281 100644 --- a/src/app/(app)/mingo/hooks/use-mingo-unified-chat-state.test.ts +++ b/src/app/(app)/mingo/hooks/use-mingo-unified-chat-state.test.ts @@ -1,5 +1,176 @@ +import type { ChatRef, SlashCommandSummary } from '@flamingo-stack/openframe-frontend-core/components/chat'; import { describe, expect, it } from 'vitest'; -import { needsAllChatsScope } from './use-mingo-unified-chat-state'; +import type { ProcessedMessage } from './use-mingo-chat'; +import { + buildMingoDisplayCommand, + hasMingoDisplayCommand, + mapMingoMessageToUnified, + needsAllChatsScope, +} from './use-mingo-unified-chat-state'; + +const TIMESTAMP = new Date('2026-08-26T12:00:00Z'); + +function assistantMessage(fields: Record = {}): ProcessedMessage { + return { + id: 'assistant-turn', + role: 'assistant', + content: 'Install the agent from the Devices page.', + name: 'Mingo', + timestamp: TIMESTAMP, + ...fields, + } as ProcessedMessage; +} + +/** + * The seam between the reducer's rows and what the lib renders. Its whole job is + * to be exhaustive WITHOUT enumerating: the lib keeps adding per-message metadata + * (Guide Mode V3's `sources` / card refs being the current batch), and a mapper + * that lists fields drops every one it was not updated for — silently, because + * the message still renders, just without the part the new field carried. + */ +describe('mapMingoMessageToUnified', () => { + it('forwards metadata the mapper never names', () => { + // `sources` / `refs` are Guide Mode V3's per-answer metadata. The mapper + // does not mention them by name — that is the point: they ride through + // because it spreads what it did not destructure. + const sources = [{ index: 1, name: 'Install the agent', path: 'docs/agent.md', documentType: 'markdown' }]; + const refs = [{ type: 'video', id: 'MdFJNoJeqZQ', title: 'Install', url: null }]; + const message = mapMingoMessageToUnified( + assistantMessage({ streamSeq: 42, scrollAnchor: 'top', hidden: true, sources, refs }), + ); + + expect(message).toMatchObject({ id: 'assistant-turn', streamSeq: 42, scrollAnchor: 'top', hidden: true }); + // By reference — the lib's message memo compares this way. + expect(message.sources).toBe(sources); + expect(message.refs).toBe(refs); + }); + + it('moves a segment list into `segments` and empties `content`', () => { + const segments = [{ type: 'text' as const, text: '## Install the agent' }]; + const message = mapMingoMessageToUnified(assistantMessage({ content: segments })); + + expect(message.segments).toBe(segments); + expect(message.content).toBe(''); + }); + + it('drops the host identity on assistant rows so the lib renders its own', () => { + const message = mapMingoMessageToUnified(assistantMessage({ avatar: '/mingo.png', authorType: 'admin' })); + + expect(message.name).toBeUndefined(); + expect(message.avatar).toBeUndefined(); + expect(message.authorType).toBeUndefined(); + }); + + it('carries the real sender identity and context chips on user rows', () => { + const contextItems = [{ type: 'DEVICE', id: 'device-42' }]; + const message = mapMingoMessageToUnified( + assistantMessage({ + role: 'user', + name: 'Ada Lovelace', + avatar: 'https://cdn.example/ada.png', + authorType: 'admin', + contextItems, + }), + ); + + expect(message).toMatchObject({ + role: 'user', + name: 'Ada Lovelace', + avatar: 'https://cdn.example/ada.png', + authorType: 'admin', + contextItems, + }); + }); + + it('degrades an unresolved sender name to the lib fallback', () => { + expect(mapMingoMessageToUnified(assistantMessage({ role: 'user', name: 'Unknown' })).name).toBeUndefined(); + }); + + it('folds an error row into the assistant bubble', () => { + expect(mapMingoMessageToUnified(assistantMessage({ role: 'error' })).role).toBe('assistant'); + }); +}); + +const displayCommands: SlashCommandSummary[] = [ + { + id: 'onboarding-guides', + description: 'Onboarding guides', + primarySourceId: 'onboarding-guides', + actions: [{ id: 'display', label: 'Display' }], + }, + { + id: 'openframe-docs', + description: 'Product documentation', + primarySourceId: 'openframe-docs', + actions: [{ id: 'display', label: 'Display' }], + }, + { + id: 'search-only', + description: 'Webinars', + primarySourceId: 'webinars', + actions: [{ id: 'search', label: 'Search' }], + }, +]; + +function ref(fields: Partial & Pick): ChatRef { + return { url: null, ...fields }; +} + +describe('buildMingoDisplayCommand', () => { + it('resolves the command from the ref’s own sourceRepo', () => { + // V3 hands the registry table id back with the card, so nothing is guessed. + expect( + buildMingoDisplayCommand( + ref({ + type: 'onboarding_guide', + id: '88dd40cc', + title: 'Install the OpenFrame Agent on Windows', + sourceRepo: 'onboarding-guides', + metadata: { slug: 'install-the-openframe-agent-on-windows' }, + }), + displayCommands, + ), + ).toBe('/onboarding-guides display "install-the-openframe-agent-on-windows"'); + }); + + it('falls back to the documentType→table map for a ref with no sourceRepo', () => { + expect( + buildMingoDisplayCommand(ref({ type: 'markdown', id: 'guide-id', title: 'Getting Started' }), displayCommands), + ).toBe('/openframe-docs display "Getting Started"'); + }); + + it('escapes a value that would otherwise break out of the quotes', () => { + // `\` before `"`, so a trailing backslash cannot smuggle the close quote + // past a parser that honours JS-style escapes. + expect( + buildMingoDisplayCommand( + ref({ type: 'markdown', id: 'doc', title: 'x', metadata: { slug: 'a\\b"c' } }), + displayCommands, + ), + ).toBe('/openframe-docs display "a\\\\b\\"c"'); + }); + + it('returns null when the resolved source has no display command', () => { + // `search-only` covers the same source but would run a query instead of + // dumping the row — offering Display for it would be a lie. + expect(buildMingoDisplayCommand(ref({ type: 'webinar', id: 'w-1', title: 'Pricing' }), displayCommands)).toBeNull(); + }); + + it('returns null for a document type the catalog does not cover', () => { + expect(buildMingoDisplayCommand(ref({ type: 'unknown_type', id: 'x', title: 'X' }), displayCommands)).toBeNull(); + }); +}); + +describe('hasMingoDisplayCommand', () => { + it('is true when the catalog can display something', () => { + expect(hasMingoDisplayCommand(displayCommands)).toBe(true); + }); + + it('is false for the V2 catalog, where the affordance must not render at all', () => { + expect(hasMingoDisplayCommand([displayCommands[2]])).toBe(false); + expect(hasMingoDisplayCommand([])).toBe(false); + }); +}); /** * The rail's scope is a filter over the LIST, but a dialog can arrive without going diff --git a/src/app/(app)/mingo/hooks/use-mingo-unified-chat-state.ts b/src/app/(app)/mingo/hooks/use-mingo-unified-chat-state.ts index 5e2a4106..517c5815 100644 --- a/src/app/(app)/mingo/hooks/use-mingo-unified-chat-state.ts +++ b/src/app/(app)/mingo/hooks/use-mingo-unified-chat-state.ts @@ -30,12 +30,20 @@ import type { DialogItem, DialogTokenUsage, MessageSegment, + SlashCommandSummary, StreamingPhase, UnifiedChatMessage, UnifiedChatState, UnifiedSendMessageOptions, } from '@flamingo-stack/openframe-frontend-core/components/chat'; -import { buildDiscussPrompt } from '@flamingo-stack/openframe-frontend-core/components/chat'; +import { + buildDiscussPrompt, + defaultTableIdForDocumentType, + formatSingularLookupInvocation, + sanitizeTitleForChat, + useSlashCommandRegistry, +} from '@flamingo-stack/openframe-frontend-core/components/chat'; +import { useChatRuntime } from '@flamingo-stack/openframe-frontend-core/contexts'; import { useCallback, useDeferredValue, useMemo, useState } from 'react'; import { useAuthStore } from '@/app/(auth)/auth/stores/auth-store'; import { useAiModelStatus } from '@/app/hooks/use-ai-model'; @@ -43,7 +51,7 @@ import { EVENT_SUBTYPE, trackDashboardActivity } from '@/lib/analytics'; import { CONTEXT_ITEMS_MAX, RECENT_VIEWS_MAX } from '../context/context-types'; import { useMingoContextStore } from '../stores/mingo-context-store'; import { useMingoMessagesStore } from '../stores/mingo-messages-store'; -import { type MingoSendContext, useMingoChat } from './use-mingo-chat'; +import { type MingoSendContext, type ProcessedMessage, useMingoChat } from './use-mingo-chat'; import { useMingoDialogActions } from './use-mingo-dialog-actions'; import { useMingoDialogSelection } from './use-mingo-dialog-selection'; import { useMingoDialogs } from './use-mingo-dialogs'; @@ -127,9 +135,101 @@ export function needsAllChatsScope(ownerUserId: string | undefined, currentUserI return ownerUserId !== currentUserId; } +/** Slash-command action that dumps a row's body into the chat verbatim. */ +const DISPLAY_ACTION_ID = 'display'; + +/** + * ProcessedMessage → UnifiedChatMessage. + * + * Destructure-and-spread, NOT a field-by-field rebuild. Every field the lib + * stamps on a row and later reads back off it — `streamSeq`, `scrollAnchor`, + * `hidden`, and Guide Mode V3's per-answer source/card/video metadata — rides + * through `metadata` without this seam having to name it. A rebuild silently + * drops whatever it does not list, and the drop is invisible: the message still + * renders, just without the part the new field carried. Only the fields whose + * SHAPE differs between the two types are handled explicitly below. + */ +export function mapMingoMessageToUnified(message: ProcessedMessage): UnifiedChatMessage { + const { content, role: sourceRole, name, avatar, authorType, assistantType, contextItems, ...metadata } = message; + + // The lib folds 'error' into the assistant bubble and re-derives the assistant + // identity (brand icon + "Mingo") itself, which is why `assistantType` is + // destructured off rather than forwarded. + const role: 'user' | 'assistant' = sourceRole === 'user' ? 'user' : 'assistant'; + + // USER bubbles carry the real sender identity — the admin's name, avatar and + // `authorType` (accent name colour) — so the drawer reads like the standalone + // /mingo page instead of a hardcoded "You". A missing/Unknown name degrades to + // the lib's own fallback. + const identity = + role === 'user' ? { name: name && name !== 'Unknown' ? name : undefined, avatar: avatar ?? null, authorType } : {}; + + // Entity-context chips under a user bubble (Figma 1:6437). They ride the + // optimistic send and the realtime MESSAGE_REQUEST echo; the lib resolves each + // chip's icon from `contextPicker.entityTypes` by `type`. + const context = role === 'user' && contextItems?.length ? { contextItems } : {}; + + // Segment lists travel in `segments`; `content` must then be the empty string, + // which is what tells the lib to render the structured form. + const body = Array.isArray(content) ? { content: '', segments: content } : { content }; + + return { ...metadata, role, ...body, ...identity, ...context }; +} + +function supportsDisplay(command: SlashCommandSummary): boolean { + return command.actions.some(action => action.id === DISPLAY_ACTION_ID); +} + +/** + * The chat message that displays `reference`, or null when the catalog has no + * command for it. + * + * Two table-id lookups, in priority order: + * 1. `reference.sourceRepo` — Guide Mode V3, where the MCP metadata hands the + * registry table id back with the card, so no mapping is guessed; + * 2. the lib's documentType→table map, for refs that carry no repo. + * + * Both are matched against `SlashCommandSummary.primarySourceId`, and only a + * command that declares the `display` action counts — the others resolve the + * same source but would run a search instead of dumping the row. + */ +export function buildMingoDisplayCommand(reference: ChatRef, commands: SlashCommandSummary[]): string | null { + const tableIds = [reference.sourceRepo, defaultTableIdForDocumentType(reference.type)]; + const command = tableIds + .filter((tableId): tableId is string => Boolean(tableId)) + .map(tableId => commands.find(candidate => candidate.primarySourceId === tableId && supportsDisplay(candidate))) + .find((candidate): candidate is SlashCommandSummary => Boolean(candidate)); + if (!command) return null; + + // The slug is what the backend resolves fastest; the title is the V2 fallback + // and the id the last resort, so a card with neither still opens something. + const slug = typeof reference.metadata?.slug === 'string' ? reference.metadata.slug : ''; + const value = slug || sanitizeTitleForChat(reference.title) || reference.id; + + // `formatSingularLookupInvocation` is the SSOT for the quoting the backend's + // slash parser consumes (it escapes `\` BEFORE `"`, so a value ending in a + // backslash cannot smuggle the closing quote past the parser). The action word + // rides in the command position because that IS the grammar the parser reads: + // `/ display ""`. + return formatSingularLookupInvocation(`${command.id} ${DISPLAY_ACTION_ID}`, value); +} + +/** Whether ANY command in the catalog can display a row — the gate on offering + * the affordance at all (see `displayRef` below). */ +export function hasMingoDisplayCommand(commands: SlashCommandSummary[]): boolean { + return commands.some(supportsDisplay); +} + export function useMingoUnifiedChatState(): MingoUnifiedChat { const { aiModel } = useAiModelStatus(); + // Same react-query entry ``'s onboarding-card list reads + // (keyed on `commandsUrl` alone), so this adds no request of its own. What the + // catalog contains is decided upstream by `chat-slash-command-visibility.ts`: + // the full server-owned set under Guide Mode V3, the four V2 commands without it. + const commandsUrl = useChatRuntime()?.endpoints.commandsUrl ?? ''; + const { commands: slashCommands } = useSlashCommandRegistry(commandsUrl, { enabled: Boolean(commandsUrl) }); + const { activeDialogId, setActiveDialogId, resetUnread, addMessage, tokenUsageByDialog } = useMingoMessagesStore(); // Server-side dialog search. The embeddable chat's search bar emits the @@ -252,14 +352,8 @@ export function useMingoUnifiedChatState(): MingoUnifiedChat { }, [activeDialogId, tokenUsageByDialog, dialogData?.tokenUsage]); // ─── Messages: ProcessedMessage[] → UnifiedChatMessage[] ────────────────── - // The lib re-derives assistantType itself and folds 'error' into the - // assistant bubble (same as the /mingo list). For USER bubbles we surface the - // real sender identity — the admin's name (GraphQL `owner.user` / optimistic - // auth-store), avatar, and `authorType` — so the embeddable chat matches the - // standalone /mingo page: the sender shows up as the admin (accent name color) - // instead of the hardcoded "You". Assistant rows keep the lib's Mingo defaults - // (brand icon + "Mingo"), and a missing/Unknown name degrades to the lib's - // "You" fallback. + // The row-level mapping is `mapMingoMessageToUnified` (above, and unit-tested); + // what lives here is only the CACHING around it. // `processedMessages` hands back referentially-stable objects for unchanged // messages (see useMingoChat's reconciliation), so keying a WeakMap by the // source object yields a stable UnifiedChatMessage too — the lib's reference- @@ -273,45 +367,7 @@ export function useMingoUnifiedChatState(): MingoUnifiedChat { return processedMessages.map(m => { const cached = cache.get(m); if (cached) return cached; - - const role: 'user' | 'assistant' = m.role === 'user' ? 'user' : 'assistant'; - const identity = - role === 'user' - ? { - name: m.name && m.name !== 'Unknown' ? m.name : undefined, - avatar: m.avatar ?? null, - authorType: m.authorType, - } - : {}; - // Forward the real message timestamp so the lib renders the actual - // send time AND its memoized message keeps a stable `getTime()` across - // realtime chunks (a missing/`new Date()` timestamp would re-render the - // whole list and collapse open menus on every chunk). - // Forward attached entity-context items on user messages so the lib - // renders the read-only chip strip under the bubble (Figma 1:6437). They - // ride the optimistic message (full `ChatContextItem` with labels) and the - // realtime `MESSAGE_REQUEST` echo; the lib resolves each chip's icon from - // `contextPicker.entityTypes` by `type`. - const context = role === 'user' && m.contextItems?.length ? { contextItems: m.contextItems } : {}; - // `hidden` is load-bearing, NOT cosmetic: it marks synthetic rows (e.g. - // an auto-continuation directive) that the model must see but the reader - // must not. This field-by-field rebuild drops anything not listed, so the - // flag has to be forwarded explicitly — without it the lib's message list - // has nothing to skip on and renders an author label with no body (or the - // raw directive text) in the transcript. - const visibility = m.hidden ? { hidden: true as const } : {}; - const unified: UnifiedChatMessage = Array.isArray(m.content) - ? { - id: m.id, - role, - content: '', - segments: m.content, - timestamp: m.timestamp, - ...identity, - ...context, - ...visibility, - } - : { id: m.id, role, content: m.content, timestamp: m.timestamp, ...identity, ...context, ...visibility }; + const unified = mapMingoMessageToUnified(m); cache.set(m, unified); return unified; }); @@ -482,12 +538,30 @@ export function useMingoUnifiedChatState(): MingoUnifiedChat { }, [sendMessage], ); - // Display dumps a row's raw body via a `/ display ""` slash command, which - // only the hub's SSE transport has a registry for — the agent has no verbatim-dump - // path. Left UNDEFINED rather than stubbed: the lib gates the affordance on this - // callback, so a stub renders a dead "Display" row where "Ask Mingo" (which works) - // belongs. The lib's type makes it optional for exactly this case. - const displayRef: UnifiedChatState['displayRef'] = undefined; + // Display dumps a row's raw body via a `/ display ""` slash command. + // Guide Mode V3 is what makes that reachable from Mingo: the agent resolves the + // command through MCP `prompts/get` and forces the tool the prompt declares, so + // the same catalog the composer autocompletes from is the one that runs here. + const handleDisplayRef = useCallback( + (reference: ChatRef) => { + const text = buildMingoDisplayCommand(reference, slashCommands); + if (!text) { + console.warn( + `[MingoChat] displayRef: no display command for type="${reference.type}" sourceRepo="${reference.sourceRepo}"; ignoring click`, + ); + return; + } + void sendMessage(text); + }, + [sendMessage, slashCommands], + ); + // UNDEFINED, not a stub, while the catalog can't display anything: the lib gates + // the affordance on this callback's presence, so a stub renders a dead "Display" + // row where "Ask Mingo" (which works) belongs. Its type is optional for exactly + // this case — which is also the V2 state, where the trimmed catalog has none. + const displayRef: UnifiedChatState['displayRef'] = hasMingoDisplayCommand(slashCommands) + ? handleDisplayRef + : undefined; const state = useMemo( () => ({ diff --git a/src/app/(app)/mingo/queries/dialogs-queries.test.ts b/src/app/(app)/mingo/queries/dialogs-queries.test.ts new file mode 100644 index 00000000..0e07a622 --- /dev/null +++ b/src/app/(app)/mingo/queries/dialogs-queries.test.ts @@ -0,0 +1,89 @@ +/** + * The ASK intro alias and its inverse are pinned TOGETHER on purpose. + * + * The alias exists because `AskData.text` is `String` while the other `text` + * fields in the same selection set are `String!` — GraphQL rejects the whole + * query over that, not just the fragment. `normalizeAskMessageData` maps the + * alias back so the core lib sees the same shape the live NATS chunk carries. + * An alias without its inverse is silent: every ask intro simply disappears on + * reload. So neither half gets a test of its own. + */ + +import { describe, expect, it } from 'vitest'; +import { ASK_INTRO_ALIAS, getMingoDialogMessagesQuery, normalizeAskMessageData } from './dialogs-queries'; + +/** The body of `... on { … }` in the messages query. */ +function fragmentBody(query: string, typeName: string): string { + const start = query.indexOf(`... on ${typeName} {`); + expect(start, `no fragment for ${typeName}`).toBeGreaterThan(-1); + return query.slice(start, query.indexOf('}', start)); +} + +describe('getMingoDialogMessagesQuery', () => { + const query = getMingoDialogMessagesQuery(); + + it('fetches the Guide Mode V3 source metadata through the GuideData payload', () => { + expect(fragmentBody(query, 'GuideData')).toContain('payload'); + }); + + it('does not select GuideData.text', () => { + // Payload-only records persist `text` as an empty non-null string, which + // would replay as an empty text segment above the answer. + expect(fragmentBody(query, 'GuideData')).not.toMatch(/^\s*text\s*$/m); + }); + + it('fetches the ask intro under the alias, never as a bare `text`', () => { + expect(fragmentBody(query, 'AskData')).toContain(`${ASK_INTRO_ALIAS}: text`); + expect(fragmentBody(query, 'AskData')).not.toMatch(/^\s*text\s*$/m); + expect(fragmentBody(query, 'AskData')).toContain('question'); + }); + + it('keeps remote write-tool approvals selecting their public arguments', () => { + // The approval card renders `toolTitle` + `toolExplanation` + the public + // arguments; MCP provider and trust metadata never reach the frontend. + const approval = fragmentBody(query, 'ApprovalRequestData'); + expect(approval).toContain('toolTitle'); + expect(approval).toContain('toolExplanation'); + expect(approval).toContain('toolCallArguments'); + }); +}); + +describe('normalizeAskMessageData', () => { + it('maps the alias back onto `text`', () => { + expect( + normalizeAskMessageData([ + { type: 'ASK', [ASK_INTRO_ALIAS]: 'Docs, or your workspace?', question: 'Which?', options: [] }, + ]), + ).toEqual([{ type: 'ASK', text: 'Docs, or your workspace?', question: 'Which?', options: [] }]); + }); + + it('normalizes a single non-list messageData', () => { + expect(normalizeAskMessageData({ type: 'ASK', [ASK_INTRO_ALIAS]: 'Pick one', question: 'Which?' })).toEqual({ + type: 'ASK', + text: 'Pick one', + question: 'Which?', + }); + }); + + it('drops a null intro instead of writing `text: null`', () => { + // The live chunk omits the intro entirely when there is none — the two + // shapes have to stay identical. + expect(normalizeAskMessageData([{ type: 'ASK', [ASK_INTRO_ALIAS]: null, question: 'Which?' }])).toEqual([ + { type: 'ASK', question: 'Which?' }, + ]); + }); + + it('passes other rows through by reference', () => { + const guide = { type: 'GUIDE', payload: { sources: [] } }; + const input = [guide]; + const output = normalizeAskMessageData(input); + + expect(output).toBe(input); + expect(output[0]).toBe(guide); + }); + + it('tolerates a missing payload', () => { + expect(normalizeAskMessageData(undefined)).toBeUndefined(); + expect(normalizeAskMessageData(null)).toBeNull(); + }); +}); diff --git a/src/app/(app)/mingo/queries/dialogs-queries.ts b/src/app/(app)/mingo/queries/dialogs-queries.ts index 7dc2f7b5..7cf1af00 100644 --- a/src/app/(app)/mingo/queries/dialogs-queries.ts +++ b/src/app/(app)/mingo/queries/dialogs-queries.ts @@ -1,3 +1,54 @@ +/** + * Response name the ASK card's intro sentence is fetched under. + * + * `AskData.text` is `String` while `TextData`/`ThinkingData`/`SystemData.text` + * are `String!`, and GraphQL's SameResponseShape rule refuses to merge + * same-named fields with different nullability into one selection set — the + * WHOLE query is rejected, not just that fragment. An alias gives the ask intro + * its own response name, which is never merged with the others. + * + * Everything downstream — the core lib's history decoder included — reads + * `text`, so `normalizeAskMessageData` maps it back at the single parse point. + * Change one of the two and you must change the other; the pair is pinned + * together in `dialogs-queries.test.ts`. + */ +export const ASK_INTRO_ALIAS = 'askIntro'; + +/** + * Undo `ASK_INTRO_ALIAS` on one persisted `messageData` row, so an ASK reaches + * the core lib in the SAME shape the live NATS chunk carries + * (`{ type, text, question, options }`). + * + * Returns the row BY REFERENCE when there is nothing to rename — the caller + * uses that identity to avoid copying a page of history it did not change. + */ +function normalizeAskRow(row: unknown): unknown { + if (!row || typeof row !== 'object') return row; + const fields = row as Record; + if (fields.type !== 'ASK' || !(ASK_INTRO_ALIAS in fields)) return row; + + const { [ASK_INTRO_ALIAS]: intro, ...rest } = fields; + // A null intro is DROPPED rather than written back as `text: null`: the live + // chunk simply omits it, and the two shapes have to stay identical. + return typeof intro === 'string' && intro ? { ...rest, text: intro } : rest; +} + +/** + * `normalizeAskRow` over a message's `messageData`, which the chat service sends + * as either a single object or a list. + */ +export function normalizeAskMessageData(messageData: T): T { + if (!Array.isArray(messageData)) return normalizeAskRow(messageData) as T; + + let changed = false; + const normalized = messageData.map(row => { + const next = normalizeAskRow(row); + if (next !== row) changed = true; + return next; + }); + return (changed ? normalized : messageData) as T; +} + export const GET_MINGO_DIALOGS_QUERY = ` query GetDialogs($filter: DialogFilterInput, $pagination: CursorPaginationInput, $search: String) { dialogs(filter: $filter, pagination: $pagination, search: $search) { @@ -105,6 +156,25 @@ export const GET_MINGO_DIALOG_QUERY = ` `; export function getMingoDialogMessagesQuery() { + // Guide Mode V3 persists an answer's source metadata (product-doc sources, + // video refs, card refs) in its own `GUIDE` row, separate from the answer + // text, and its clarification cards in `ASK` rows. Fetch both so a reloaded + // dialog renders exactly what the live turn did. `GuideData.text` is + // deliberately NOT selected: payload-only records persist it as an empty + // string, which would replay as an empty text segment. + const guideModeFragment = `... on GuideData { + payload + } + + ... on AskData { + ${ASK_INTRO_ALIAS}: text + question + options { + label + description + } + }`; + return ` query GetAllMessages($dialogId: ID!, $cursor: String, $limit: Int, $sortField: String, $sortDirection: SortDirection) { messages( @@ -149,6 +219,8 @@ export function getMingoDialogMessagesQuery() { text } + ${guideModeFragment} + ... on ExecutingToolData { type integratedToolType diff --git a/src/app/components/chat-slash-command-visibility.test.ts b/src/app/components/chat-slash-command-visibility.test.ts new file mode 100644 index 00000000..4d396e8e --- /dev/null +++ b/src/app/components/chat-slash-command-visibility.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { applySlashCommandVisibility } from './chat-slash-command-visibility'; + +const commands = [ + { id: 'docs' }, + { id: 'my-tickets' }, + { id: 'open-ticket' }, + { id: 'update-ticket' }, + { id: 'roadmap' }, + { id: 'webinars' }, +]; + +describe('applySlashCommandVisibility', () => { + it('keeps the whole server-owned catalog under Guide Mode V3', () => { + const payload = { commands }; + // Identity, not just equality: the caller reads it as "leave the response + // alone" and skips re-serializing it. + expect(applySlashCommandVisibility(payload, true)).toBe(payload); + }); + + it('trims to the V2 command set when remote tools are off', () => { + expect(applySlashCommandVisibility({ commands }, false).commands).toEqual([ + { id: 'docs' }, + { id: 'my-tickets' }, + { id: 'open-ticket' }, + { id: 'update-ticket' }, + ]); + }); + + it('returns the payload unchanged when the V2 catalog needs no trimming', () => { + const payload = { commands: [{ id: 'docs' }, { id: 'my-tickets' }] }; + expect(applySlashCommandVisibility(payload, false)).toBe(payload); + }); + + it('passes a shape it does not recognise straight through', () => { + const payload = {}; + expect(applySlashCommandVisibility(payload, false)).toBe(payload); + }); +}); diff --git a/src/app/components/chat-slash-command-visibility.ts b/src/app/components/chat-slash-command-visibility.ts index 24029ced..a79acbf9 100644 --- a/src/app/components/chat-slash-command-visibility.ts +++ b/src/app/components/chat-slash-command-visibility.ts @@ -1,5 +1,13 @@ 'use client'; +import { featureFlags, whenFeatureFlagsResolved } from '@/lib/feature-flags'; + +/** + * The commands Guide Mode V2 ships. Guide Mode V3 (`ai-mingo-remote-tools`) + * resolves its catalog through Hub MCP prompts, so under V3 this list is not a + * subset to trim to — it is a stale snapshot of one, and the whole server-owned + * catalog is what the agent can actually run. + */ export const VISIBLE_SLASH_COMMAND_IDS: ReadonlySet = new Set([ 'docs', 'my-tickets', @@ -11,10 +19,24 @@ export const VISIBLE_SLASH_COMMAND_IDS: ReadonlySet = new Set([ const COMMANDS_PATH = '/api/docs/commands'; /** Shape of the commands response — the subset this filter needs. */ -interface CommandsResponse { +export interface CommandsResponse { commands?: Array<{ id?: string }>; } +/** + * Trim the catalog to the V2 command set, or pass it through under V3. + * + * Returns the ORIGINAL object by reference when nothing was removed — that + * identity is the caller's "leave the response alone" signal, so an untouched + * catalog is never re-serialized (which would rewrite its headers for nothing). + */ +export function applySlashCommandVisibility(payload: CommandsResponse, remoteToolsEnabled: boolean): CommandsResponse { + if (remoteToolsEnabled || !Array.isArray(payload.commands)) return payload; + + const commands = payload.commands.filter(cmd => cmd.id !== undefined && VISIBLE_SLASH_COMMAND_IDS.has(cmd.id)); + return commands.length === payload.commands.length ? payload : { ...payload, commands }; +} + let installed = false; function isCommandsRequest(input: RequestInfo | URL): boolean { @@ -27,7 +49,8 @@ function isCommandsRequest(input: RequestInfo | URL): boolean { } /** - * Trim the server-owned command catalog down to the commands openframe ships. + * Trim the server-owned command catalog down to the commands openframe ships, + * unless Guide Mode V3 is on for this tenant. * * Sits on `fetch` because the request fires from a CHILD mount effect, before any * parent effect could gate it — hence the call at module load, next to @@ -51,13 +74,18 @@ export function installSlashCommandVisibilityFilter(): void { } catch { return response; } - if (!Array.isArray(payload.commands)) return response; - const commands = payload.commands.filter(cmd => cmd.id !== undefined && VISIBLE_SLASH_COMMAND_IDS.has(cmd.id)); - if (commands.length === payload.commands.length) return response; + // Wait for the real flag value rather than reading the fallback. This + // response is what `useSlashCommandRegistry` caches for the session, so a + // guess made before the flags land is not a wrong frame — it is the catalog + // the panel keeps until reload. See `whenFeatureFlagsResolved`. + await whenFeatureFlagsResolved(); + + const visiblePayload = applySlashCommandVisibility(payload, featureFlags.mingoRemoteTools.enabled()); + if (visiblePayload === payload) return response; // Headers rebuilt rather than copied — the original `Content-Length` no longer matches. - return new Response(JSON.stringify({ ...payload, commands }), { + return new Response(JSON.stringify(visiblePayload), { status: response.status, statusText: response.statusText, headers: { 'Content-Type': 'application/json' }, diff --git a/src/app/components/openframe-embeddable-chat-entry.tsx b/src/app/components/openframe-embeddable-chat-entry.tsx index 3e378aa5..345f0c8e 100644 --- a/src/app/components/openframe-embeddable-chat-entry.tsx +++ b/src/app/components/openframe-embeddable-chat-entry.tsx @@ -45,6 +45,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import { getFullImageUrl } from '@/lib/image-url'; import { mingoDialogLink } from '@/lib/routes'; import { runtimeEnv } from '@/lib/runtime-config'; +import { KNOWLEDGE_BASE_ROUTE } from '../(app)/help-center/endpoints'; import { MINGO_CONTEXT_ENTITY_TYPES } from '../(app)/mingo/context/context-sources'; import { CONTEXT_ITEMS_MAX } from '../(app)/mingo/context/context-types'; import { renderMingoContextItem, renderMingoMention } from '../(app)/mingo/context/mention-chips/render-mention'; @@ -293,6 +294,11 @@ export function OpenframeEmbeddableChatEntry({ open, onOpenChange }: OpenframeEm // the uncontrolled active mode defaults to 'mingo'. modes={{}} mingoState={state} + // Where an in-app doc chip navigates. Guide Mode V3 answers cite product + // documentation, and each cited source renders as a chip whose target is + // resolved against this route; without it the lib derives one from + // `runtime.source`, which is the hub's doc tree, not ours. + baseRoute={KNOWLEDGE_BASE_ROUTE} // PENDING approval cards are FILTERED OUT of their bubble by // `useMingoChat` (dedupe for interrupted retries), so a card that the // reducer built renders nowhere unless it is handed back here — the diff --git a/src/lib/feature-flags.test.ts b/src/lib/feature-flags.test.ts new file mode 100644 index 00000000..8b0b4b8d --- /dev/null +++ b/src/lib/feature-flags.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useFeatureFlagsStore } from '@/stores/feature-flags-store'; +import { featureFlags, whenFeatureFlagsResolved } from './feature-flags'; + +afterEach(() => { + useFeatureFlagsStore.getState().reset(); +}); + +/** + * The window between app start and the flags answer is a REAL state, not a + * transient (flags are deliberately uncached — see `feature-flags-store.ts`). + * `featureFlags.*.enabled()` reports the fallback inside it, which is why + * anything that KEEPS its answer has to wait for this instead. + */ +describe('whenFeatureFlagsResolved', () => { + it('does not settle while the server has not answered', async () => { + const settled = vi.fn(); + void whenFeatureFlagsResolved().then(settled); + + await Promise.resolve(); + expect(settled).not.toHaveBeenCalled(); + // And the imperative read is exactly the wrong answer for that window. + expect(featureFlags.mingoRemoteTools.enabled()).toBe(false); + }); + + it('settles when the flags land, with the real value readable', async () => { + const pending = whenFeatureFlagsResolved(); + useFeatureFlagsStore.getState().setFlags([{ name: 'ai-mingo-remote-tools', enabled: true }]); + + await expect(pending).resolves.toBeUndefined(); + expect(featureFlags.mingoRemoteTools.enabled()).toBe(true); + }); + + it('settles on a terminal failure, which marks the flags loaded with no values', async () => { + const pending = whenFeatureFlagsResolved(); + useFeatureFlagsStore.getState().setLoaded(); + + await expect(pending).resolves.toBeUndefined(); + expect(featureFlags.mingoRemoteTools.enabled()).toBe(false); + }); + + it('settles immediately once the answer is already in', async () => { + useFeatureFlagsStore.getState().setFlags([{ name: 'ai-mingo-remote-tools', enabled: true }]); + await expect(whenFeatureFlagsResolved()).resolves.toBeUndefined(); + }); +}); diff --git a/src/lib/feature-flags.ts b/src/lib/feature-flags.ts index 41bcf2ad..7b58abd0 100644 --- a/src/lib/feature-flags.ts +++ b/src/lib/feature-flags.ts @@ -11,6 +11,11 @@ export const FEATURE_FLAG_NAMES = [ 'help-center', 'notifications', 'debug-nats-chunks', + // Mingo Guide Mode V3 — the agent answers through Hub MCP remote tools instead + // of the local V2 tool set. Same flag name the saas-ai-agent backend reads, on + // purpose: the command catalog the frontend shows and the tools the backend can + // actually run have to be the same generation, and two names could drift. + 'ai-mingo-remote-tools', 'mingo-ai-chat-settings', 'customer-ai-assistant-settings', 'customer-ai-configuration', @@ -113,6 +118,16 @@ export const featureFlags = { return getFlagValue('ai-resolution', () => false); }, }, + /** + * Mingo Guide Mode V3 (Hub MCP remote tools). OFF is V2: the local tool set and + * the four commands openframe ships. ON exposes the whole server-owned command + * catalog — see `chat-slash-command-visibility.ts`. + */ + mingoRemoteTools: { + enabled(): boolean { + return getFlagValue('ai-mingo-remote-tools', () => false); + }, + }, mingoAiChatSettings: { enabled(): boolean { return getFlagValue('mingo-ai-chat-settings', () => false); @@ -163,3 +178,29 @@ export const featureFlags = { * Feature flag keys */ export type FeatureFlagKey = keyof typeof featureFlags; + +/** + * Resolve once the server has answered — or terminally failed. + * + * `featureFlags.*.enabled()` reports the env fallback before the answer, which is + * acceptable wherever the read REPEATS (a render re-runs, a handler runs again on + * the next click) and wrong wherever its result is KEPT. The slash-command filter + * is the second kind: it rewrites a response the lib caches for the whole session + * (`useSlashCommandRegistry` runs at `staleTime: Infinity`), so a guess made in + * that window is the catalog the panel shows until the next reload. + * + * Always settles — the same guarantee `useFeatureFlagsReady` is built on: + * `FeatureFlagsLoader` marks the flags loaded on query error and offline, and + * marks them at mount in saas-shared mode. + */ +export function whenFeatureFlagsResolved(): Promise { + if (useFeatureFlagsStore.getState().isLoaded) return Promise.resolve(); + + return new Promise(resolve => { + const unsubscribe = useFeatureFlagsStore.subscribe(state => { + if (!state.isLoaded) return; + unsubscribe(); + resolve(); + }); + }); +}