Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/app/(app)/help-center/help-center-content-href.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 10 additions & 5 deletions src/app/(app)/help-center/help-center-content-href.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-<id>` 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-<id>` 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 }),
},
Expand Down
87 changes: 48 additions & 39 deletions src/app/(app)/mingo/hooks/use-mingo-chat.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -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<string> = 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<T extends object>(a: T, b: T, skip: ReadonlySet<string>): 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 {
Expand Down Expand Up @@ -140,21 +151,19 @@ 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
// both the standalone page and the embeddable chat get an absolute src.
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 } : {}),
});
}

Expand Down
17 changes: 15 additions & 2 deletions src/app/(app)/mingo/hooks/use-mingo-dialog-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 };
},
Expand Down
173 changes: 172 additions & 1 deletion src/app/(app)/mingo/hooks/use-mingo-unified-chat-state.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}): 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<ChatRef> & Pick<ChatRef, 'type' | 'id' | 'title'>): 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
Expand Down
Loading
Loading