FN-8291: add inline native structure previews to chat

Enable chat users to inspect and open referenced native structures inline.

- Render clickable inline previews for native structure references across chat surfaces.
- Route preview actions to their corresponding task, mission, goal, and other dashboard views.
- Add regression coverage, dashboard documentation, and a CLI changeset.

Files changed:
 ...fn-8291-chat-native-structure-inline-preview.md |   7 +
 docs/dashboard-guide.md                            |   5 +-
 packages/dashboard/app/App.tsx                     |  31 +++
 .../app/components/StandardChatSurface.tsx         | 226 ++++++++++++++++--
 ...andardChatSurface.nativeStructureEmbed.test.tsx | 257 +++++++++++++++++++++
 .../__tests__/nativeStructureChatRef.test.ts       |  54 +++++
 .../app/components/nativeStructureChatRef.ts       |  58 +++++
 .../app/components/nativeStructureNavigation.ts    |  21 ++
 8 files changed, 635 insertions(+), 24 deletions(-)

Fusion-Task-Id: FN-8291

Fusion-Task-Lineage: 9db2c0fa-494e-4779-93f3-3309eed8565a

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-18 19:36:57 -07:00
parent b61311baa8
commit bc4b679598
8 changed files with 635 additions and 24 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Preview supported missions, findings, evals, and goals directly in chat.
category: feature
dev: Chat recognizes strict fusion://<kind>/<id> tokens and assistant Markdown links.

View File

@@ -585,6 +585,9 @@ The full **New Task** dialog includes a compact **GitHub issue or PR** picker ne
Chat view provides project-scoped conversations with agents. Chat view provides project-scoped conversations with agents.
<!-- FNXC:NativeStructureEmbed 2026-07-19-19:30: Document the shared chat reference contract so agents and operators can use an unambiguous token rather than relying on title matching. -->
- Chat recognizes native structure references in both assistant and user messages using the explicit `fusion://<kind>/<id>` form. Supported kinds are `mission`, `milestone`, `roadmap-item`, `research-finding`, `eval-result`, and `goal`. Use a bare token such as `fusion://mission/M-001` in either message type, or an assistant Markdown link such as `[Mission](fusion://mission/M-001)`. `roadmap-item` currently resolves to the shared unavailable card until its plugin supplies a preview adapter and dashboard destination.
- Recognized references render an inline preview card before you leave the conversation. Select **Open** on an available card to navigate to its owning dashboard view; missing, archived, or otherwise unavailable structures show a safe unavailable placeholder instead. Plain-text mode deliberately leaves reference text raw.
- Entering `/new` or `/clear` (exact match after trimming) in the composer starts a fresh thread for the current chat target instead of sending the literal command to the model - Entering `/new` or `/clear` (exact match after trimming) in the composer starts a fresh thread for the current chat target instead of sending the literal command to the model
- On mobile, the New Chat and Delete Conversation dialogs use a compact inset treatment (centered, viewport-bounded, internally scrollable) instead of the app's default full-height mobile modal chrome. - On mobile, the New Chat and Delete Conversation dialogs use a compact inset treatment (centered, viewport-bounded, internally scrollable) instead of the app's default full-height mobile modal chrome.
- **Settings → Project Models → Chat** controls New Chat defaults per project. Choose a default target kind (**Model** with provider/model and optional Thinking Level, or **Agent** with a durable agent id) and a mode: **Prompt for model each time** opens the New Chat dialog with that default preselected, while **Always use configured default** creates the session immediately from the resolved default. If the configured target is incomplete or missing, New Chat falls back to the dialog instead of creating an unroutable session. - **Settings → Project Models → Chat** controls New Chat defaults per project. Choose a default target kind (**Model** with provider/model and optional Thinking Level, or **Agent** with a durable agent id) and a mode: **Prompt for model each time** opens the New Chat dialog with that default preselected, while **Always use configured default** creates the session immediately from the resolved default. If the configured target is incomplete or missing, New Chat falls back to the dialog instead of creating an unroutable session.
@@ -1898,7 +1901,7 @@ The dashboard's CSS is split into a global stylesheet (`packages/dashboard/app/s
### Native structure previews ### Native structure previews
`NativeStructurePreview` is the shared compact card for mission, milestone, research-finding, eval-result, and goal references. It resolves `GET /api/native-structures/:kind/:id/preview` to a typed available or unavailable payload and uses a required consumer-supplied `onOpen(ref, payload)` callback. `openTarget` is a view-state descriptor, not a URL, because dashboard navigation is callback based. `roadmap-item` is intentionally deferred until its plugin provides a backend-safe reader and dashboard destination. `NativeStructurePreview` is the shared compact card for mission, milestone, roadmap item, research-finding, eval-result, and goal references. It resolves `GET /api/native-structures/:kind/:id/preview` to a typed available or unavailable payload and uses a required consumer-supplied `onOpen(ref, payload)` callback. Chat is a consumer: it parses strict `fusion://<kind>/<id>` tokens/assistant Markdown links and dispatches the callback into its owning dashboard view. `openTarget` is a view-state descriptor, not a URL, because dashboard navigation is callback based. Until its plugin provides a backend-safe reader and dashboard destination, a `roadmap-item` uses the shared unavailable card.
PR tab note: `PrPanel` cards use tokenized `.pr-card` grid spacing (`padding` + `gap`) and boxed token-based hint callouts for empty/loading states. Manual PR merges now show in-progress feedback (`Merging…` button state + status hint) until the merge call resolves. PR tab note: `PrPanel` cards use tokenized `.pr-card` grid spacing (`padding` + `gap`) and boxed token-based hint callouts for empty/loading states. Manual PR merges now show in-progress feedback (`Merging…` button state + status hint) until the merge call resolves.

View File

@@ -113,6 +113,7 @@ export {
import { subscribeSse } from "./sse-bus"; import { subscribeSse } from "./sse-bus";
import { AuthTokenRecoveryDialog } from "./components/AuthTokenRecoveryDialog"; import { AuthTokenRecoveryDialog } from "./components/AuthTokenRecoveryDialog";
import { MainContent } from "./components/dashboard/MainContent"; import { MainContent } from "./components/dashboard/MainContent";
import { NATIVE_STRUCTURE_OPEN_EVENT, type NativeStructureOpenEventDetail } from "./components/nativeStructureNavigation";
import { DashboardBanners } from "./components/dashboard/DashboardBanners"; import { DashboardBanners } from "./components/dashboard/DashboardBanners";
import type { DashboardBannersProps, MainContentProps } from "./components/dashboard/types"; import type { DashboardBannersProps, MainContentProps } from "./components/dashboard/types";
import type { GraphWorkflowSelection } from "./components/GraphWorkflowSwitcherSlot"; import type { GraphWorkflowSelection } from "./components/GraphWorkflowSwitcherSlot";
@@ -481,6 +482,36 @@ function AppInner() {
} }
}, [handleChangeTaskView, taskView, pushNav]); }, [handleChangeTaskView, taskView, pushNav]);
/*
FNXC:NativeStructureEmbed 2026-07-19-19:30:
NativeStructurePreview deliberately reports callback/view-state destinations instead of URLs.
Listen once at the dashboard root so cards from general, task-bound, floating, and dock chat
open their owning view without duplicating navigation logic at any chat render call-site.
*/
useEffect(() => {
const openNativeStructure = (event: Event) => {
const { payload } = (event as CustomEvent<NativeStructureOpenEventDetail>).detail;
if (!payload?.available) return;
const target = payload.openTarget;
if (target.view === "missions") {
// FNXC:NativeStructureEmbed 2026-07-20-01:00: Mission navigation resets stale selection
// state. Navigate before setting this preview's target so the destination opens the
// referenced mission rather than an unselected Missions view.
handleTaskViewChange("missions");
setMissionTargetId(target.missionId ?? target.id);
return;
}
if (target.view === "goals") {
handleTaskViewChange("goalsView");
setGoalAnchorId(target.id);
return;
}
handleTaskViewChange(target.view);
};
window.addEventListener(NATIVE_STRUCTURE_OPEN_EVENT, openNativeStructure);
return () => window.removeEventListener(NATIVE_STRUCTURE_OPEN_EVENT, openNativeStructure);
}, [handleTaskViewChange]);
// FNXC:DashboardLiveUpdates 2026-06-26-01:08: // FNXC:DashboardLiveUpdates 2026-06-26-01:08:
// SSE remains enabled only for board/list views to free connection slots for mission detail fetches. The false→true missed-event catch-up lives inside useTasks so App keeps the routing gate only and cannot double-fetch on task-view re-entry. // SSE remains enabled only for board/list views to free connection slots for mission detail fetches. The false→true missed-event catch-up lives inside useTasks so App keeps the routing gate only and cannot double-fetch on task-view re-entry.
const taskSseEnabled = taskView === "board" || taskView === "list"; const taskSseEnabled = taskView === "board" || taskView === "list";

View File

@@ -1,6 +1,6 @@
import type { Agent } from "@fusion/core"; import type { Agent } from "@fusion/core";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import React, { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
import type { Components } from "react-markdown"; import type { Components } from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { ArrowUpToLine, Bot, File, Pencil, Send, TriangleAlert } from "lucide-react"; import { ArrowUpToLine, Bot, File, Pencil, Send, TriangleAlert } from "lucide-react";
@@ -10,6 +10,9 @@ import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify
import { parseQuestionToolCall } from "../utils/parseQuestionToolCall"; import { parseQuestionToolCall } from "../utils/parseQuestionToolCall";
import { ChatQuestionResponse } from "./ChatQuestionResponse"; import { ChatQuestionResponse } from "./ChatQuestionResponse";
import { ProviderIcon } from "./ProviderIcon"; import { ProviderIcon } from "./ProviderIcon";
import { NativeStructurePreview } from "./NativeStructurePreview";
import { openNativeStructure } from "./nativeStructureNavigation";
import { nativeStructureChatRefMatcher, parseNativeStructureChatRef, splitNativeStructureChatRefMatch } from "./nativeStructureChatRef";
export interface StandardRoomContext { export interface StandardRoomContext {
roomName: string; roomName: string;
@@ -305,17 +308,169 @@ export function renderStandardToolCalls(
); );
} }
/**
* FNXC:NativeStructureEmbed 2026-07-19-19:30:
* ReactMarkdown leaves bare custom-scheme tokens as text, unlike Markdown links. Transform text
* at this shared rendering seam so room, task-bound, floating, and dock assistant messages all
* gain the same card without teaching individual ChatView hosts how to parse references.
*/
function renderNativeStructureChatTokens(text: string): ReactNode[] {
const nodes: ReactNode[] = [];
let lastIndex = 0;
let index = 0;
nativeStructureChatRefMatcher.lastIndex = 0;
let match = nativeStructureChatRefMatcher.exec(text);
while (match) {
const { token, trailingPunctuation } = splitNativeStructureChatRefMatch(match);
const start = match.index;
const structureRef = parseNativeStructureChatRef(token);
if (!structureRef) {
match = nativeStructureChatRefMatcher.exec(text);
continue;
}
if (start > lastIndex) nodes.push(text.slice(lastIndex, start));
nodes.push(<NativeStructurePreview key={`native-structure-${start}-${index}`} ref={structureRef} onOpen={openNativeStructure} />);
if (trailingPunctuation) nodes.push(trailingPunctuation);
lastIndex = start + match[0].length;
index += 1;
match = nativeStructureChatRefMatcher.exec(text);
}
if (lastIndex === 0) return [text];
if (lastIndex < text.length) nodes.push(text.slice(lastIndex));
return nodes;
}
/**
* FNXC:NativeStructureEmbed 2026-07-19-21:15:
* NativeStructurePreview has a block root. Paragraphs and headings must lift a detected preview
* into a sibling instead of nesting it in their phrasing-only content, preserving valid HTML and
* heading semantics while retaining prose before and after a token.
*/
type NativeStructurePreviewMarker = { readonly structureRef: React.ComponentProps<typeof NativeStructurePreview>["ref"] };
type NativeStructureMarkdownPart = ReactNode | NativeStructurePreviewMarker;
function isNativeStructurePreviewMarker(part: NativeStructureMarkdownPart): part is NativeStructurePreviewMarker {
return typeof part === "object" && part !== null && "structureRef" in part;
}
/**
* FNXC:NativeStructureEmbed 2026-07-20-00:15:
* Markdown phrasing nodes such as `strong` can wrap a bare token or custom link. Split those
* wrappers around preview markers before the paragraph/heading renderer lifts each card, rather
* than placing the preview's block root inside `<strong>` or another phrasing-only container.
*/
function splitMarkdownNodeAtNativeStructurePreviews(node: ReactNode): NativeStructureMarkdownPart[] {
if (typeof node === "string") {
return renderNativeStructureChatTokens(node).map((part) => (
React.isValidElement<React.ComponentProps<typeof NativeStructurePreview>>(part) && part.type === NativeStructurePreview
? { structureRef: part.props.ref }
: part
));
}
if (!React.isValidElement<{ children?: ReactNode; href?: string }>(node)) return [node];
const linkedRef = node.type === NativeStructureMarkdownAnchor ? parseNativeStructureChatRef(node.props.href ?? "") : null;
if (linkedRef) return [{ structureRef: linkedRef }];
if (node.type === NativeStructurePreview) return [{ structureRef: (node.props as React.ComponentProps<typeof NativeStructurePreview>).ref }];
// Links and code are opaque text islands: preview cards must never become nested interactive content.
if (node.type === "a" || node.type === NativeStructureMarkdownAnchor || node.type === NativeStructureMarkdownCode || node.props.children === undefined) return [node];
const childParts = React.Children.toArray(node.props.children).flatMap(splitMarkdownNodeAtNativeStructurePreviews);
if (!childParts.some(isNativeStructurePreviewMarker)) {
return [React.cloneElement(node, undefined, childParts as ReactNode[])];
}
const parts: NativeStructureMarkdownPart[] = [];
let inlineChildren: ReactNode[] = [];
const flushInlineChildren = () => {
if (inlineChildren.length > 0) {
parts.push(React.cloneElement(node, undefined, inlineChildren));
inlineChildren = [];
}
};
for (const childPart of childParts) {
if (isNativeStructurePreviewMarker(childPart)) {
flushInlineChildren();
parts.push(childPart);
} else {
inlineChildren.push(childPart);
}
}
flushInlineChildren();
return parts;
}
function renderMarkdownBlockWithNativeStructurePreviews(
Tag: "p" | "li" | "blockquote" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "table",
children: ReactNode,
props: React.HTMLAttributes<HTMLElement>,
): ReactNode {
const blocks: ReactNode[] = [];
let inlineChildren: ReactNode[] = [];
let index = 0;
const flushInlineChildren = () => {
if (inlineChildren.length > 0) {
blocks.push(<Tag key={`native-structure-inline-${index}`} {...props}>{linkifyReactChildren(inlineChildren)}</Tag>);
inlineChildren = [];
index += 1;
}
};
for (const part of React.Children.toArray(children).flatMap(splitMarkdownNodeAtNativeStructurePreviews)) {
if (isNativeStructurePreviewMarker(part)) {
flushInlineChildren();
blocks.push(<NativeStructurePreview key={`native-structure-preview-${index}`} ref={part.structureRef} onOpen={openNativeStructure} />);
index += 1;
} else {
inlineChildren.push(part);
}
}
flushInlineChildren();
return blocks.length === 1 ? blocks[0] : <>{blocks}</>;
}
function NativeStructureMarkdownAnchor({ children, href, ...props }: React.ComponentProps<"a">) {
const structureRef = href ? parseNativeStructureChatRef(href) : null;
if (structureRef) return <NativeStructurePreview ref={structureRef} onOpen={openNativeStructure} />;
return <a href={href} {...props}>{children}</a>;
}
function NativeStructureMarkdownCode({ children, ...props }: React.ComponentProps<"code">) {
const text = typeof children === "string" ? children : React.Children.toArray(children).join("");
const linkedChildren = linkifyFilePaths(text);
if (linkedChildren.length === 1 && typeof linkedChildren[0] === "string") return <code {...props}>{children}</code>;
return <code {...props}>{linkedChildren}</code>;
}
export const standardChatMarkdownComponents: Components = { export const standardChatMarkdownComponents: Components = {
p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>, p: ({ children, ...props }) => renderMarkdownBlockWithNativeStructurePreviews("p", children, props),
li: ({ children, ...props }) => <li {...props}>{linkifyReactChildren(children)}</li>, // FNXC:NativeStructureEmbed 2026-07-20-01:00: List and quote bodies can contain Markdown
// phrasing wrappers, so they use the same marker/lifting pass as paragraphs instead of nesting
// NativeStructurePreview inside `strong`, `em`, or other phrasing-only elements.
li: ({ children, ...props }) => renderMarkdownBlockWithNativeStructurePreviews("li", children, props),
blockquote: ({ children, ...props }) => renderMarkdownBlockWithNativeStructurePreviews("blockquote", children, props),
h1: ({ children, ...props }) => renderMarkdownBlockWithNativeStructurePreviews("h1", children, props),
h2: ({ children, ...props }) => renderMarkdownBlockWithNativeStructurePreviews("h2", children, props),
h3: ({ children, ...props }) => renderMarkdownBlockWithNativeStructurePreviews("h3", children, props),
h4: ({ children, ...props }) => renderMarkdownBlockWithNativeStructurePreviews("h4", children, props),
h5: ({ children, ...props }) => renderMarkdownBlockWithNativeStructurePreviews("h5", children, props),
h6: ({ children, ...props }) => renderMarkdownBlockWithNativeStructurePreviews("h6", children, props),
// Table descendants are lifted by the table renderer below. A cell itself must stay textual:
// HTML tables cannot contain the preview card's block root directly.
td: ({ children, ...props }) => <td {...props}>{linkifyReactChildren(children)}</td>,
th: ({ children, ...props }) => <th {...props}>{linkifyReactChildren(children)}</th>,
/*
FNXC:NativeStructureEmbed 2026-07-19-19:30:
Markdown links and bare assistant tokens take different ReactMarkdown paths. Only a strict
canonical link becomes the shared preview; every other href keeps normal rendering and URL
sanitization, including ReactMarkdown's javascript: rejection.
*/
a: NativeStructureMarkdownAnchor,
pre: ({ children, ...props }) => <pre {...props} className="chat-markdown-pre">{children}</pre>, pre: ({ children, ...props }) => <pre {...props} className="chat-markdown-pre">{children}</pre>,
code: ({ children, ...props }) => { code: NativeStructureMarkdownCode,
const text = typeof children === "string" ? children : React.Children.toArray(children).join(""); // FNXC:NativeStructureEmbed 2026-07-20-01:00: Lift markers through the full table tree so a
const linkedChildren = linkifyFilePaths(text); // formatted token in a cell never creates invalid `<td><div>` markup. The card becomes a
if (linkedChildren.length === 1 && typeof linkedChildren[0] === "string") return <code {...props}>{children}</code>; // sibling block; non-reference table content retains its normal table structure.
return <code {...props}>{linkedChildren}</code>; table: ({ children, ...props }) => renderMarkdownBlockWithNativeStructurePreviews("table", children, { ...props, className: "chat-markdown-table" }),
},
table: ({ children, ...props }) => <table {...props} className="chat-markdown-table">{children}</table>,
}; };
function formatRelativeTime(dateStr: string, t: (key: string, defaultValue: string, opts?: Record<string, unknown>) => string): string { function formatRelativeTime(dateStr: string, t: (key: string, defaultValue: string, opts?: Record<string, unknown>) => string): string {
@@ -334,7 +489,17 @@ function formatRelativeTime(dateStr: string, t: (key: string, defaultValue: stri
export function renderStandardAssistantContent(content: string, forcePlain: boolean): ReactNode { export function renderStandardAssistantContent(content: string, forcePlain: boolean): ReactNode {
if (forcePlain) return <div className="chat-message-content chat-message-content--plain">{content}</div>; if (forcePlain) return <div className="chat-message-content chat-message-content--plain">{content}</div>;
return <div className="chat-message-content chat-message-content--markdown"><ReactMarkdown remarkPlugins={[remarkGfm]} components={standardChatMarkdownComponents}>{content}</ReactMarkdown></div>; return (
<div className="chat-message-content chat-message-content--markdown">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={standardChatMarkdownComponents}
urlTransform={(href) => parseNativeStructureChatRef(href) ? href : defaultUrlTransform(href)}
>
{content}
</ReactMarkdown>
</div>
);
} }
export const StandardChatMessageItem = memo(function StandardChatMessageItem({ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
@@ -413,24 +578,39 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
if (isAssistantMessage) return null; if (isAssistantMessage) return null;
const content = message.content; const content = message.content;
const mentionRegex = /@([\w-]+)/g; const mentionRegex = /@([\w-]+)/g;
const tokens = [
...Array.from(content.matchAll(mentionRegex)).map((match) => ({ type: "mention" as const, match })),
...Array.from(content.matchAll(nativeStructureChatRefMatcher)).map((match) => ({ type: "native-structure" as const, match })),
].sort((left, right) => (left.match.index ?? 0) - (right.match.index ?? 0));
const parts: ReactNode[] = []; const parts: ReactNode[] = [];
let lastIndex = 0; let lastIndex = 0;
let match = mentionRegex.exec(content); /*
while (match) { FNXC:NativeStructureEmbed 2026-07-19-19:30:
const [fullMatch, rawName = ""] = match; User bodies are intentionally raw text rather than Markdown. Extend their existing mention
const start = match.index; tokenizer with the same strict parser used for assistant text and links so native previews
work without changing user-message formatting or adding per-surface render forks.
*/
for (const token of tokens) {
const [fullMatch, rawName = ""] = token.match;
const start = token.match.index ?? 0;
if (start < lastIndex) continue;
if (start > lastIndex) parts.push(content.slice(lastIndex, start)); if (start > lastIndex) parts.push(content.slice(lastIndex, start));
const normalizedName = rawName.replace(/_/g, " ").toLowerCase(); if (token.type === "native-structure") {
const mentionedAgent = mentionAgentsByName.get(normalizedName); const { token: referenceToken, trailingPunctuation } = splitNativeStructureChatRefMatch(token.match);
if (mentionedAgent) { const structureRef = parseNativeStructureChatRef(referenceToken);
const isNonMember = Boolean(roomContext && !roomContext.memberIds.has(mentionedAgent.id)); parts.push(structureRef ? <React.Fragment key={`native-structure-user-${start}`}><NativeStructurePreview ref={structureRef} onOpen={openNativeStructure} />{trailingPunctuation}</React.Fragment> : fullMatch);
const nonMemberLabel = isNonMember ? t("chat.mentionNonMember", "Not a member of {{roomName}}", { roomName: roomContext?.roomName }) : undefined;
parts.push(<span key={`${mentionedAgent.id}-${start}`} className={`chat-mention-chip${isNonMember ? " chat-mention-chip--non-member" : ""}`} title={nonMemberLabel} aria-label={nonMemberLabel}>@{mentionedAgent.name.replace(/\s+/g, "_")}</span>);
} else { } else {
parts.push(fullMatch); const normalizedName = rawName.replace(/_/g, " ").toLowerCase();
const mentionedAgent = mentionAgentsByName.get(normalizedName);
if (mentionedAgent) {
const isNonMember = Boolean(roomContext && !roomContext.memberIds.has(mentionedAgent.id));
const nonMemberLabel = isNonMember ? t("chat.mentionNonMember", "Not a member of {{roomName}}", { roomName: roomContext?.roomName }) : undefined;
parts.push(<span key={`${mentionedAgent.id}-${start}`} className={`chat-mention-chip${isNonMember ? " chat-mention-chip--non-member" : ""}`} title={nonMemberLabel} aria-label={nonMemberLabel}>@{mentionedAgent.name.replace(/\s+/g, "_")}</span>);
} else {
parts.push(fullMatch);
}
} }
lastIndex = start + fullMatch.length; lastIndex = start + fullMatch.length;
match = mentionRegex.exec(content);
} }
if (lastIndex < content.length) parts.push(content.slice(lastIndex)); if (lastIndex < content.length) parts.push(content.slice(lastIndex));
return parts.length === 0 ? content : parts; return parts.length === 0 ? content : parts;

View File

@@ -0,0 +1,257 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { NativeStructurePreviewResult } from "@fusion/core";
import type { ChatMessageInfo } from "../../hooks/chatTypes";
import { attachChatStream, ensureTaskPlannerChatSession, fetchChatMessages, fetchChatSession, fetchNativeStructurePreview, fetchTaskPlannerChatSession } from "../../api";
import { StandardChatMessageItem, StandardStreamingMessage } from "../StandardChatSurface";
import { ChatView } from "../ChatView";
import { TaskPlannerChatTab } from "../TaskPlannerChatTab";
import { activeSessionFixture, installChatViewEnv, renderWithAct, setupMockChat, setupMockRooms } from "./ChatView.test-harness";
vi.mock("../../hooks/useChat");
vi.mock("../../hooks/useChatRooms");
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
return { ...actual, useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }) };
});
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }) };
});
vi.mock("../../api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../api")>();
return {
...actual,
fetchNativeStructurePreview: vi.fn(),
fetchSettings: vi.fn().mockResolvedValue({}),
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [], defaultProvider: null, defaultModelId: null }),
fetchAgents: vi.fn().mockResolvedValue([]),
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
fetchTasks: vi.fn().mockResolvedValue([]),
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
ensureTaskPlannerChatSession: vi.fn(),
fetchTaskPlannerChatSession: vi.fn(),
fetchChatSession: vi.fn(),
fetchChatMessages: vi.fn(),
attachChatStream: vi.fn(),
};
});
installChatViewEnv();
const fetchPreview = vi.mocked(fetchNativeStructurePreview);
const ensurePlannerSession = vi.mocked(ensureTaskPlannerChatSession);
const fetchPlannerSession = vi.mocked(fetchTaskPlannerChatSession);
const fetchSession = vi.mocked(fetchChatSession);
const fetchMessages = vi.mocked(fetchChatMessages);
const attachStream = vi.mocked(attachChatStream);
const available: NativeStructurePreviewResult = {
available: true,
kind: "mission",
kindLabel: "Mission",
title: "Inline mission",
excerpt: "A preview rendered in chat.",
openTarget: { view: "missions", id: "M-001" },
};
function message(overrides: Partial<ChatMessageInfo>): ChatMessageInfo {
return {
id: "message-1",
sessionId: "session-1",
role: "assistant",
content: "",
createdAt: "2026-07-19T00:00:00.000Z",
...overrides,
};
}
function renderMessage(overrides: Partial<ChatMessageInfo>, forcePlain = false) {
return render(
<StandardChatMessageItem
message={message(overrides)}
forcePlain={forcePlain}
agentName="Assistant"
hideAssistantIdentity={false}
showAssistantModelTag={false}
activeModelTag={null}
activeModelProvider={null}
activeSessionId="session-1"
/>,
);
}
async function expectPreview() {
await waitFor(() => expect(screen.getByTestId("native-structure-preview")).toHaveAttribute("data-kind", "mission"));
}
describe("StandardChatSurface native structure embeds", () => {
afterEach(() => {
cleanup();
fetchPreview.mockReset();
ensurePlannerSession.mockReset();
fetchPlannerSession.mockReset();
fetchSession.mockReset();
fetchMessages.mockReset();
attachStream.mockReset();
});
it.each([
["settled Markdown link", "[Mission](fusion://mission/M-001)"],
["settled bare assistant token", "Open fusion://mission/M-001 now."],
])("renders a preview for %s", async (_name, content) => {
fetchPreview.mockResolvedValue(available);
renderMessage({ content });
await expectPreview();
expect(screen.queryByText("fusion://mission/M-001")).not.toBeInTheDocument();
});
it.each([
["Markdown link", "[Mission](fusion://mission/M-001)"],
["bare token", "fusion://mission/M-001"],
])("renders a preview while streaming a %s", async (_name, streamingText) => {
fetchPreview.mockResolvedValue(available);
render(
<StandardStreamingMessage
streamingText={streamingText}
forcePlain={false}
agentName="Assistant"
hideAssistantIdentity={false}
showAssistantModelTag={false}
activeModelTag={null}
activeModelProvider={null}
/>,
);
await expectPreview();
});
it("renders a user bare token through the raw message tokenizer", async () => {
fetchPreview.mockResolvedValue(available);
renderMessage({ role: "user", content: "Please open fusion://mission/M-001." });
await expectPreview();
expect(screen.getByTestId("chat-message-message-1").textContent).toContain("Open.");
});
it("lifts a paragraph preview outside phrasing-only content", async () => {
fetchPreview.mockResolvedValue(available);
renderMessage({ content: "Before fusion://mission/M-001 after" });
await expectPreview();
expect(screen.getByTestId("native-structure-preview").closest("p, h1, h2, h3, h4, h5, h6")).toBeNull();
expect(screen.getByText("Before").closest("p")).not.toBeNull();
expect(screen.getByText("after").closest("p")).not.toBeNull();
});
it("lifts a heading preview into a sibling block without an empty heading", async () => {
fetchPreview.mockResolvedValue(available);
renderMessage({ content: "## fusion://mission/M-001" });
await expectPreview();
expect(screen.getByTestId("native-structure-preview").closest("h1, h2, h3, h4, h5, h6")).toBeNull();
expect(document.querySelector("h2")).toBeNull();
});
it.each([
["formatted list", "- **fusion://mission/M-001**", "li"],
["formatted table cell", "| Structure |\n| --- |\n| **fusion://mission/M-001** |", "td, th, table"],
])("lifts a preview from a %s instead of nesting its block root", async (_surface, content, forbiddenAncestor) => {
fetchPreview.mockResolvedValue(available);
renderMessage({ content });
await expectPreview();
expect(screen.getByTestId("native-structure-preview").closest(forbiddenAncestor)).toBeNull();
});
it.each([
["bare token", "**Before fusion://mission/M-001 after**"],
["Markdown link", "**Before [Mission](fusion://mission/M-001) after**"],
])("lifts a formatted %s outside its phrasing wrapper", async (_form, content) => {
fetchPreview.mockResolvedValue(available);
renderMessage({ content });
await expectPreview();
expect(screen.getByTestId("native-structure-preview").closest("strong, em, p, h1, h2, h3, h4, h5, h6")).toBeNull();
expect(screen.getByText("Before").closest("strong")).not.toBeNull();
expect(screen.getByText("after").closest("strong")).not.toBeNull();
});
it("uses the shared unavailable placeholder without crashing", async () => {
fetchPreview.mockResolvedValue({ available: false, kind: "mission", id: "M-404", reason: "soft-deleted" });
renderMessage({ content: "fusion://mission/M-404" });
await waitFor(() => expect(screen.getByTestId("native-structure-preview-unavailable")).toHaveAttribute("data-reason", "soft-deleted"));
});
it("routes roadmap references to the shared unavailable placeholder", async () => {
renderMessage({ content: "fusion://roadmap-item/R-001" });
await waitFor(() => expect(screen.getByTestId("native-structure-preview-unavailable")).toHaveAttribute("data-reason", "missing"));
});
it("preserves malformed references as text and blocks unsafe Markdown URLs", () => {
renderMessage({ content: "fusion://mission/M-001?query [unsafe](javascript:alert(1))" });
expect(screen.queryByTestId("native-structure-preview")).not.toBeInTheDocument();
expect(screen.getByText("fusion://mission/M-001?query")).toBeInTheDocument();
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
it("does not transform native-looking Markdown link labels or inline code", () => {
renderMessage({ content: "[fusion://mission/M-001](https://example.com) and `fusion://mission/M-002`" });
expect(screen.queryByTestId("native-structure-preview")).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "fusion://mission/M-001" })).toHaveAttribute("href", "https://example.com");
expect(screen.getByText("fusion://mission/M-002").tagName).toBe("CODE");
});
it.each(["[Mission](fusion://mission/M-001)", "fusion://mission/M-001"])("keeps %s raw in forcePlain mode", (content) => {
renderMessage({ content }, true);
expect(screen.queryByTestId("native-structure-preview")).not.toBeInTheDocument();
expect(screen.getByText(content)).toBeInTheDocument();
});
function setupPlannerMessages(messages: Array<Record<string, unknown>>, sessionOverrides: Record<string, unknown> = {}) {
const session = { id: "planner-session", agentId: "task-planner:FN-1", title: null, status: "active", projectId: "project-1", modelProvider: "anthropic", modelId: "claude", createdAt: "2026-07-19T00:00:00.000Z", updatedAt: "2026-07-19T00:00:00.000Z", cliSessionFile: null, cliExecutorAdapterId: null, inFlightGeneration: null, ...sessionOverrides };
fetchPlannerSession.mockResolvedValue({ session });
ensurePlannerSession.mockResolvedValue({ session });
fetchSession.mockResolvedValue({ session });
fetchMessages.mockResolvedValue({ messages: messages as never });
return session;
}
function renderPlannerChat() {
return render(<TaskPlannerChatTab task={{ id: "FN-1", description: "Task", column: "todo", dependencies: [], steps: [], currentStep: 0, createdAt: "2026-07-19T00:00:00.000Z", updatedAt: "2026-07-19T00:00:00.000Z" } as never} active planningModel={{ provider: "anthropic", modelId: "claude" }} projectId="project-1" addToast={vi.fn()} />);
}
it.each([
["settled assistant", { id: "planner-message", role: "assistant", content: "fusion://mission/M-001" }],
["settled user", { id: "planner-user-message", role: "user", content: "fusion://mission/M-001" }],
])("renders the shared preview in task-bound %s chat", async (_surface, plannerMessage) => {
fetchPreview.mockResolvedValue(available);
setupPlannerMessages([{ ...plannerMessage, sessionId: "planner-session", createdAt: "2026-07-19T00:00:00.000Z", thinkingOutput: null, metadata: null }]);
renderPlannerChat();
await expectPreview();
});
it("renders a reattached task-bound streaming preview from the in-flight session snapshot", async () => {
fetchPreview.mockResolvedValue(available);
attachStream.mockReturnValue({ close: vi.fn(), isConnected: () => true } as never);
setupPlannerMessages([], {
isGenerating: true,
inFlightGeneration: { status: "generating", streamingText: "**fusion://mission/M-001**", streamingThinking: "", toolCalls: [] },
});
renderPlannerChat();
await expectPreview();
expect(document.querySelector(".chat-message--streaming")).toBeInTheDocument();
expect(screen.getByTestId("native-structure-preview").closest("strong")).toBeNull();
});
it.each([
["desktop assistant room", {}, "assistant"],
["floating narrow assistant room", { floating: true, compactLayout: true }, "assistant"],
["desktop user room", {}, "user"],
])("renders the shared preview in ChatView %s", async (_surface, layout, role) => {
fetchPreview.mockResolvedValue(available);
setupMockRooms();
setupMockChat({
sessions: [activeSessionFixture],
filteredSessions: [activeSessionFixture],
activeSession: activeSessionFixture,
messages: [{ id: "room-message", sessionId: activeSessionFixture.id, role, content: "fusion://mission/M-001", createdAt: "2026-07-19T00:00:00.000Z" } as never],
});
await renderWithAct(<ChatView projectId="project-1" addToast={vi.fn()} {...layout} />);
await expectPreview();
});
});

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { nativeStructureChatRefMatcher, parseNativeStructureChatRef } from "../nativeStructureChatRef";
describe("parseNativeStructureChatRef", () => {
it.each([
"mission",
"milestone",
"roadmap-item",
"research-finding",
"eval-result",
"goal",
] as const)("parses the canonical %s form", (kind) => {
expect(parseNativeStructureChatRef(`fusion://${kind}/ID-001`)).toEqual({ kind, id: "ID-001" });
});
it.each([
"mission:M-001",
"fusion://unknown-kind/R-001",
"fusion://mission/",
"fusion://mission/M-001/extra",
"fusion://mission/M%2F001",
"fusion://mission/M%5C001",
"fusion://mission/%20",
"https://mission/M-001",
])("rejects malformed or unsupported reference %s", (value) => {
expect(parseNativeStructureChatRef(value)).toBeNull();
});
it("matches a bare canonical token within surrounding text without swallowing it", () => {
const source = "Read fusion://mission/M-001, then continue.";
const matches = Array.from(source.matchAll(nativeStructureChatRefMatcher));
expect(matches.map((match) => match[0])).toEqual(["fusion://mission/M-001,"]);
expect(source.slice(0, matches[0]!.index)).toBe("Read ");
expect(matches[0]![1]).toBe("fusion://mission/M-001");
expect(matches[0]![2]).toBe(",");
expect(source.slice((matches[0]!.index ?? 0) + matches[0]![0].length)).toBe(" then continue.");
});
it.each([
"fusion://mission/M-001?query",
"fusion://mission/M-001#fragment",
"fusion://mission/M-001/extra",
"fusion://mission/M-001%2Fextra",
])("does not match a valid prefix of malformed bare token %s", (value) => {
expect(Array.from(value.matchAll(nativeStructureChatRefMatcher))).toEqual([]);
});
it("keeps defined terminal prose punctuation outside the canonical token", () => {
const matches = Array.from("See fusion://mission/M.001.".matchAll(nativeStructureChatRefMatcher));
expect(matches[0]![1]).toBe("fusion://mission/M.001");
expect(matches[0]![2]).toBe(".");
});
});

View File

@@ -0,0 +1,58 @@
import type { NativeStructureRef } from "@fusion/core";
const NATIVE_STRUCTURE_KINDS = [
"mission",
"milestone",
"roadmap-item",
"research-finding",
"eval-result",
"goal",
] as const;
type NativeStructureChatKind = (typeof NATIVE_STRUCTURE_KINDS)[number];
const nativeStructureKindsPattern = NATIVE_STRUCTURE_KINDS.join("|");
const canonicalRefPattern = new RegExp(`^fusion://(${nativeStructureKindsPattern})/([^/?#\\s]+)$`);
const trailingProsePunctuationPattern = /[.,!?;:]+$/;
/**
* FNXC:NativeStructureEmbed 2026-07-19-19:30:
* Chat references use the explicit `fusion://<kind>/<id>` form instead of free-text matching so
* ordinary prose cannot accidentally fetch or expose a native structure. This matcher finds only
* complete bare tokens in surrounding text; every candidate is still validated by the parser.
*/
export const nativeStructureChatRefMatcher = new RegExp(
`(?<![A-Za-z0-9_-])(fusion://(?:${nativeStructureKindsPattern})/[A-Za-z0-9][A-Za-z0-9._:-]*?)([.,!?;:]*)?(?=$|[\\s<>()\\[\\]{}])`,
"g",
);
/** Returns the canonical token and only terminal prose punctuation consumed by the bare-token matcher. */
export function splitNativeStructureChatRefMatch(match: RegExpMatchArray): { token: string; trailingPunctuation: string } {
return { token: match[1] ?? match[0].replace(trailingProsePunctuationPattern, ""), trailingPunctuation: match[2] ?? "" };
}
/**
* FNXC:NativeStructureEmbed 2026-07-19-19:30:
* One strict parser is shared by assistant Markdown links, assistant text nodes, and raw user
* messages. Encoded path separators and extra segments are rejected so a display token cannot
* resolve a structure other than the one it visibly names.
*/
export function parseNativeStructureChatRef(hrefOrToken: string): NativeStructureRef | null {
const match = canonicalRefPattern.exec(hrefOrToken);
if (!match) return null;
const [, kind, id] = match;
if (!kind || !id || /%2f|%5c/i.test(id)) return null;
let decodedId: string;
try {
decodedId = decodeURIComponent(id);
} catch {
return null;
}
if (!decodedId.trim() || decodedId !== id || /[\\/]/.test(decodedId)) return null;
// FNXC:NativeStructureEmbed 2026-07-19-20:10: `roadmap-item` intentionally reaches the shared
// unavailable renderer until its adapter lands; retain the core ref contract rather than fork it here.
return { kind: kind as NativeStructureChatKind, id } as NativeStructureRef;
}

View File

@@ -0,0 +1,21 @@
import type { NativeStructurePreviewResult, NativeStructureRef } from "@fusion/core";
export const NATIVE_STRUCTURE_OPEN_EVENT = "fusion:native-structure-open";
export interface NativeStructureOpenEventDetail {
ref: NativeStructureRef;
payload: NativeStructurePreviewResult;
}
/**
* FNXC:NativeStructureEmbed 2026-07-19-19:30:
* StandardChatSurface is shared by room, task-bound, floating, and dock chat, so its preview
* callback crosses this narrow event boundary instead of adding navigation forks to each host.
* App owns translating the foundation's callback/view-state target into the active dashboard view.
*/
export function openNativeStructure(ref: NativeStructureRef, payload: NativeStructurePreviewResult): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent<NativeStructureOpenEventDetail>(NATIVE_STRUCTURE_OPEN_EVENT, {
detail: { ref, payload },
}));
}