From 2d0f1edfa33762f281b835b65fa7d117141e33cf Mon Sep 17 00:00:00 2001 From: Pavlo Shylo Date: Tue, 8 Sep 2026 18:08:59 +0100 Subject: [PATCH 1/4] feat(mingo): wire Guide Mode V3 source metadata and remote tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lib now decodes an answer's `GUIDE`/`SOURCES` metadata and renders its citation strip and entity cards (openframe-oss-lib#2115). This is the app half: fetching that metadata back on reload, gating the tools behind their flag, and giving the cards a working Display action. - `dialogs-queries` selects `GuideData.payload` so a reloaded dialog shows the citations the live turn did. `GuideData.text` is deliberately NOT selected — payload-only records persist it as an empty string, which replays as an empty text segment. `SourcesData` is not in the tenant schema yet, and a fragment for a type the schema lacks fails the WHOLE query, so only `GuideData` is named here; the lib decoder accepts both. - `AskData.text` is aliased. It is nullable where `TextData.text` is not, and GraphQL's SameResponseShape rule rejects the entire query over that conflict rather than the one field. - `feature-flags` gains `ai-mingo-remote-tools` and `whenFeatureFlagsResolved()`. The slash-command interceptor rewrites a response the lib caches with `staleTime: Infinity`, so a guess made before the flags land is the catalog the panel keeps for the whole session — it has to await them, not read them optimistically. - With the flag off, the command list is filtered to the four the backend's `HubMcpPromptPolicy` allows MCP prompts to replace. The filter is a pure function so the policy is testable without a panel. - Entity cards get a Display action built from the ref's own `sourceRepo`, falling back to a documentType→table map, and only when the catalog actually has a `display` action for that source — offering it for a search-only source would run a query instead of showing the row. The invocation is formatted through the lib's quoting helper rather than a second escaping rule here. - `mapMingoMessageToUnified` spreads what it does not destructure, so per-message metadata the lib adds later rides through without a change here. That is the same seam that silently dropped `sources`/`refs` on the lib side; a mapper that enumerates fields is the shape of that bug. Requires a core-lib version carrying openframe-oss-lib#2115 — the bump is NOT in this commit and must land before merge. --- src/app/(app)/mingo/hooks/use-mingo-chat.ts | 87 +++++---- .../mingo/hooks/use-mingo-dialog-selection.ts | 17 +- .../use-mingo-unified-chat-state.test.ts | 173 +++++++++++++++- .../hooks/use-mingo-unified-chat-state.ts | 184 ++++++++++++------ .../mingo/queries/dialogs-queries.test.ts | 89 +++++++++ .../(app)/mingo/queries/dialogs-queries.ts | 72 +++++++ .../chat-slash-command-visibility.test.ts | 39 ++++ .../chat-slash-command-visibility.ts | 40 +++- .../openframe-embeddable-chat-entry.tsx | 6 + src/lib/feature-flags.test.ts | 46 +++++ src/lib/feature-flags.ts | 41 ++++ 11 files changed, 691 insertions(+), 103 deletions(-) create mode 100644 src/app/(app)/mingo/queries/dialogs-queries.test.ts create mode 100644 src/app/components/chat-slash-command-visibility.test.ts create mode 100644 src/lib/feature-flags.test.ts 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 0ce5afa9..354d9fd8 100644 --- a/src/lib/feature-flags.ts +++ b/src/lib/feature-flags.ts @@ -12,6 +12,11 @@ export const FEATURE_FLAG_NAMES = [ 'notifications', 'notifications-legacy-path', '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', @@ -143,6 +148,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); @@ -193,3 +208,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(); + }); + }); +} From 67fbc8c30b8d60e8081096989a9742bc01d79e11 Mon Sep 17 00:00:00 2001 From: Pavlo Shylo Date: Tue, 8 Sep 2026 18:15:30 +0100 Subject: [PATCH 2/4] chore: pin the core lib to the Guide Mode V3 prerelease MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `0.0.610-2115.5931.1`, built from openframe-oss-lib#2115 — the branch this PR needs. Exact, not caret: a caret range on a prerelease only matches other prereleases of the SAME version, which is a resolution rule nobody reads a `^` as meaning. Replace with the plain release once #2115 merges. --- package-lock.json | 115 ++++++++++++++++++---------------------------- package.json | 2 +- 2 files changed, 46 insertions(+), 71 deletions(-) diff --git a/package-lock.json b/package-lock.json index ed0835b3..eacab76e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.7.1", "@codemirror/view": "^6.43.9", - "@flamingo-stack/openframe-frontend-core": "^0.0.609", + "@flamingo-stack/openframe-frontend-core": "0.0.610-2115.5931.1", "@hookform/resolvers": "^5.2.2", "@lezer/highlight": "^1.2.3", "@tanstack/react-query": "^5.90.16", @@ -93,7 +93,7 @@ "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", @@ -110,7 +110,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", @@ -127,7 +127,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" @@ -137,7 +137,7 @@ "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@atlaskit/pragmatic-drag-and-drop": { @@ -449,7 +449,7 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "css-tree": "^3.0.0" @@ -631,7 +631,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -651,7 +651,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -675,7 +675,7 @@ "version": "4.1.10", "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -703,7 +703,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -726,7 +726,7 @@ "version": "1.1.7", "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -751,7 +751,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -938,7 +938,7 @@ "version": "1.15.1", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" @@ -953,9 +953,9 @@ } }, "node_modules/@flamingo-stack/openframe-frontend-core": { - "version": "0.0.609", - "resolved": "https://registry.npmjs.org/@flamingo-stack/openframe-frontend-core/-/openframe-frontend-core-0.0.609.tgz", - "integrity": "sha512-PZ9rVJ24OqbCqURE9i2qSw/ojRxMAfdc/4S/Rxj9rUeC67xccFH/Wh4YLHoN9rcQMZWSk5wY2R24TH6J3mQRAg==", + "version": "0.0.610-2115.5931.1", + "resolved": "https://registry.npmjs.org/@flamingo-stack/openframe-frontend-core/-/openframe-frontend-core-0.0.610-2115.5931.1.tgz", + "integrity": "sha512-xe84yxO2rILXuF9PKNN0IPYyUqCw0yL2OKkbWvI6ZdxzrJ2G2w9tjdpyuFoBgCKlfLXiwMp8lDxT45sZBG6E3g==", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^3.0.0", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^3.0.1", @@ -5304,7 +5304,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5321,7 +5320,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5338,7 +5336,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5355,7 +5352,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5372,7 +5368,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5389,7 +5384,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5406,7 +5400,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5423,7 +5416,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5440,7 +5432,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5457,7 +5448,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5474,7 +5464,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5491,7 +5480,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5508,7 +5496,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5525,7 +5512,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5999,7 +5985,7 @@ }, "node_modules/@types/node": { "version": "22.19.11", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -7264,7 +7250,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "require-from-string": "^2.0.2" @@ -7707,7 +7693,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "mdn-data": "2.27.1", @@ -8233,7 +8219,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "whatwg-mimetype": "^5.0.0", @@ -8247,7 +8233,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" @@ -8260,7 +8246,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=20" @@ -8270,7 +8256,7 @@ "version": "16.0.1", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.11.0", @@ -10142,7 +10128,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.6.0" @@ -10621,7 +10607,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/is-regex": { @@ -10824,7 +10810,7 @@ "version": "29.1.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@asamuzakjp/css-color": "^5.1.11", @@ -10865,7 +10851,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=20.19.0" @@ -10878,7 +10864,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "entities": "^8.0.0" @@ -10891,7 +10877,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" @@ -10904,7 +10890,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=20" @@ -10914,7 +10900,7 @@ "version": "16.0.1", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.11.0", @@ -11109,7 +11095,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11130,7 +11115,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11151,7 +11135,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11172,7 +11155,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11193,7 +11175,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11214,7 +11195,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11235,7 +11215,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11256,7 +11235,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11277,7 +11255,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11298,7 +11275,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11319,7 +11295,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11409,7 +11384,7 @@ "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "dev": true, + "devOptional": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -11715,7 +11690,7 @@ "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, + "devOptional": true, "license": "CC0-1.0" }, "node_modules/media-chrome": { @@ -14370,7 +14345,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -14564,7 +14539,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" @@ -15103,7 +15078,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/tabbable": { @@ -15278,7 +15253,7 @@ "version": "7.4.10", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tldts-core": "^7.4.10" @@ -15291,7 +15266,7 @@ "version": "7.4.10", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/to-regex-range": { @@ -15336,7 +15311,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "tldts": "^7.0.5" @@ -15605,7 +15580,7 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=20.18.1" @@ -15613,7 +15588,7 @@ }, "node_modules/undici-types": { "version": "6.21.0", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unified": { @@ -16180,7 +16155,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "xml-name-validator": "^5.0.0" @@ -16243,7 +16218,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=20" @@ -16405,7 +16380,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=18" @@ -16415,7 +16390,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/yallist": { diff --git a/package.json b/package.json index cd15ee23..a887b39a 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.7.1", "@codemirror/view": "^6.43.9", - "@flamingo-stack/openframe-frontend-core": "^0.0.609", + "@flamingo-stack/openframe-frontend-core": "0.0.610-2115.5931.1", "@hookform/resolvers": "^5.2.2", "@lezer/highlight": "^1.2.3", "@tanstack/react-query": "^5.90.16", From 19f48665d76318d47879e0926d0bd59378f19dc2 Mon Sep 17 00:00:00 2001 From: Pavlo Shylo Date: Mon, 14 Sep 2026 15:52:39 +0100 Subject: [PATCH 3/4] chore: bump core lib to ^0.0.631 Picks up the ordered-list start fix (openframe-oss-lib#2181), so cards in a numbered Guide Mode V3 answer are no longer all numbered 1. --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8e03f8e0..6a20c54d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.7.1", "@codemirror/view": "^6.43.9", - "@flamingo-stack/openframe-frontend-core": "^0.0.630", + "@flamingo-stack/openframe-frontend-core": "^0.0.631", "@hookform/resolvers": "^5.2.2", "@lezer/highlight": "^1.2.3", "@tanstack/react-query": "^5.90.16", @@ -953,9 +953,9 @@ } }, "node_modules/@flamingo-stack/openframe-frontend-core": { - "version": "0.0.630", - "resolved": "https://registry.npmjs.org/@flamingo-stack/openframe-frontend-core/-/openframe-frontend-core-0.0.630.tgz", - "integrity": "sha512-9PmHxuOPZHDuvvKJ/XDTGULMjmUPH+XUypA/PGN8OS9AtQ12J9effVm8Y8IZtrdTn9VfBrfq+C+Vsu2gr99Nbg==", + "version": "0.0.631", + "resolved": "https://registry.npmjs.org/@flamingo-stack/openframe-frontend-core/-/openframe-frontend-core-0.0.631.tgz", + "integrity": "sha512-zMSOhEyMYQRdoWApAPrUYswdkksP8PMApWth1soLRXjZpIAY/ijD2Kw1xmX/iA4qZuFQo4lefcYaCjOSVq54qg==", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^3.0.0", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^3.0.1", diff --git a/package.json b/package.json index 98278fad..381d12f6 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.7.1", "@codemirror/view": "^6.43.9", - "@flamingo-stack/openframe-frontend-core": "^0.0.630", + "@flamingo-stack/openframe-frontend-core": "^0.0.631", "@hookform/resolvers": "^5.2.2", "@lezer/highlight": "^1.2.3", "@tanstack/react-query": "^5.90.16", From 7d727ad05a77d3f3d5d087040966d7b3f88ac039 Mon Sep 17 00:00:00 2001 From: Pavlo Shylo Date: Tue, 15 Sep 2026 13:29:11 +0100 Subject: [PATCH 4/4] fix(help-center): stop linking anonymized known-issue tickets to the viewer's list hubspot_ticket_anon cards are other customers' tickets, so the session-scoped /help-center/tickets list answered "No tickets found". The hub mints them with url: null on purpose; dropping our override lets the lib's noComposedHref keep the card unlinked, with "Ask Mingo" as its only action. --- .../help-center/help-center-content-href.test.ts | 10 +++++++++- .../(app)/help-center/help-center-content-href.ts | 15 ++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) 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 }), },