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:
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(".");
|
||||
});
|
||||
});
|
||||
58
packages/dashboard/app/components/nativeStructureChatRef.ts
Normal file
58
packages/dashboard/app/components/nativeStructureChatRef.ts
Normal 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;
|
||||
}
|
||||
@@ -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 },
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user