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.
<!-- 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
- 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.
@@ -1898,7 +1901,7 @@ The dashboard's CSS is split into a global stylesheet (`packages/dashboard/app/s
### 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.

View File

@@ -113,6 +113,7 @@ export {
import { subscribeSse } from "./sse-bus";
import { AuthTokenRecoveryDialog } from "./components/AuthTokenRecoveryDialog";
import { MainContent } from "./components/dashboard/MainContent";
import { NATIVE_STRUCTURE_OPEN_EVENT, type NativeStructureOpenEventDetail } from "./components/nativeStructureNavigation";
import { DashboardBanners } from "./components/dashboard/DashboardBanners";
import type { DashboardBannersProps, MainContentProps } from "./components/dashboard/types";
import type { GraphWorkflowSelection } from "./components/GraphWorkflowSwitcherSlot";
@@ -481,6 +482,36 @@ function AppInner() {
}
}, [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:
// 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";

View File

@@ -1,6 +1,6 @@
import type { Agent } from "@fusion/core";
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 remarkGfm from "remark-gfm";
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 { ChatQuestionResponse } from "./ChatQuestionResponse";
import { ProviderIcon } from "./ProviderIcon";
import { NativeStructurePreview } from "./NativeStructurePreview";
import { openNativeStructure } from "./nativeStructureNavigation";
import { nativeStructureChatRefMatcher, parseNativeStructureChatRef, splitNativeStructureChatRefMatch } from "./nativeStructureChatRef";
export interface StandardRoomContext {
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 = {
p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>,
li: ({ children, ...props }) => <li {...props}>{linkifyReactChildren(children)}</li>,
p: ({ children, ...props }) => renderMarkdownBlockWithNativeStructurePreviews("p", children, props),
// 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>,
code: ({ children, ...props }) => {
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>;
},
table: ({ children, ...props }) => <table {...props} className="chat-markdown-table">{children}</table>,
code: NativeStructureMarkdownCode,
// FNXC:NativeStructureEmbed 2026-07-20-01:00: Lift markers through the full table tree so a
// formatted token in a cell never creates invalid `<td><div>` markup. The card becomes a
// sibling block; non-reference table content retains its normal table structure.
table: ({ children, ...props }) => renderMarkdownBlockWithNativeStructurePreviews("table", children, { ...props, className: "chat-markdown-table" }),
};
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 {
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({
@@ -413,24 +578,39 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
if (isAssistantMessage) return null;
const content = message.content;
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[] = [];
let lastIndex = 0;
let match = mentionRegex.exec(content);
while (match) {
const [fullMatch, rawName = ""] = match;
const start = match.index;
/*
FNXC:NativeStructureEmbed 2026-07-19-19:30:
User bodies are intentionally raw text rather than Markdown. Extend their existing mention
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));
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>);
if (token.type === "native-structure") {
const { token: referenceToken, trailingPunctuation } = splitNativeStructureChatRefMatch(token.match);
const structureRef = parseNativeStructureChatRef(referenceToken);
parts.push(structureRef ? <React.Fragment key={`native-structure-user-${start}`}><NativeStructurePreview ref={structureRef} onOpen={openNativeStructure} />{trailingPunctuation}</React.Fragment> : fullMatch);
} 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;
match = mentionRegex.exec(content);
}
if (lastIndex < content.length) parts.push(content.slice(lastIndex));
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 },
}));
}