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
26 changes: 26 additions & 0 deletions backend/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
108 changes: 108 additions & 0 deletions backend/tests/test_canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, "<p>hi</p>", 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, "<p>hi</p>", 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())
14 changes: 14 additions & 0 deletions web_ui/src/app/canvas/sceneStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions web_ui/src/app/canvas/sceneStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions web_ui/src/app/chrome/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ function makeStore(
createContainer: vi.fn(),
newChat: vi.fn(),
saveChat: vi.fn(),
collapseAllNodes: vi.fn(),
expandAllNodes: vi.fn(),
};
}

Expand Down Expand Up @@ -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.
Expand Down
26 changes: 26 additions & 0 deletions web_ui/src/app/chrome/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
{
Expand Down Expand Up @@ -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,
},
];
}
Loading