Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions web_ui/src/app/canvas/ChatNodeView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function renderChatNode(overrides: Partial<ChatFlowNode["data"]> = {}) {
const onGenerateChart = vi.fn();
const onGenerateKeyTakeaway = vi.fn();
const onGenerateExplainerNote = vi.fn();
const onOpenDocumentView = vi.fn();
const onScrollChange = vi.fn();
const props = {
id: "n0",
Expand All @@ -51,6 +52,7 @@ function renderChatNode(overrides: Partial<ChatFlowNode["data"]> = {}) {
onGenerateChart,
onGenerateKeyTakeaway,
onGenerateExplainerNote,
onOpenDocumentView,
onScrollChange,
...overrides,
},
Expand All @@ -64,7 +66,7 @@ function renderChatNode(overrides: Partial<ChatFlowNode["data"]> = {}) {
return {
onToggleCollapse, onDelete, onUndockChild, onRegenerate, onGenerateImage,
onGenerateChart, onGenerateKeyTakeaway, onGenerateExplainerNote,
onScrollChange, container,
onOpenDocumentView, onScrollChange, container,
};
}

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 });
Expand Down
24 changes: 19 additions & 5 deletions web_ui/src/app/canvas/ChatNodeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,8 @@
* 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
Expand Down Expand Up @@ -46,6 +44,9 @@
* 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 -
Expand Down Expand Up @@ -76,6 +77,7 @@
onGenerateChart: (chartType: string) => void;
onGenerateKeyTakeaway: () => void;
onGenerateExplainerNote: () => void;
onOpenDocumentView: () => void;
onScrollChange: (value: number) => void;
}

Expand Down Expand Up @@ -114,6 +116,7 @@
onGenerateChart,
onGenerateKeyTakeaway,
onGenerateExplainerNote,
onOpenDocumentView,
onClose,
}: {
position: MenuPosition;
Expand All @@ -130,6 +133,7 @@
onGenerateChart: (chartType: string) => void;
onGenerateKeyTakeaway: () => void;
onGenerateExplainerNote: () => void;
onOpenDocumentView: () => void;
onClose: () => void;
}) {
const [chartMenuOpen, setChartMenuOpen] = useState(false);
Expand Down Expand Up @@ -192,7 +196,16 @@
))}
</>
)}
<button type="button" role="menuitem" disabled title="Document view integration isn't wired into the SPA yet">
{/* R8a: real. Opens the shared DocumentViewDialog (frontend-only, no
backend intent) with this node's own content - already client-side. */}
<button
type="button"
role="menuitem"
onClick={() => {
onOpenDocumentView();
onClose();
}}
>
Open Document View
</button>
{/* R8a: real. Each runs its agent over THIS node's text and drops the
Expand Down Expand Up @@ -292,7 +305,7 @@
* ChartNodeView.tsx's makeDebouncedChartResize / HtmlNodeView.tsx's
* makeDebouncedSplitterReport, exported standalone for the same direct-unit-
* testability reason. */
export function makeDebouncedScrollReport(

Check warning on line 308 in web_ui/src/app/canvas/ChatNodeView.tsx

View workflow job for this annotation

GitHub Actions / Frontend checks (npm run check)

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
timerRef: { current: ReturnType<typeof setTimeout> | null },
onScrollChange: (value: number) => void,
debounceMs: number = CHAT_SCROLL_REPORT_DEBOUNCE_MS,
Expand Down Expand Up @@ -388,6 +401,7 @@
onGenerateChart={data.onGenerateChart}
onGenerateKeyTakeaway={data.onGenerateKeyTakeaway}
onGenerateExplainerNote={data.onGenerateExplainerNote}
onOpenDocumentView={data.onOpenDocumentView}
onClose={() => setMenuPosition(null)}
/>
)}
Expand Down
25 changes: 19 additions & 6 deletions web_ui/src/app/canvas/ConversationNodeView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ function renderConversationNode(overrides: Partial<ConversationFlowNode["data"]>
const onSend = vi.fn();
const onDeleteMessage = vi.fn();
const onCancel = vi.fn();
const onOpenDocumentView = vi.fn();
const props = {
id: "n0",
selected: false,
Expand All @@ -28,6 +29,7 @@ function renderConversationNode(overrides: Partial<ConversationFlowNode["data"]>
onSend,
onDeleteMessage,
onCancel,
onOpenDocumentView,
...overrides,
},
} as unknown as NodeProps<ConversationFlowNode>;
Expand All @@ -37,7 +39,7 @@ function renderConversationNode(overrides: Partial<ConversationFlowNode["data"]>
<ConversationNodeView {...props} />
</ReactFlowProvider>,
);
return { onToggleCollapse, onDelete, onSend, onDeleteMessage, onCancel };
return { onToggleCollapse, onDelete, onSend, onDeleteMessage, onCancel, onOpenDocumentView };
}

// Directly sets the React Flow internal Zustand store's transform/zoom
Expand All @@ -61,6 +63,7 @@ function renderConversationNodeAtZoom(zoom: number, overrides: Partial<Conversat
const onSend = vi.fn();
const onDeleteMessage = vi.fn();
const onCancel = vi.fn();
const onOpenDocumentView = vi.fn();
const props = {
id: "n0",
selected: false,
Expand All @@ -73,6 +76,7 @@ function renderConversationNodeAtZoom(zoom: number, overrides: Partial<Conversat
onSend,
onDeleteMessage,
onCancel,
onOpenDocumentView,
...overrides,
},
} as unknown as NodeProps<ConversationFlowNode>;
Expand Down Expand Up @@ -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();

Expand All @@ -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();
Expand All @@ -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
Expand Down
28 changes: 21 additions & 7 deletions web_ui/src/app/canvas/ConversationNodeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -56,6 +56,7 @@ export interface ConversationNodeData extends Record<string, unknown> {
onSend: (text: string) => void;
onDeleteMessage: (index: number) => void;
onCancel: () => void;
onOpenDocumentView: () => void;
}

export type ConversationFlowNode = Node<ConversationNodeData, "conversation">;
Expand All @@ -74,12 +75,14 @@ function ConversationNodeMenu({
isCollapsed,
onToggleCollapse,
onDelete,
onOpenDocumentView,
onClose,
}: {
position: MenuPosition;
isCollapsed: boolean;
onToggleCollapse: () => void;
onDelete: () => void;
onOpenDocumentView: () => void;
onClose: () => void;
}) {

Expand All @@ -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. */}
<button type="button" role="menuitem" disabled title="Document view integration isn't wired into the SPA yet">
{/* R8a: real. Opens the shared DocumentViewDialog (frontend-only, no
backend intent) with this node's entire history formatted as a
numbered markdown transcript. */}
<button
type="button"
role="menuitem"
onClick={() => {
onOpenDocumentView();
onClose();
}}
>
Open Document View
</button>
<button
Expand Down Expand Up @@ -304,6 +317,7 @@ export function ConversationNodeView({ data, selected }: NodeProps<ConversationF
isCollapsed={data.isCollapsed}
onToggleCollapse={data.onToggleCollapse}
onDelete={data.onDelete}
onOpenDocumentView={data.onOpenDocumentView}
onClose={() => setMenuPosition(null)}
/>
)}
Expand Down
65 changes: 65 additions & 0 deletions web_ui/src/app/canvas/DocumentViewDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { DocumentViewDialog } from "./DocumentViewDialog";
import { OverlayProvider, useOverlays } from "../overlays/overlays";

// R8a: Document View dialog tests - mirrors the OverlayProvider + trigger
// pattern from SettingsDialog.test.tsx (see that file's OpenDocumentView
// helper's sibling, OpenSettingsButton), since Dialog only mounts its
// content while its named surface is the open one.

function OpenDocumentViewButton() {
const overlays = useOverlays();
return (
<button type="button" onClick={() => overlays.open("document-view", "dialog")}>
open document view
</button>
);
}

function setup(content: string | null) {
const user = userEvent.setup();
render(
<OverlayProvider>
<OpenDocumentViewButton />
<DocumentViewDialog content={content} />
</OverlayProvider>,
);
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();
});
});
Loading
Loading