diff --git a/backend/canvas.py b/backend/canvas.py index 4a7116d..1ab30bb 100644 --- a/backend/canvas.py +++ b/backend/canvas.py @@ -2514,6 +2514,22 @@ def set_chat_collapsed(self, node_id: str, collapsed: bool) -> None: if node.kind in ("frame", "container"): self._recompute_group_bounds(node_id) + def set_all_conversational_collapsed(self, collapsed: bool) -> None: + """R7.5e: Collapse All / Expand All - the bulk counterpart to + set_chat_collapsed above. Mirrors legacy's + graphlink_window_navigation.py collapse_all/expand_all exactly: + those iterate ONLY scene._all_conversational_nodes() (chat + + conversation + html_view nodes) and call set_collapsed(bool) on + each - NOT code/document/image/thinking/chart nodes, and NOT + frame/container groups (whose is_collapsed drives derived geometry + via _recompute_group_bounds, same carve-out set_chat_collapsed's + own comment documents - a frame/container never opts into this + bulk op, only into the per-node setter above).""" + collapsed = bool(collapsed) + for node in self.nodes.values(): + if node.kind in ("chat", "conversation", "html"): + node.is_collapsed = collapsed + def set_chat_scroll_value(self, node_id: str, value: float) -> None: """R6.3: persists a chat node's own scroll position within its content area. chat kind only (SceneError otherwise), matching every @@ -3135,6 +3151,14 @@ async def set_chat_collapsed(node_id, collapsed): document.set_chat_collapsed(node_id, collapsed) await publish_scene() + async def collapse_all_nodes(): + document.set_all_conversational_collapsed(True) + await publish_scene() + + async def expand_all_nodes(): + document.set_all_conversational_collapsed(False) + await publish_scene() + async def set_chat_scroll_value(node_id, value): document.set_chat_scroll_value(node_id, value) await publish_scene() @@ -4044,6 +4068,8 @@ async def set_view_state(zoom_factor, scroll_x, scroll_y): bus.register_intent("scene", "setNodeDocked", set_node_docked) bus.register_intent("scene", "deleteChatNode", delete_chat_node) bus.register_intent("scene", "setChatCollapsed", set_chat_collapsed) + bus.register_intent("scene", "collapseAllNodes", collapse_all_nodes) + bus.register_intent("scene", "expandAllNodes", expand_all_nodes) bus.register_intent("scene", "setChatScrollValue", set_chat_scroll_value) bus.register_intent("scene", "sendMessage", send_message) bus.register_intent("scene", "regenerateResponse", regenerate_response) diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index 6c66b3f..814e3fb 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -5776,3 +5776,111 @@ def test_scene_payload_content_parts_is_none_not_empty_list_when_unset(): payload_node = next(n for n in doc.scene_payload()["nodes"] if n["id"] == chat_node.id) assert payload_node["contentParts"] is None + + +# -- R7.5e: Collapse All / Expand All ----------------------------------------- + + +def test_set_all_conversational_collapsed_only_touches_chat_conversation_html_nodes(): + doc = SceneDocument() + chat_node = doc.add_chat_node(0, 0, "hi", True) + conversation_node = doc.add_conversation_node(0, 160, chat_node.id) + html_node = doc.add_html_node(0, 320, "

hi

", chat_node.id) + code_node = doc.add_code_node(0, 480, "x = 1", "python", parent_id=chat_node.id) + document_node = doc.add_document_node( + 0, 640, "file.txt", "contents", "document", chat_node.id + ) + frame = doc.create_frame([code_node.id]) + + doc.set_all_conversational_collapsed(True) + + assert doc.nodes[chat_node.id].is_collapsed is True + assert doc.nodes[conversation_node.id].is_collapsed is True + assert doc.nodes[html_node.id].is_collapsed is True + # Untouched kinds - completely unaffected, not just coincidentally False. + assert doc.nodes[code_node.id].is_collapsed is False + assert doc.nodes[document_node.id].is_collapsed is False + assert doc.nodes[frame.id].is_collapsed is False + + +def test_set_all_conversational_collapsed_expand_does_not_clobber_a_collapsed_frame(): + doc = SceneDocument() + chat_node = doc.add_chat_node(0, 0, "hi", True) + conversation_node = doc.add_conversation_node(0, 160, chat_node.id) + html_node = doc.add_html_node(0, 320, "

hi

", chat_node.id) + code_node = doc.add_code_node(0, 480, "x = 1", "python", parent_id=chat_node.id) + document_node = doc.add_document_node( + 0, 640, "file.txt", "contents", "document", chat_node.id + ) + frame = doc.create_frame([code_node.id]) + + doc.set_all_conversational_collapsed(True) + # The frame's own collapse mechanism - independent of the bulk op above, + # which must never have touched it (it started False, per the previous + # test). + doc.toggle_group_collapsed(frame.id) + assert doc.nodes[frame.id].is_collapsed is True + + doc.set_all_conversational_collapsed(False) + + assert doc.nodes[chat_node.id].is_collapsed is False + assert doc.nodes[conversation_node.id].is_collapsed is False + assert doc.nodes[html_node.id].is_collapsed is False + # Proves expand-all did not clobber the frame: still True afterward. + assert doc.nodes[frame.id].is_collapsed is True + # document/code were never collapsed by either bulk call - still False. + assert doc.nodes[code_node.id].is_collapsed is False + assert doc.nodes[document_node.id].is_collapsed is False + + +def test_collapse_all_nodes_intent_collapses_eligible_nodes_and_publishes_once(): + async def run(): + bus, document, recorder = make_bus() + chat_id = await bus.dispatch_intent("scene", "addChatNode", [0, 0, "hi", True]) + conversation_id = await bus.dispatch_intent( + "scene", "addConversationNode", [0, 160, chat_id] + ) + recorder.messages.clear() + + await bus.dispatch_intent("scene", "collapseAllNodes", []) + + assert document.nodes[chat_id].is_collapsed is True + assert document.nodes[conversation_id].is_collapsed is True + assert recorder.topics_seen().count("scene") == 1, "one publish, not one per node" + + asyncio.run(run()) + + +def test_expand_all_nodes_intent_expands_eligible_nodes_and_publishes_once(): + async def run(): + bus, document, recorder = make_bus() + chat_id = await bus.dispatch_intent("scene", "addChatNode", [0, 0, "hi", True]) + conversation_id = await bus.dispatch_intent( + "scene", "addConversationNode", [0, 160, chat_id] + ) + await bus.dispatch_intent("scene", "setChatCollapsed", [chat_id, True]) + await bus.dispatch_intent("scene", "setChatCollapsed", [conversation_id, True]) + recorder.messages.clear() + + await bus.dispatch_intent("scene", "expandAllNodes", []) + + assert document.nodes[chat_id].is_collapsed is False + assert document.nodes[conversation_id].is_collapsed is False + assert recorder.topics_seen().count("scene") == 1, "one publish, not one per node" + + asyncio.run(run()) + + +def test_collapse_all_and_expand_all_intents_on_an_empty_scene_still_publish_once(): + async def run(): + bus, document, recorder = make_bus() + recorder.messages.clear() + + await bus.dispatch_intent("scene", "collapseAllNodes", []) + assert recorder.topics_seen().count("scene") == 1 + + recorder.messages.clear() + await bus.dispatch_intent("scene", "expandAllNodes", []) + assert recorder.topics_seen().count("scene") == 1 + + asyncio.run(run()) diff --git a/web_ui/src/app/canvas/sceneStore.test.ts b/web_ui/src/app/canvas/sceneStore.test.ts index 525ebc1..120868a 100644 --- a/web_ui/src/app/canvas/sceneStore.test.ts +++ b/web_ui/src/app/canvas/sceneStore.test.ts @@ -188,6 +188,20 @@ describe("SceneStore", () => { ]); }); + it("collapseAllNodes sends the scene-topic collapseAllNodes intent with no args", () => { + const { transport, intents } = makeFakeTransport(); + const store = new SceneStore(transport); + store.collapseAllNodes(); + expect(intents).toEqual([{ topic: "scene", intent: "collapseAllNodes", args: [] }]); + }); + + it("expandAllNodes sends the scene-topic expandAllNodes intent with no args", () => { + const { transport, intents } = makeFakeTransport(); + const store = new SceneStore(transport); + store.expandAllNodes(); + expect(intents).toEqual([{ topic: "scene", intent: "expandAllNodes", args: [] }]); + }); + it("sends code-node intents with the backend's registered names and shapes", () => { const { transport, intents } = makeFakeTransport(); const store = new SceneStore(transport); diff --git a/web_ui/src/app/canvas/sceneStore.ts b/web_ui/src/app/canvas/sceneStore.ts index 7a2c224..ecaf89c 100644 --- a/web_ui/src/app/canvas/sceneStore.ts +++ b/web_ui/src/app/canvas/sceneStore.ts @@ -164,6 +164,20 @@ export class SceneStore { this.transport.intent("scene", "setChatCollapsed", [id, collapsed]); } + // R7.5e: legacy's "Collapse All Nodes"/"Expand All Nodes" (graphlink_ + // window_navigation.py:12-13) - a bulk is_collapsed change restricted + // server-side to chat/conversation/html-kind nodes only. Same plain + // fire-and-forget shape as setChatCollapsed above and every other no-arg + // scene intent (organizeNodes, ...): the new is_collapsed values arrive + // through the next scene snapshot, nothing synchronous needed here. + collapseAllNodes(): void { + this.transport.intent("scene", "collapseAllNodes", []); + } + + expandAllNodes(): void { + this.transport.intent("scene", "expandAllNodes", []); + } + // R3.9/R3.10: real document nodes (attachments). Unlike chat/code, // parentId is REQUIRED - the backend's add_document_node signature has no // default for it (a document node can never exist without a parent chat diff --git a/web_ui/src/app/chrome/commands.test.ts b/web_ui/src/app/chrome/commands.test.ts index ca07e5c..54853ad 100644 --- a/web_ui/src/app/chrome/commands.test.ts +++ b/web_ui/src/app/chrome/commands.test.ts @@ -29,6 +29,8 @@ function makeStore( createContainer: vi.fn(), newChat: vi.fn(), saveChat: vi.fn(), + collapseAllNodes: vi.fn(), + expandAllNodes: vi.fn(), }; } @@ -156,6 +158,33 @@ describe("buildCommands", () => { expect(store.createContainer).toHaveBeenCalledWith(["n0", "n1"]); }); + it("collapse-all-nodes/expand-all-nodes are disabled when the scene has zero chat/conversation/html nodes, even with other node kinds present", () => { + const store = makeStore([ + { id: "n0", x: 0, y: 0, title: "A", kind: "code" }, + { id: "n1", x: 0, y: 0, title: "B", kind: "frame" }, + ]); + // @ts-expect-error - test double + const commands = buildCommands(store, makeRf(), makeOverlays()); + expect(commands.find((c) => c.id === "collapse-all-nodes")!.enabled()).toBe(false); + expect(commands.find((c) => c.id === "expand-all-nodes")!.enabled()).toBe(false); + }); + + it("collapse-all-nodes/expand-all-nodes enable once a chat node exists and each run() calls the matching store method once", () => { + const store = makeStore([{ id: "n0", x: 0, y: 0, title: "A", kind: "chat" }]); + // @ts-expect-error - test double + const commands = buildCommands(store, makeRf(), makeOverlays()); + + const collapseAll = commands.find((c) => c.id === "collapse-all-nodes")!; + expect(collapseAll.enabled()).toBe(true); + collapseAll.run(); + expect(store.collapseAllNodes).toHaveBeenCalledTimes(1); + + const expandAll = commands.find((c) => c.id === "expand-all-nodes")!; + expect(expandAll.enabled()).toBe(true); + expandAll.run(); + expect(store.expandAllNodes).toHaveBeenCalledTimes(1); + }); + it("new-chat is always enabled and calls store.newChat (R7.5a)", () => { // Empty scene -> R7.5c's confirm is skipped entirely, so this still // reaches newChat without any modal. diff --git a/web_ui/src/app/chrome/commands.ts b/web_ui/src/app/chrome/commands.ts index 94518ba..d2d84cc 100644 --- a/web_ui/src/app/chrome/commands.ts +++ b/web_ui/src/app/chrome/commands.ts @@ -71,6 +71,12 @@ export function buildCommands( // cannot answer a "2+ selected" question). const selectedNodeIds = () => rf.getNodes().filter((n) => n.selected).map((n) => n.id); const hasMultiSelection = () => selectedNodeIds().length >= 2; + // R7.5e: Collapse All/Expand All are gated on scene content, not selection + // (legacy enabled them whenever at least one eligible node existed, + // regardless of what was selected) - restricted to the three kinds the + // backend's collapseAllNodes/expandAllNodes intents actually touch. + const hasCollapsibleNodes = () => + store.getScene().nodes.some((n) => n.kind === "chat" || n.kind === "conversation" || n.kind === "html"); return [ { @@ -256,5 +262,25 @@ export function buildCommands( run: () => store.createContainer(selectedNodeIds()), enabled: hasMultiSelection, }, + { + // R7.5e: legacy's "Collapse All Nodes" (graphlink_window_navigation.py:12), + // alias "fold all" - enabled only when at least one chat/conversation/ + // html node exists, same posture as create-frame/create-container's own + // content-shaped gate above. + id: "collapse-all-nodes", + name: "Collapse All Nodes", + aliases: ["fold all"], + run: () => store.collapseAllNodes(), + enabled: hasCollapsibleNodes, + }, + { + // R7.5e: legacy's "Expand All Nodes" (graphlink_window_navigation.py:13), + // alias "unfold all". + id: "expand-all-nodes", + name: "Expand All Nodes", + aliases: ["unfold all"], + run: () => store.expandAllNodes(), + enabled: hasCollapsibleNodes, + }, ]; }