From 122ce83b1b81c8895f4e319f193959729399a93f Mon Sep 17 00:00:00 2001 From: dovvnloading Date: Tue, 28 Jul 2026 20:01:30 -0400 Subject: [PATCH] R8a: restore Open Document View for chat and conversation nodes Both node kinds' right-click menus carried a disabled "Open Document View" stub with the tooltip "Document view integration isn't wired into the SPA yet" since the R7.6b Qt cutover deleted the legacy embedded document- viewer panel (graphlink_document_viewer_web.py) without a successor. DocumentViewDialog.tsx restores it as a read-only, scrollable modal showing a node's full content as markdown. Frontend-only: the content is already client-side (a chat node's own text, or a conversation node's message history), so there is no backend change and no new WS intent - only formatting and display. It reuses the app's existing shared Dialog surface (the same one About/Help/Settings/Chat Library use - a centered, single-open-across-the-app modal) rather than the raw-createPortal anchored-popup pattern NodeMenu/CodeExecutionApprovalPanel use, since this is not anchored to a click point. Conversation nodes format their message history into a numbered transcript, ported from the deleted app's own _history_to_markdown + _build_document_section("Conversation Transcript", ...): each non-blank message becomes "### N. Role", 1-indexed over the full history including skipped blanks (a blank message consumes its number rather than compressing the sequence), joined under one "## Conversation Transcript" heading. SceneCanvas.tsx owns the one piece of state this needs (which markdown is currently on display) and threads an onOpenDocumentView callback through toFlowNodes as a new optional third parameter, so the ~50 existing two-argument call sites in SceneCanvas.test.tsx are unaffected. Both node kinds guard against opening on empty content. Built via four agents working in parallel on disjoint files against one shared contract, then reviewed by two independent passes (cross-file contract correctness, and transcript-format/test-quality fidelity) - both came back clean. Verified independently: full backend suite (1033), frontend typecheck/lint/tests/build (813 tests, 0 lint errors), and a live browser check confirming the menu item is enabled, a real chat node's content renders in the dialog, and Escape closes it. Co-Authored-By: Claude Opus 5 --- web_ui/src/app/canvas/ChatNodeView.test.tsx | 28 +++- web_ui/src/app/canvas/ChatNodeView.tsx | 24 +++- .../app/canvas/ConversationNodeView.test.tsx | 25 +++- .../src/app/canvas/ConversationNodeView.tsx | 28 +++- .../app/canvas/DocumentViewDialog.test.tsx | 65 +++++++++ web_ui/src/app/canvas/DocumentViewDialog.tsx | 41 ++++++ web_ui/src/app/canvas/SceneCanvas.test.tsx | 123 ++++++++++++++++++ web_ui/src/app/canvas/SceneCanvas.tsx | 76 ++++++++++- web_ui/src/app/styles.css | 12 ++ 9 files changed, 392 insertions(+), 30 deletions(-) create mode 100644 web_ui/src/app/canvas/DocumentViewDialog.test.tsx create mode 100644 web_ui/src/app/canvas/DocumentViewDialog.tsx 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;