From bc4b679598f321a68e9e3076d3d8cab78f4ffc5a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 18 Jul 2026 19:36:57 -0700 Subject: [PATCH] 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) --- ...91-chat-native-structure-inline-preview.md | 7 + docs/dashboard-guide.md | 5 +- packages/dashboard/app/App.tsx | 31 +++ .../app/components/StandardChatSurface.tsx | 226 +++++++++++++-- ...dChatSurface.nativeStructureEmbed.test.tsx | 257 ++++++++++++++++++ .../__tests__/nativeStructureChatRef.test.ts | 54 ++++ .../app/components/nativeStructureChatRef.ts | 58 ++++ .../components/nativeStructureNavigation.ts | 21 ++ 8 files changed, 635 insertions(+), 24 deletions(-) create mode 100644 .changeset/fn-8291-chat-native-structure-inline-preview.md create mode 100644 packages/dashboard/app/components/__tests__/StandardChatSurface.nativeStructureEmbed.test.tsx create mode 100644 packages/dashboard/app/components/__tests__/nativeStructureChatRef.test.ts create mode 100644 packages/dashboard/app/components/nativeStructureChatRef.ts create mode 100644 packages/dashboard/app/components/nativeStructureNavigation.ts diff --git a/.changeset/fn-8291-chat-native-structure-inline-preview.md b/.changeset/fn-8291-chat-native-structure-inline-preview.md new file mode 100644 index 0000000000..3c5fcedd3f --- /dev/null +++ b/.changeset/fn-8291-chat-native-structure-inline-preview.md @@ -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:/// tokens and assistant Markdown links. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 786ac5a464..aa06767c2c 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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 recognizes native structure references in both assistant and user messages using the explicit `fusion:///` 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:///` 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. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 3f71b8dcbe..792301c233 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -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).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"; diff --git a/packages/dashboard/app/components/StandardChatSurface.tsx b/packages/dashboard/app/components/StandardChatSurface.tsx index 5b11c09e2e..12e01cf092 100644 --- a/packages/dashboard/app/components/StandardChatSurface.tsx +++ b/packages/dashboard/app/components/StandardChatSurface.tsx @@ -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(); + 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["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 `` or another phrasing-only container. + */ +function splitMarkdownNodeAtNativeStructurePreviews(node: ReactNode): NativeStructureMarkdownPart[] { + if (typeof node === "string") { + return renderNativeStructureChatTokens(node).map((part) => ( + React.isValidElement>(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).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, +): ReactNode { + const blocks: ReactNode[] = []; + let inlineChildren: ReactNode[] = []; + let index = 0; + const flushInlineChildren = () => { + if (inlineChildren.length > 0) { + blocks.push({linkifyReactChildren(inlineChildren)}); + inlineChildren = []; + index += 1; + } + }; + for (const part of React.Children.toArray(children).flatMap(splitMarkdownNodeAtNativeStructurePreviews)) { + if (isNativeStructurePreviewMarker(part)) { + flushInlineChildren(); + blocks.push(); + 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 ; + return {children}; +} + +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 {children}; + return {linkedChildren}; +} + export const standardChatMarkdownComponents: Components = { - p: ({ children, ...props }) =>

{linkifyReactChildren(children)}

, - li: ({ children, ...props }) =>
  • {linkifyReactChildren(children)}
  • , + 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 }) => {linkifyReactChildren(children)}, + th: ({ children, ...props }) => {linkifyReactChildren(children)}, + /* + 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 }) =>
    {children}
    , - 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 {children}; - return {linkedChildren}; - }, - table: ({ children, ...props }) => {children}
    , + 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 `
    ` 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): 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
    {content}
    ; - return
    {content}
    ; + return ( +
    + parseNativeStructureChatRef(href) ? href : defaultUrlTransform(href)} + > + {content} + +
    + ); } 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(@{mentionedAgent.name.replace(/\s+/g, "_")}); + if (token.type === "native-structure") { + const { token: referenceToken, trailingPunctuation } = splitNativeStructureChatRefMatch(token.match); + const structureRef = parseNativeStructureChatRef(referenceToken); + parts.push(structureRef ? {trailingPunctuation} : 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(@{mentionedAgent.name.replace(/\s+/g, "_")}); + } 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; diff --git a/packages/dashboard/app/components/__tests__/StandardChatSurface.nativeStructureEmbed.test.tsx b/packages/dashboard/app/components/__tests__/StandardChatSurface.nativeStructureEmbed.test.tsx new file mode 100644 index 0000000000..247013d977 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/StandardChatSurface.nativeStructureEmbed.test.tsx @@ -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(); + return { ...actual, useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }) }; +}); + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }) }; +}); +vi.mock("../../api", async (importOriginal) => { + const actual = await importOriginal(); + 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 { + return { + id: "message-1", + sessionId: "session-1", + role: "assistant", + content: "", + createdAt: "2026-07-19T00:00:00.000Z", + ...overrides, + }; +} + +function renderMessage(overrides: Partial, forcePlain = false) { + return render( + , + ); +} + +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( + , + ); + 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>, sessionOverrides: Record = {}) { + 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(); + } + + 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(); + await expectPreview(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/nativeStructureChatRef.test.ts b/packages/dashboard/app/components/__tests__/nativeStructureChatRef.test.ts new file mode 100644 index 0000000000..e73854d4e6 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/nativeStructureChatRef.test.ts @@ -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("."); + }); +}); diff --git a/packages/dashboard/app/components/nativeStructureChatRef.ts b/packages/dashboard/app/components/nativeStructureChatRef.ts new file mode 100644 index 0000000000..ba116e8c63 --- /dev/null +++ b/packages/dashboard/app/components/nativeStructureChatRef.ts @@ -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:///` 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( + `(?()\\[\\]{}])`, + "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; +} diff --git a/packages/dashboard/app/components/nativeStructureNavigation.ts b/packages/dashboard/app/components/nativeStructureNavigation.ts new file mode 100644 index 0000000000..f28e37f186 --- /dev/null +++ b/packages/dashboard/app/components/nativeStructureNavigation.ts @@ -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(NATIVE_STRUCTURE_OPEN_EVENT, { + detail: { ref, payload }, + })); +}