diff --git a/backend/plugins.py b/backend/plugins.py index 1d2beb9..2bd2b65 100644 --- a/backend/plugins.py +++ b/backend/plugins.py @@ -271,6 +271,43 @@ async def execute_plugin(plugin_name: str, parent_node_id: str | None = None): await bus.publish("scene") return note.id + if name == "Conversation Node": + # R7.5a: ConversationNode has existed since R3.25 with zero + # creation path - add_conversation_node was only ever reachable + # from backend tests, never from a real UI action. Same + # "branch-point child, real valid parent required" posture as + # every real node-creation plugin above. + if not parent_node_id or parent_node_id not in canvas_document.nodes: + notifications.show( + "Please select a valid node to branch from before adding a Conversation Node.", + "warning", + ) + await bus.publish("notification") + return None + parent = canvas_document.nodes[parent_node_id] + node = canvas_document.add_conversation_node(parent.x, parent.y + MESSAGE_VERTICAL_SPACING, parent_node_id) + await bus.publish("scene") + return node.id + + if name == "HTML Renderer": + # R7.5a: HtmlViewNode has existed since R3.17 with zero creation + # path, same gap class as Conversation Node above. Starts with + # empty html_content - the same "create blank, then edit in + # place" posture add_note's System Prompt branch above and the + # plain "Add Note" command already use, since the plugin picker + # has no field to source initial HTML from. + if not parent_node_id or parent_node_id not in canvas_document.nodes: + notifications.show( + "Please select a valid node to branch from before adding an HTML Renderer node.", + "warning", + ) + await bus.publish("notification") + return None + parent = canvas_document.nodes[parent_node_id] + node = canvas_document.add_html_node(parent.x, parent.y + MESSAGE_VERTICAL_SPACING, "", parent_node_id) + await bus.publish("scene") + return node.id + notifications.show(f'"{name}" node creation lands in R3/R5.', "info") await bus.publish("notification") return None diff --git a/backend/tests/test_plugins.py b/backend/tests/test_plugins.py index e96370e..75fe540 100644 --- a/backend/tests/test_plugins.py +++ b/backend/tests/test_plugins.py @@ -2,8 +2,6 @@ import asyncio -import pytest - from backend.canvas import SceneDocument from backend.events import SessionBus from backend.notifications import NotificationState @@ -87,21 +85,32 @@ async def send_json(self, data): assert recorder.messages[0]["payload"]["categories"] -def test_execute_plugin_shows_info_notification_for_known_plugin(): - # "HTML Renderer" (not "Gitlink"/"Py-Coder"/"Execution Sandbox" - all - # three are now real node-creation plugins, see their own dedicated - # tests below) stands in for "any plugin name still on the - # honest-deferred-notice path". +def test_execute_plugin_falls_through_to_the_generic_deferred_notice_for_an_unhandled_registered_name(monkeypatch): + # R7.5a closed the last 2 gaps (Conversation Node, HTML Renderer) - every + # _PLUGINS entry today has its own real branch, so the generic fallback + # at the bottom of execute_plugin has no live entry left to exercise + # through the real registry. It stays in place as the established growth + # path for the NEXT plugin added to _PLUGINS before its own branch lands + # (same state every plugin above was once in) - proven still-correct + # here by monkeypatching _PLUGINS into exactly that state for one call. + import backend.plugins as plugins_module + + monkeypatch.setattr( + plugins_module, + "_PLUGINS", + plugins_module._PLUGINS + [("Future Plugin", "Not yet wired.", "Build & Execution")], + ) bus = SessionBus("plugins-exec-test") notifications = NotificationState() bus.register_topic("notification", notifications.payload) register_plugins(bus, notifications, SceneDocument()) - asyncio.run(bus.dispatch_intent("app-plugins", "executePlugin", ["HTML Renderer"])) + result = asyncio.run(bus.dispatch_intent("app-plugins", "executePlugin", ["Future Plugin"])) + + assert result is None assert notifications.visible is True assert notifications.msg_type == "info" - assert "HTML Renderer" in notifications.message - assert "R3" in notifications.message or "R5" in notifications.message + assert notifications.message == '"Future Plugin" node creation lands in R3/R5.' def test_execute_plugin_shows_warning_notification_for_unknown_plugin(): @@ -555,27 +564,147 @@ def test_execute_plugin_system_prompt_reuses_an_existing_note_instead_of_creatin assert sum(1 for n in canvas_document.nodes.values() if n.kind == "note") == 1 -# -- R5.1/R5.2/R5.3/R5.4/R6.1: non-regression - every OTHER plugin name is -# still an honest, unchanged deferred notice ---------------------------------- +# -- R7.5a: "Conversation Node" - the seventh real node-creation plugin ------ +# +# Existed as a real, working node kind since R3.25 (add_conversation_node) +# with zero UI-reachable creation path - execute_plugin had no branch for +# it at all, silently falling through to the generic deferred notice despite +# the node itself being fully functional. -@pytest.mark.parametrize( - "name", - [ - n for n in (p[0] for p in _PLUGINS) - if n not in ( - "Web Research", "Artifact / Drafter", "Gitlink", "Py-Coder", "Execution Sandbox", - "System Prompt", - ) - ], -) -def test_execute_plugin_every_other_plugin_name_still_shows_the_unchanged_deferred_notice(name): +def test_execute_plugin_conversation_node_requires_parent(): bus, notifications, canvas_document = _make_plugins_bus() - result = asyncio.run(bus.dispatch_intent("app-plugins", "executePlugin", [name])) + result = asyncio.run(bus.dispatch_intent("app-plugins", "executePlugin", ["Conversation Node"])) assert result is None assert notifications.visible is True - assert notifications.msg_type == "info" - assert notifications.message == f'"{name}" node creation lands in R3/R5.' - assert not any(n.kind == "web_research" for n in canvas_document.nodes.values()) + assert notifications.msg_type == "warning" + assert notifications.message == ( + "Please select a valid node to branch from before adding a Conversation Node." + ) + assert not any(n.kind == "conversation" for n in canvas_document.nodes.values()) + + +def test_execute_plugin_conversation_node_rejects_unknown_parent_id(): + bus, notifications, canvas_document = _make_plugins_bus() + + result = asyncio.run( + bus.dispatch_intent("app-plugins", "executePlugin", ["Conversation Node", "ghost-node-id"]) + ) + + assert result is None + assert notifications.visible is True + assert notifications.msg_type == "warning" + assert not any(n.kind == "conversation" for n in canvas_document.nodes.values()) + + +def test_execute_plugin_conversation_node_creates_a_real_conversation_node(): + bus, notifications, canvas_document = _make_plugins_bus() + parent = canvas_document.add_node(10, 20, "parent") + + class Recorder: + def __init__(self): + self.messages = [] + + async def send_json(self, data): + self.messages.append(data) + + def topics_seen(self): + return [m["topic"] for m in self.messages if m["kind"] == "state"] + + recorder = Recorder() + bus.attach(recorder) + + result = asyncio.run( + bus.dispatch_intent("app-plugins", "executePlugin", ["Conversation Node", parent.id]) + ) + + assert result is not None + node = canvas_document.nodes[result] + assert node.kind == "conversation" + assert node.title == "Conversation" + assert any( + e.source == parent.id and e.target == node.id for e in canvas_document.edges.values() + ) + assert notifications.visible is False, "success is not a deferral - no notification fires" + assert "scene" in recorder.topics_seen() + + +# -- R7.5a: "HTML Renderer" - the eighth real node-creation plugin ----------- +# +# Same gap class as Conversation Node above: add_html_node has existed since +# R3.17 with zero creation path. Starts with empty html_content since the +# plugin picker has no field to source initial HTML from - same "create +# blank, then edit in place" posture as "Add Note". + + +def test_execute_plugin_html_renderer_requires_parent(): + bus, notifications, canvas_document = _make_plugins_bus() + + result = asyncio.run(bus.dispatch_intent("app-plugins", "executePlugin", ["HTML Renderer"])) + + assert result is None + assert notifications.visible is True + assert notifications.msg_type == "warning" + assert notifications.message == ( + "Please select a valid node to branch from before adding an HTML Renderer node." + ) + assert not any(n.kind == "html" for n in canvas_document.nodes.values()) + + +def test_execute_plugin_html_renderer_rejects_unknown_parent_id(): + bus, notifications, canvas_document = _make_plugins_bus() + + result = asyncio.run( + bus.dispatch_intent("app-plugins", "executePlugin", ["HTML Renderer", "ghost-node-id"]) + ) + + assert result is None + assert notifications.visible is True + assert notifications.msg_type == "warning" + assert not any(n.kind == "html" for n in canvas_document.nodes.values()) + + +def test_execute_plugin_html_renderer_creates_a_real_html_node_with_empty_content(): + bus, notifications, canvas_document = _make_plugins_bus() + parent = canvas_document.add_node(10, 20, "parent") + + class Recorder: + def __init__(self): + self.messages = [] + + async def send_json(self, data): + self.messages.append(data) + + def topics_seen(self): + return [m["topic"] for m in self.messages if m["kind"] == "state"] + + recorder = Recorder() + bus.attach(recorder) + + result = asyncio.run( + bus.dispatch_intent("app-plugins", "executePlugin", ["HTML Renderer", parent.id]) + ) + + assert result is not None + node = canvas_document.nodes[result] + assert node.kind == "html" + assert node.content == "" + assert any( + e.source == parent.id and e.target == node.id for e in canvas_document.edges.values() + ) + assert notifications.visible is False, "success is not a deferral - no notification fires" + assert "scene" in recorder.topics_seen() + + +def test_every_plugin_now_has_a_real_creation_branch(): + # R7.5a closes the last 2 gaps - confirms explicitly (rather than + # letting the old parametrized non-regression test below silently + # collect zero cases) that every _PLUGINS entry has moved off the + # generic deferred notice. + handled = { + "Web Research", "Artifact / Drafter", "Gitlink", "Py-Coder", "Execution Sandbox", + "System Prompt", "Conversation Node", "HTML Renderer", + } + assert handled == {name for name, _description, _category in _PLUGINS} diff --git a/web_ui/src/app/canvas/ChatNodeView.test.tsx b/web_ui/src/app/canvas/ChatNodeView.test.tsx index 7df4129..4f5342c 100644 --- a/web_ui/src/app/canvas/ChatNodeView.test.tsx +++ b/web_ui/src/app/canvas/ChatNodeView.test.tsx @@ -1,9 +1,21 @@ import { ReactFlowProvider, type NodeProps } from "@xyflow/react"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ChatNodeView, makeDebouncedScrollReport, type ChatFlowNode } from "./ChatNodeView"; +// R7.5a: jsdom implements neither URL.createObjectURL nor +// URL.revokeObjectURL - same hand-installed-fakes pattern +// ImageNodeView.test.tsx's own Export Image tests already established. +beforeEach(() => { + URL.createObjectURL = vi.fn().mockReturnValue("blob:fake-object-url"); + URL.revokeObjectURL = vi.fn(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + // Rendered directly (not through a real mount): RF's // own node wrapper stays `visibility: hidden` in jsdom until its // ResizeObserver-driven measurement pass completes, and forcing that pass @@ -79,9 +91,7 @@ describe("ChatNodeView", () => { fireEvent.contextMenu(role); expect(screen.getByRole("menu")).toBeInTheDocument(); - const exportItem = screen.getByRole("menuitem", { name: "Export" }); - expect(exportItem).toBeDisabled(); - expect(exportItem).toHaveAttribute("title", "Export lands in R6"); + expect(screen.getByRole("menuitem", { name: "Export" })).not.toBeDisabled(); const hideBranches = screen.getByRole("menuitem", { name: "Hide Other Branches" }); expect(hideBranches).toBeDisabled(); expect(hideBranches).toHaveAttribute("title", "Branch visibility isn't built yet"); @@ -103,6 +113,30 @@ describe("ChatNodeView", () => { expect(onDelete).toHaveBeenCalledOnce(); }); + it("clicking Export downloads the raw content (not rendered markdown) as a .md file, then closes the menu (R7.5a)", async () => { + const user = userEvent.setup(); + renderChatNode({ content: "Hello **world**" }); + + const captured: { anchor?: HTMLAnchorElement } = {}; + const clickSpy = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(function (this: HTMLAnchorElement) { + captured.anchor = this; + }); + + fireEvent.contextMenu(screen.getByText("You")); + await user.click(screen.getByRole("menuitem", { name: "Export" })); + + await waitFor(() => expect(clickSpy).toHaveBeenCalled()); + expect(URL.createObjectURL).toHaveBeenCalled(); + const blobArg = (URL.createObjectURL as ReturnType).mock.calls[0][0] as Blob; + expect(await blobArg.text()).toBe("Hello **world**"); + expect(captured.anchor?.getAttribute("href")).toBe("blob:fake-object-url"); + expect(captured.anchor?.getAttribute("download")).toBe("chat-n0.md"); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:fake-object-url"); + expect(screen.queryByRole("menu")).toBeNull(); // onClose fires after Export + }); + it("Regenerate Response only appears for assistant messages, matching the legacy is_user guard", () => { renderChatNode({ isUser: true }); fireEvent.contextMenu(screen.getByText("You")); diff --git a/web_ui/src/app/canvas/ChatNodeView.tsx b/web_ui/src/app/canvas/ChatNodeView.tsx index fc690b5..0992748 100644 --- a/web_ui/src/app/canvas/ChatNodeView.tsx +++ b/web_ui/src/app/canvas/ChatNodeView.tsx @@ -4,6 +4,7 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeHighlight from "rehype-highlight"; import { CHAT_SCROLL_REPORT_DEBOUNCE_MS, LOD_ZOOM_THRESHOLD } from "./canvasConstants"; +import { downloadTextFile } from "./downloadTextFile"; /** * The chat node (Qt-removal plan R3.1/R3.2) - ChatNode's React successor: @@ -14,9 +15,9 @@ import { CHAT_SCROLL_REPORT_DEBOUNCE_MS, LOD_ZOOM_THRESHOLD } from "./canvasCons * (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), Key Takeaway/Explainer Note - * generation (R4, same agent-layer blocker), Export (R6 session/export - * work), Open Document View (the document-viewer island isn't wired into the - * SPA overlay system yet), and Hide Other Branches (the legacy scene's + * generation (R4, same agent-layer blocker), 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 - * unscoped, not owned by any R-phase). One legacy item is still deliberately * NOT listed even as disabled: "Generate Group Summary" is itself @@ -41,7 +42,11 @@ import { CHAT_SCROLL_REPORT_DEBOUNCE_MS, LOD_ZOOM_THRESHOLD } from "./canvasCons * each dispatching the real generateChart intent with this node as the * parent. Key Takeaway/Explainer Note remain honestly deferred (still no * agent-layer support of their own) - the stale "Chart" mention in their old - * shared R4-blocker note above has been removed accordingly. + * shared R4-blocker note above has been removed accordingly. "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 - + * frontend-only, no backend involved, since the content is already in + * memory client-side. * * R6.3: the node's own scroll position within .chat-node-content (its * scrollable markdown body) is now restored on mount and reported @@ -90,6 +95,7 @@ const CHART_TYPE_OPTIONS: { value: string; label: string }[] = [ function ChatNodeMenu({ position, + nodeId, content, isUser, isCollapsed, @@ -103,6 +109,7 @@ function ChatNodeMenu({ onClose, }: { position: MenuPosition; + nodeId: string; content: string; isUser: boolean; isCollapsed: boolean; @@ -162,7 +169,14 @@ function ChatNodeMenu({ > {isCollapsed ? "Expand" : "Collapse"} - -