diff --git a/web_ui/src/app/canvas/ChatNodeView.test.tsx b/web_ui/src/app/canvas/ChatNodeView.test.tsx index aa5b7ce..ddb429f 100644 --- a/web_ui/src/app/canvas/ChatNodeView.test.tsx +++ b/web_ui/src/app/canvas/ChatNodeView.test.tsx @@ -33,6 +33,7 @@ function renderChatNode(overrides: Partial = {}) { const onGenerateChart = vi.fn(); const onGenerateKeyTakeaway = vi.fn(); const onGenerateExplainerNote = vi.fn(); + const onOpenDocumentView = vi.fn(); const onScrollChange = vi.fn(); const props = { id: "n0", @@ -51,6 +52,7 @@ function renderChatNode(overrides: Partial = {}) { onGenerateChart, onGenerateKeyTakeaway, onGenerateExplainerNote, + onOpenDocumentView, onScrollChange, ...overrides, }, @@ -64,7 +66,7 @@ function renderChatNode(overrides: Partial = {}) { return { onToggleCollapse, onDelete, onUndockChild, onRegenerate, onGenerateImage, onGenerateChart, onGenerateKeyTakeaway, onGenerateExplainerNote, - onScrollChange, container, + onOpenDocumentView, onScrollChange, container, }; } @@ -103,12 +105,15 @@ describe("ChatNodeView", () => { const hideBranches = screen.getByRole("menuitem", { name: "Hide Other Branches" }); expect(hideBranches).toBeDisabled(); expect(hideBranches).toHaveAttribute("title", "Branch visibility isn't built yet"); + // R8a: these three were disabled stubs until their agents/dialog were + // wired up (Open Document View: the shared DocumentViewDialog; Key + // Takeaway/Explainer Note: ported back from the deleted Qt app). This + // assertion is deliberately inverted rather than removed - it was the + // guard that encoded the stub as correct, so it has to now encode the + // opposite. const docView = screen.getByRole("menuitem", { name: "Open Document View" }); - expect(docView).toBeDisabled(); - // R8a: these two were disabled stubs until their agents were ported - // back from the deleted Qt app. This assertion is deliberately inverted - // rather than removed - it was the guard that encoded the stub as - // correct, so it has to now encode the opposite. + expect(docView).not.toBeDisabled(); + expect(docView).not.toHaveAttribute("title"); for (const name of ["Generate Key Takeaway", "Generate Explainer Note"]) { const item = screen.getByRole("menuitem", { name }); expect(item).not.toBeDisabled(); @@ -181,6 +186,17 @@ describe("ChatNodeView", () => { expect(screen.queryByRole("menu")).toBeNull(); // onClose fires after onGenerateImage }); + it("Open Document View calls onOpenDocumentView then closes the menu", async () => { + const user = userEvent.setup(); + const { onOpenDocumentView } = renderChatNode({ isUser: false }); + + fireEvent.contextMenu(screen.getByText("Assistant")); + await user.click(screen.getByRole("menuitem", { name: "Open Document View" })); + + expect(onOpenDocumentView).toHaveBeenCalledOnce(); + expect(screen.queryByRole("menu")).toBeNull(); + }); + it("Generate Key Takeaway calls onGenerateKeyTakeaway then closes the menu", async () => { const user = userEvent.setup(); const { onGenerateKeyTakeaway, onGenerateExplainerNote } = renderChatNode({ isUser: false }); diff --git a/web_ui/src/app/canvas/ChatNodeView.tsx b/web_ui/src/app/canvas/ChatNodeView.tsx index e7c22c3..58a6d48 100644 --- a/web_ui/src/app/canvas/ChatNodeView.tsx +++ b/web_ui/src/app/canvas/ChatNodeView.tsx @@ -15,10 +15,8 @@ import { NodeMenu } from "./NodeMenu"; * honest disabled+title label rather than a fake action or a silent drop * (an R3.4 live-drive audit found several legacy ChatNode menu items had * been dropped with zero acknowledgment - fixed here): Regenerate (assistant - * nodes only, needs the R4 agent layer), Open Document View (the - * document-viewer island isn't wired into the SPA overlay system yet), and - * Hide Other Branches (the legacy scene's - * branch-visibility toggle has no backend/frontend equivalent at all yet - + * nodes only, needs the R4 agent layer), and Hide Other Branches (the + * legacy scene's branch-visibility toggle has no backend/frontend equivalent at all yet - * unscoped, not owned by any R-phase). One legacy item is still deliberately * NOT listed even as disabled: "Generate Group Summary" is itself * conditionally hidden in the legacy menu (only when a multi-selection @@ -46,6 +44,9 @@ import { NodeMenu } from "./NodeMenu"; * agent layer had been stale since R4 - the very layer Regenerate/Image/ * Chart already use. Both now dispatch real intents that drop the agent's * output into a new note beside this node (graphlink_note_agent.py). + * "Open Document View" is likewise no longer deferred as of this change: it + * now opens DocumentViewDialog.tsx with this node's own content + * (frontend-only, no backend intent - the content is already client-side). * "Export" is * likewise no longer deferred as of R7.5a: it downloads the node's raw * content (not the rendered markdown) as a .md file via downloadTextFile - @@ -76,6 +77,7 @@ export interface ChatNodeData extends Record { onGenerateChart: (chartType: string) => void; onGenerateKeyTakeaway: () => void; onGenerateExplainerNote: () => void; + onOpenDocumentView: () => void; onScrollChange: (value: number) => void; } @@ -114,6 +116,7 @@ function ChatNodeMenu({ onGenerateChart, onGenerateKeyTakeaway, onGenerateExplainerNote, + onOpenDocumentView, onClose, }: { position: MenuPosition; @@ -130,6 +133,7 @@ function ChatNodeMenu({ onGenerateChart: (chartType: string) => void; onGenerateKeyTakeaway: () => void; onGenerateExplainerNote: () => void; + onOpenDocumentView: () => void; onClose: () => void; }) { const [chartMenuOpen, setChartMenuOpen] = useState(false); @@ -192,7 +196,16 @@ function ChatNodeMenu({ ))} )} - {/* R8a: real. Each runs its agent over THIS node's text and drops the @@ -388,6 +401,7 @@ export function ChatNodeView({ id, data, selected }: NodeProps) { onGenerateChart={data.onGenerateChart} onGenerateKeyTakeaway={data.onGenerateKeyTakeaway} onGenerateExplainerNote={data.onGenerateExplainerNote} + onOpenDocumentView={data.onOpenDocumentView} onClose={() => setMenuPosition(null)} /> )} diff --git a/web_ui/src/app/canvas/ConversationNodeView.test.tsx b/web_ui/src/app/canvas/ConversationNodeView.test.tsx index 7b105cc..41ba12e 100644 --- a/web_ui/src/app/canvas/ConversationNodeView.test.tsx +++ b/web_ui/src/app/canvas/ConversationNodeView.test.tsx @@ -13,6 +13,7 @@ function renderConversationNode(overrides: Partial const onSend = vi.fn(); const onDeleteMessage = vi.fn(); const onCancel = vi.fn(); + const onOpenDocumentView = vi.fn(); const props = { id: "n0", selected: false, @@ -28,6 +29,7 @@ function renderConversationNode(overrides: Partial onSend, onDeleteMessage, onCancel, + onOpenDocumentView, ...overrides, }, } as unknown as NodeProps; @@ -37,7 +39,7 @@ function renderConversationNode(overrides: Partial , ); - return { onToggleCollapse, onDelete, onSend, onDeleteMessage, onCancel }; + return { onToggleCollapse, onDelete, onSend, onDeleteMessage, onCancel, onOpenDocumentView }; } // Directly sets the React Flow internal Zustand store's transform/zoom @@ -61,6 +63,7 @@ function renderConversationNodeAtZoom(zoom: number, overrides: Partial; @@ -182,7 +186,7 @@ describe("ConversationNodeView", () => { expect(screen.queryByRole("menuitem", { name: "Delete Node" })).toBeNull(); }); - it("the node-level right-click menu shows exactly 3 items: Open Document View (disabled, exact title), Collapse/Expand (real), Delete Node (real)", async () => { + it("the node-level right-click menu shows exactly 3 items: Open Document View (real), Collapse/Expand (real), Delete Node (real)", async () => { const user = userEvent.setup(); const { onDelete, onToggleCollapse } = renderConversationNode(); @@ -195,10 +199,7 @@ describe("ConversationNodeView", () => { expect(items).toHaveLength(3); const docView = screen.getByRole("menuitem", { name: "Open Document View" }); - expect(docView).toBeDisabled(); - // Copied verbatim from ChatNodeView.tsx's own disabled "Open Document - // View" menu item title string. - expect(docView).toHaveAttribute("title", "Document view integration isn't wired into the SPA yet"); + expect(docView).toBeEnabled(); const collapseItem = screen.getByRole("menuitem", { name: "Collapse" }); expect(collapseItem).toBeEnabled(); @@ -220,6 +221,18 @@ describe("ConversationNodeView", () => { expect(onDelete).toHaveBeenCalledOnce(); }); + it("Open Document View calls onOpenDocumentView then closes the menu", async () => { + const user = userEvent.setup(); + const { onOpenDocumentView } = renderConversationNode(); + + const header = screen.getByText("Conversation"); + fireEvent.contextMenu(header); + await user.click(screen.getByRole("menuitem", { name: "Open Document View" })); + + expect(onOpenDocumentView).toHaveBeenCalledOnce(); + expect(screen.queryByRole("menu")).toBeNull(); + }); + it("the node-level menu's Collapse/Expand label flips when isCollapsed is true", () => { renderConversationNode({ isCollapsed: true }); // isCollapsed alone collapses the body, so use the header label to open diff --git a/web_ui/src/app/canvas/ConversationNodeView.tsx b/web_ui/src/app/canvas/ConversationNodeView.tsx index 7ca78db..1b6a341 100644 --- a/web_ui/src/app/canvas/ConversationNodeView.tsx +++ b/web_ui/src/app/canvas/ConversationNodeView.tsx @@ -21,18 +21,18 @@ import { NodeMenu } from "./NodeMenu"; * node is never a branch point/reparented, same as code/thinking/html/image), * per-bubble copy + delete-from-history, Send (appends a real user message * and triggers the real agent reply via the backend agent layer - see - * sendConversationMessage's own backend docstring), and (R4.3) Cancel: a + * sendConversationMessage's own backend docstring), (R4.3) Cancel: a * real per-node cancel affordance, the exact same conditional-render pattern * as the Composer's own Cancel button (Composer.tsx) applied one level down * - per-node instead of per-session. Cancel is rendered only while this * node's own data.pendingRequestId is non-null (this node has an in-flight * reply of its own), and Send is additionally disabled while that same * field is set, so a second send can't be issued mid-flight for this node. - * Still deferred, with an honest disabled+title label rather than a silent - * drop (same audit convention every prior node kind in this plan has - * followed): Open Document View (the document-viewer island isn't wired - * into the SPA overlay system yet - same reason ChatNodeView's own menu - * defers it). + * (R8a) Open Document View is real too: it opens the shared + * DocumentViewDialog (frontend-only, no backend intent) with this node's + * entire history formatted as a numbered markdown transcript - see + * DocumentViewDialog.tsx / SceneCanvas.tsx's toFlowNodes for the transcript + * formatter. * * Card menu deliberately does NOT include "Hide Other Branches" or "Include * Previous Branch Context": the legacy PluginNodeContextMenu for this node @@ -56,6 +56,7 @@ export interface ConversationNodeData extends Record { onSend: (text: string) => void; onDeleteMessage: (index: number) => void; onCancel: () => void; + onOpenDocumentView: () => void; } export type ConversationFlowNode = Node; @@ -74,12 +75,14 @@ function ConversationNodeMenu({ isCollapsed, onToggleCollapse, onDelete, + onOpenDocumentView, onClose, }: { position: MenuPosition; isCollapsed: boolean; onToggleCollapse: () => void; onDelete: () => void; + onOpenDocumentView: () => void; onClose: () => void; }) { @@ -88,7 +91,17 @@ function ConversationNodeMenu({ {/* Order verified against the legacy PluginNodeContextMenu's own construction order for this node kind: Open Document View, Collapse/Expand, Delete Node - nothing else. */} - + ); +} + +function setup(content: string | null) { + const user = userEvent.setup(); + render( + + + + , + ); + return { user }; +} + +describe("DocumentViewDialog", () => { + it("renders nothing when closed", () => { + setup("# Hello"); + + expect(screen.queryByRole("dialog")).toBeNull(); + expect(screen.queryByText("Hello")).toBeNull(); + }); + + it("shows the dialog with the fixed title after opening via the trigger", async () => { + const { user } = setup("some content"); + await user.click(screen.getByText("open document view")); + + const dialog = screen.getByRole("dialog"); + expect(dialog).toBeInTheDocument(); + expect(screen.getByText("Document View")).toBeInTheDocument(); + }); + + it("renders markdown content passed via the content prop", async () => { + const { user } = setup("# Heading\n\nA paragraph of body text."); + await user.click(screen.getByText("open document view")); + + expect(screen.getByRole("heading", { name: "Heading" })).toBeInTheDocument(); + expect(screen.getByText("A paragraph of body text.")).toBeInTheDocument(); + }); + + it("does not crash and renders an empty body when content is null", async () => { + const { user } = setup(null); + await user.click(screen.getByText("open document view")); + + const dialog = screen.getByRole("dialog"); + expect(dialog).toBeInTheDocument(); + expect(screen.getByText("Document View")).toBeInTheDocument(); + }); +}); diff --git a/web_ui/src/app/canvas/DocumentViewDialog.tsx b/web_ui/src/app/canvas/DocumentViewDialog.tsx new file mode 100644 index 0000000..e428bdc --- /dev/null +++ b/web_ui/src/app/canvas/DocumentViewDialog.tsx @@ -0,0 +1,41 @@ +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import rehypeHighlight from "rehype-highlight"; +import { Dialog } from "../overlays/overlays"; + +/** + * Document View dialog (finishes a legacy stub carried over from the Qt + * cutover, R7.6b) - the SPA successor to graphlink_document_viewer_web.py, + * which the Qt app deleted rather than ported. That panel showed a node's + * full text as a read-only, scrollable document; the SPA rebuild left + * "Open Document View" wired as a disabled menu stub with an honest tooltip + * until this content had somewhere to render. + * + * Frontend-only: the content a node has to show (a chat node's own message, + * or a conversation node's assembled transcript) already lives in this + * browser's own scene state - there is nothing to fetch, no backend + * involvement, no new WS intent. SceneCanvas.tsx builds the markdown string + * and hands it down as `content`; this component only renders it. + * + * Reuses the shared, centered `Dialog` surface (the same category as + * About/Help/Settings - one app-wide, single-open surface) rather than the + * raw-createPortal pattern NodeMenu.tsx / CodeExecutionApprovalPanel.tsx + * use, since those are anchored popups tied to a click point and this is + * not anchored to anything on the canvas. + * + * The title is a fixed "Document View" rather than something per-node + * (e.g. including the node's title) because legacy's own panel never had a + * dynamic per-open title either - this restores the original behavior + * rather than inventing new behavior. + */ +export function DocumentViewDialog({ content }: { content: string | null }) { + return ( + +
+ + {content ?? ""} + +
+
+ ); +} diff --git a/web_ui/src/app/canvas/SceneCanvas.test.tsx b/web_ui/src/app/canvas/SceneCanvas.test.tsx index 784e033..714fe93 100644 --- a/web_ui/src/app/canvas/SceneCanvas.test.tsx +++ b/web_ui/src/app/canvas/SceneCanvas.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { applyGroupDragDelta, + conversationHistoryToDocumentMarkdown, groupDragKindOf, handleSelectionChange, isOrthogonalEligible, @@ -10,6 +11,7 @@ import { withPreservedSelection, type SceneFlowNode, } from "./SceneCanvas"; +import type { ConversationMessage } from "./ConversationNodeView"; import { SceneStore, initialSceneState } from "./sceneStore"; import type { WsTransport } from "../../lib/ws/transport"; import type { SceneNodeRow, SceneState } from "../../lib/bridge-core/generated/scene-state"; @@ -204,6 +206,127 @@ describe("toFlowNodes (R4.4a Generate/Regenerate Image wiring)", () => { }); }); +describe("conversationHistoryToDocumentMarkdown (R8a Open Document View transcript formatter)", () => { + it("formats two messages with 1-based numbered headings, joined by a blank line", () => { + const history: ConversationMessage[] = [ + { role: "user", content: "hi" }, + { role: "assistant", content: "hello there" }, + ]; + expect(conversationHistoryToDocumentMarkdown(history)).toBe( + "## Conversation Transcript\n\n### 1. User\n\nhi\n\n### 2. Assistant\n\nhello there", + ); + }); + + it("skips a blank message but its number still counts (legacy enumerate-before-filter behavior)", () => { + const history: ConversationMessage[] = [ + { role: "user", content: "first" }, + { role: "assistant", content: " " }, + { role: "user", content: "third" }, + ]; + const result = conversationHistoryToDocumentMarkdown(history); + expect(result).toBe("## Conversation Transcript\n\n### 1. User\n\nfirst\n\n### 3. User\n\nthird"); + expect(result).not.toContain("### 2."); + }); + + it("returns an empty string for an empty history", () => { + expect(conversationHistoryToDocumentMarkdown([])).toBe(""); + }); + + it("returns an empty string when every message is blank", () => { + const history: ConversationMessage[] = [ + { role: "user", content: "" }, + { role: "assistant", content: " " }, + ]; + expect(conversationHistoryToDocumentMarkdown(history)).toBe(""); + }); +}); + +describe("toFlowNodes (R8a Open Document View wiring)", () => { + it("a chat node's onOpenDocumentView invokes the third-argument callback with its own content", () => { + const scene = baseScene({ + nodes: [baseNode({ id: "chat-1", kind: "chat", content: "Hello world" })], + edges: [], + }); + const store = makeStore(); + const onOpenDocumentView = vi.fn(); + + const flowNodes = toFlowNodes(scene, store, onOpenDocumentView); + const chatFlowNode = flowNodes.find((n) => n.id === "chat-1"); + expect(chatFlowNode).toBeDefined(); + + (chatFlowNode!.data as { onOpenDocumentView: () => void }).onOpenDocumentView(); + expect(onOpenDocumentView).toHaveBeenCalledWith("Hello world"); + }); + + it("a chat node with blank/whitespace-only content does NOT invoke the callback", () => { + const scene = baseScene({ + nodes: [baseNode({ id: "chat-1", kind: "chat", content: " " })], + edges: [], + }); + const store = makeStore(); + const onOpenDocumentView = vi.fn(); + + const flowNodes = toFlowNodes(scene, store, onOpenDocumentView); + const chatFlowNode = flowNodes.find((n) => n.id === "chat-1"); + expect(chatFlowNode).toBeDefined(); + + (chatFlowNode!.data as { onOpenDocumentView: () => void }).onOpenDocumentView(); + expect(onOpenDocumentView).not.toHaveBeenCalled(); + }); + + it("a conversation node's onOpenDocumentView invokes the callback with the properly formatted transcript", () => { + const history: ConversationMessage[] = [ + { role: "user", content: "hi" }, + { role: "assistant", content: "hello there" }, + ]; + const scene = baseScene({ + nodes: [baseNode({ id: "conv-1", kind: "conversation", history })], + edges: [], + }); + const store = makeStore(); + const onOpenDocumentView = vi.fn(); + + const flowNodes = toFlowNodes(scene, store, onOpenDocumentView); + const conversationFlowNode = flowNodes.find((n) => n.id === "conv-1"); + expect(conversationFlowNode).toBeDefined(); + + (conversationFlowNode!.data as { onOpenDocumentView: () => void }).onOpenDocumentView(); + expect(onOpenDocumentView).toHaveBeenCalledWith( + "## Conversation Transcript\n\n### 1. User\n\nhi\n\n### 2. Assistant\n\nhello there", + ); + }); + + it("a conversation node with an empty history does NOT invoke the callback", () => { + const scene = baseScene({ + nodes: [baseNode({ id: "conv-1", kind: "conversation", history: [] })], + edges: [], + }); + const store = makeStore(); + const onOpenDocumentView = vi.fn(); + + const flowNodes = toFlowNodes(scene, store, onOpenDocumentView); + const conversationFlowNode = flowNodes.find((n) => n.id === "conv-1"); + expect(conversationFlowNode).toBeDefined(); + + (conversationFlowNode!.data as { onOpenDocumentView: () => void }).onOpenDocumentView(); + expect(onOpenDocumentView).not.toHaveBeenCalled(); + }); + + it("toFlowNodes called with only two arguments (the existing ~50 call sites in this file) still compiles and does not throw when onOpenDocumentView would otherwise fire", () => { + const scene = baseScene({ + nodes: [baseNode({ id: "chat-1", kind: "chat", content: "Hello world" })], + edges: [], + }); + const store = makeStore(); + + const flowNodes = toFlowNodes(scene, store); + const chatFlowNode = flowNodes.find((n) => n.id === "chat-1"); + expect(chatFlowNode).toBeDefined(); + + expect(() => (chatFlowNode!.data as { onOpenDocumentView: () => void }).onOpenDocumentView()).not.toThrow(); + }); +}); + describe("toFlowNodes (R5.1 web_research node)", () => { it("maps a web_research scene node's all 6 new fields onto the flow node's data", () => { const researchResult = { diff --git a/web_ui/src/app/canvas/SceneCanvas.tsx b/web_ui/src/app/canvas/SceneCanvas.tsx index 394c0c3..6f328ad 100644 --- a/web_ui/src/app/canvas/SceneCanvas.tsx +++ b/web_ui/src/app/canvas/SceneCanvas.tsx @@ -20,13 +20,15 @@ import "@xyflow/react/dist/style.css"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import type { SceneNodeRow, SceneState } from "../../lib/bridge-core/generated/scene-state"; import type { StreamListener } from "../../lib/ws/transport"; +import { useOverlays } from "../overlays/overlays"; import { ArtifactNodeView, type ArtifactFlowNode } from "./ArtifactNodeView"; import { ChartNodeView, type ChartFlowNode } from "./ChartNodeView"; import { ChatNodeView, type ChatFlowNode } from "./ChatNodeView"; import { CodeNodeView, type CodeFlowNode } from "./CodeNodeView"; import { CodeSandboxNodeView, type CodeSandboxFlowNode } from "./CodeSandboxNodeView"; -import { ConversationNodeView, type ConversationFlowNode } from "./ConversationNodeView"; +import { ConversationNodeView, type ConversationFlowNode, type ConversationMessage } from "./ConversationNodeView"; import { DocumentNodeView, type DocumentFlowNode } from "./DocumentNodeView"; +import { DocumentViewDialog } from "./DocumentViewDialog"; import { GitlinkNodeView, type GitlinkFlowNode } from "./GitlinkNodeView"; import { GroupNodeView, type GroupFlowNode } from "./GroupNodeView"; import { HtmlNodeView, type HtmlFlowNode } from "./HtmlNodeView"; @@ -186,10 +188,35 @@ const EDGE_TYPES = { orthogonal: OrthogonalEdge, }; +// R8a: ports the deleted Qt app's own `_history_to_markdown` + +// `_build_document_section("Conversation Transcript", ...)` +// (graphlink_window.py) byte-for-byte - the exact markdown a conversation +// node's "Open Document View" menu item renders. Numbering is 1-based over +// ALL messages (including blank ones that get skipped), since legacy's own +// `enumerate(history, start=1)` numbers BEFORE filtering - a skipped message +// still consumes its number. Returns "" when no message survives (empty +// history, or every message blank) - this is what toFlowNodes' own +// "don't open on nothing" guard below keys off of. +export function conversationHistoryToDocumentMarkdown(history: ConversationMessage[]): string { + const blocks: string[] = []; + history.forEach((message, index) => { + const trimmed = message.content.trim(); + if (!trimmed) return; + const role = message.role === "user" ? "User" : "Assistant"; + blocks.push(`### ${index + 1}. ${role}\n\n${trimmed}`); + }); + if (blocks.length === 0) return ""; + return `## Conversation Transcript\n\n${blocks.join("\n\n")}`; +} + // Exported standalone for direct unit testing (same posture as // scaleDragPosition in sceneStore.ts) - covers the parentChatNodeId // derivation below without needing a full mount. -export function toFlowNodes(scene: SceneState, store: SceneStore): SceneFlowNode[] { +export function toFlowNodes( + scene: SceneState, + store: SceneStore, + onOpenDocumentView: (markdown: string) => void = () => {}, +): SceneFlowNode[] { // Looked up per-chat-node below to build dockedChildren - a docked node is // omitted from the returned array entirely (see the "thinking" branch), so // this is the only remaining way a chat node's dock badge/menu can find it. @@ -244,6 +271,14 @@ export function toFlowNodes(scene: SceneState, store: SceneStore): SceneFlowNode // arrives through the next scene snapshot. onGenerateKeyTakeaway: () => store.generateKeyTakeaway(n.id), onGenerateExplainerNote: () => store.generateExplainerNote(n.id), + // R8a: Open Document View - shows this node's own message text + // verbatim in the read-only document modal. Guards on non-blank + // content the same way legacy's own document-view action silently + // no-op'd on nothing (see conversationHistoryToDocumentMarkdown's + // own doc above for the sibling conversation-node guard). + onOpenDocumentView: () => { + if (n.content.trim()) onOpenDocumentView(n.content); + }, // R6.3: the node's own scroll position within its content area - // read on mount by ChatNodeView (restore) and reported (debounced) // via the new setChatScrollValue intent on every scroll. Defaults @@ -413,6 +448,16 @@ export function toFlowNodes(scene: SceneState, store: SceneStore): SceneFlowNode onCancel: () => { if (n.pendingRequestId) store.cancelConversationRequest(n.pendingRequestId); }, + // R8a: Open Document View - the node's ENTIRE message history, + // formatted as a numbered transcript (see + // conversationHistoryToDocumentMarkdown's own doc above). Guards + // on a non-empty formatted result (empty history, or every + // message blank, both format to "") the same way the chat branch + // above guards on non-blank content. + onOpenDocumentView: () => { + const markdown = conversationHistoryToDocumentMarkdown(n.history); + if (markdown) onOpenDocumentView(markdown); + }, }, }); continue; @@ -990,6 +1035,22 @@ function CanvasInner({ store }: { store: SceneStore }) { const [smartGuideLines, setSmartGuideLines] = useState([]); const visibleGuideLines = scene.smartGuides ? smartGuideLines : []; + // R8a: Open Document View - the read-only markdown modal a chat/ + // conversation node's card menu opens (see toFlowNodes' own + // onOpenDocumentView field on each of those two branches). The displayed + // markdown is local-only state (never scene state, same posture as + // hoveredEdgeId/smartGuideLines above) - SceneCanvas is the only place + // that knows how to open this dialog. + const overlays = useOverlays(); + const [documentViewContent, setDocumentViewContent] = useState(null); + const onOpenDocumentView = useCallback( + (markdown: string) => { + setDocumentViewContent(markdown); + overlays.open("document-view", "dialog"); + }, + [overlays], + ); + // R6.3: viewport (pan/zoom) reporting - see makeDebouncedViewportReport's // own doc above. onMove fires on every frame of a pan/zoom gesture (never // just once at the end, unlike NodeResizer's onResizeEnd), so the debounce @@ -1013,8 +1074,8 @@ function CanvasInner({ store }: { store: SceneStore }) { useEffect(() => { if (draggingRef.current) return; - setNodes((current) => withPreservedSelection(toFlowNodes(scene, store), current)); - }, [scene, store]); + setNodes((current) => withPreservedSelection(toFlowNodes(scene, store, onOpenDocumentView), current)); + }, [scene, store, onOpenDocumentView]); const edges = useMemo(() => toFlowEdges(scene, hoveredEdgeId), [scene, hoveredEdgeId]); @@ -1208,7 +1269,8 @@ function CanvasInner({ store }: { store: SceneStore }) { ); return ( -
+ <> +
)} -
+
+ + ); } diff --git a/web_ui/src/app/styles.css b/web_ui/src/app/styles.css index 7022333..8d1142c 100644 --- a/web_ui/src/app/styles.css +++ b/web_ui/src/app/styles.css @@ -1950,6 +1950,18 @@ body, height: 100%; } +/* -- Document view dialog (R8a) -------------------------------------------- */ + +.document-view-dialog { + min-width: 480px; + width: min(760px, calc(100vw - 64px)); +} + +.document-view-dialog .chat-node-content { + max-height: none; + overflow: visible; +} + .help-rail { width: 220px; flex-shrink: 0;