From 74f99af470ae8ce47f0903cf431d106cecbd37f2 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 18 Aug 2026 20:57:12 -0500 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20agent-applied=20layout=20prese?= =?UTF-8?q?ts=20=E2=80=94=20apply=5Flayout=20joins=20the=20desktop=5Fui=20?= =?UTF-8?q?toolset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent could reveal single panes (focus_pane) but had no way to arrange the workspace as one act. apply_layout closes that gap: a desktop_ui tool that emits layout.apply over the existing bridge, resolved in the renderer against the layouts contribution registry — the same list the layout picker reads — so core presets (default/focus/terminal-deck/quad), plugin presets, and user-saved presets are all addressable by id. Active session only, same as pane.reveal: a background turn never rearranges the user's desktop. --- .../hooks/use-message-stream/gateway-event.ts | 10 ++- apps/desktop/src/lib/chat-messages.ts | 2 + apps/desktop/src/store/pane-focus.ts | 26 +++++++ tests/tools/test_apply_layout_tool.py | 63 ++++++++++++++++ tools/apply_layout_tool.py | 74 +++++++++++++++++++ 5 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 tests/tools/test_apply_layout_tool.py create mode 100644 tools/apply_layout_tool.py diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index df6e4c5b88..abc364337c 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -49,7 +49,7 @@ import { setMcpSetupRequest } from '@/store/mcp-setup' import { dispatchNativeNotification } from '@/store/native-notifications' import { isDiskFullErrorMessage, notify, notifyError } from '@/store/notifications' import { requestDesktopOnboarding, requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding' -import { revealDesktopPane } from '@/store/pane-focus' +import { applyDesktopLayoutPreset, revealDesktopPane } from '@/store/pane-focus' import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet' import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' import { followActiveSessionCwd } from '@/store/projects' @@ -1462,6 +1462,14 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { if (isActiveEvent) { revealDesktopPane(payload?.pane ?? '') } + } else if (event.type === 'layout.apply') { + // Agent applied a layout preset via the desktop-gated apply_layout + // tool. Same contract as pane.reveal: active session only, and the + // preset resolves against the SAME layouts registry the picker reads, + // so core, plugin, and user presets are all addressable. + if (isActiveEvent) { + applyDesktopLayoutPreset(typeof payload?.preset === 'string' ? payload.preset : '') + } } else if (event.type === 'message.reaction') { // The agent reacted to a message via the desktop-gated // react_to_message tool. Already persisted — this only paints it now diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index ea33f0c0af..c580295276 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -116,6 +116,8 @@ export type GatewayEventPayload = { kind?: string // pane.reveal (agent focusing a desktop pane via the focus_pane tool) pane?: string + // layout.apply (agent applying a layout preset via the apply_layout tool) + preset?: string // message.reaction (agent reacting via the react_to_message tool) — the // durable messages.id, that row's full reaction list after the write, and // the row's role so a live (not-yet-round-tripped) message can be matched. diff --git a/apps/desktop/src/store/pane-focus.ts b/apps/desktop/src/store/pane-focus.ts index 2b5c99e4ac..46c1a34dfe 100644 --- a/apps/desktop/src/store/pane-focus.ts +++ b/apps/desktop/src/store/pane-focus.ts @@ -1,5 +1,8 @@ import { setTerminalTakeover } from '@/app/right-sidebar/store' +import { isLayoutNode, type LayoutNode } from '@/components/pane-shell/tree/model' +import { applyLayoutPreset, LAYOUTS_AREA } from '@/components/pane-shell/tree/presets' import { revealTreePane } from '@/components/pane-shell/tree/store' +import { registry } from '@/contrib/registry' import { setFileBrowserOpen, setSidebarOpen } from './layout' import { openReview } from './review' @@ -28,3 +31,26 @@ export function revealDesktopPane(pane: string): boolean { return true } + +/** Apply a layout preset by id, resolved against the layouts contribution + * registry — the SAME list the layout picker renders, so core presets + * (default/focus/terminal-deck/quad), plugin presets, and user-saved presets + * are all addressable by the backend `apply_layout` tool. Returns false for + * an unknown id. */ +export function applyDesktopLayoutPreset(preset: string): boolean { + if (!preset) { + return false + } + + const entry = registry + .getArea(LAYOUTS_AREA) + .find(candidate => candidate.id === preset && isLayoutNode(candidate.data)) + + if (!entry) { + return false + } + + applyLayoutPreset(entry.id, entry.data as LayoutNode) + + return true +} diff --git a/tests/tools/test_apply_layout_tool.py b/tests/tools/test_apply_layout_tool.py new file mode 100644 index 0000000000..013c2f30d4 --- /dev/null +++ b/tests/tools/test_apply_layout_tool.py @@ -0,0 +1,63 @@ +"""Tests for the GUI-surface ``apply_layout`` tool.""" + +import json + +import pytest + +from tools import apply_layout_tool as al, desktop_ui +from tools.registry import registry + + +@pytest.fixture(autouse=True) +def _reset_emitter(): + desktop_ui.set_emitter(None) + yield + desktop_ui.set_emitter(None) + + +def test_lives_in_the_gui_surface_toolset(monkeypatch): + """Surface eligibility is the toolset's job, not a process env var — the + desktop client can be driving a remote/cloud backend that never sees + HERMES_DESKTOP.""" + monkeypatch.delenv("HERMES_DESKTOP", raising=False) + entry = registry.get_entry("apply_layout") + + assert entry is not None + assert entry.toolset == "desktop_ui" + assert entry.check_fn is None + + +def test_emits_layout_apply(): + calls = [] + desktop_ui.set_emitter(lambda sid, event, payload: calls.append((event, payload))) + + out = json.loads(al.apply_layout_tool(" focus ")) + + assert out == {"success": True, "preset": "focus"} + assert calls == [("layout.apply", {"preset": "focus"})] + + +def test_preset_ids_pass_through_unmapped(): + """Ids are free-form: plugin/user presets must not be filtered by an enum + the backend can't know about.""" + calls = [] + desktop_ui.set_emitter(lambda sid, event, payload: calls.append((event, payload))) + + out = json.loads(al.apply_layout_tool("user-research-cockpit")) + + assert out["success"] is True + assert calls[0][1] == {"preset": "user-research-cockpit"} + + +def test_empty_preset_is_an_error(): + desktop_ui.set_emitter(lambda sid, event, payload: None) + + out = al.apply_layout_tool(" ") + + assert "preset is required" in out + + +def test_reports_desktop_only_without_emitter(): + out = al.apply_layout_tool("focus") + + assert "desktop app" in out diff --git a/tools/apply_layout_tool.py b/tools/apply_layout_tool.py new file mode 100644 index 0000000000..b1eb4ab32b --- /dev/null +++ b/tools/apply_layout_tool.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Apply a layout preset in the Hermes desktop GUI. + +Lives in the ``desktop_ui`` toolset (like ``focus_pane``), which the GUI +gateway enables only for desktop-sourced sessions. Emits ``layout.apply`` +through the shared ``desktop_ui`` bridge; the renderer resolves the preset id +against its layouts registry (core presets, plugin presets, and user-saved +presets are all the same list) and applies the tree through the exact code +path the layout picker uses. Only the active window's session may act — a +background turn never rearranges the user's desktop. + +Preset ids are free-form on purpose: plugins and users mint their own. The +renderer answers with the applied preset's id/title on success and the list +of available ids when the id is unknown, so the model can self-correct +without a second registry-listing tool. +""" + +import json + +from tools import desktop_ui +from tools.registry import registry, tool_error + +# Renderer answer arrives via the blocking-prompt bridge with this timeout; +# applying a layout is synchronous in the renderer, so this is generous. +_TIMEOUT_NOTE = "Layout apply is only available in the Hermes desktop app." + + +def apply_layout_tool(preset: str) -> str: + """Ask the desktop GUI to apply layout preset ``preset``.""" + name = (preset or "").strip() + if not name: + return tool_error("preset is required — a layout preset id, e.g. 'default' or 'focus'.") + + try: + ok = desktop_ui.emit("layout.apply", {"preset": name}) + except Exception as exc: + return tool_error(f"Failed to apply layout '{name}': {exc}") + if not ok: + return tool_error(_TIMEOUT_NOTE) + + return json.dumps({"success": True, "preset": name}, ensure_ascii=False) + + +APPLY_LAYOUT_SCHEMA = { + "name": "apply_layout", + "description": ( + "Apply a saved layout preset to the Hermes desktop app when the user asks to " + "rearrange the workspace — e.g. \"set up my layout for coding\", \"give me a " + "focused view\", \"put the terminal front and center\". Built-in presets: " + "default (chat + sidebars), focus (chat only), terminal-deck (terminal " + "forward), quad (four zones). Plugin and user-saved presets are addressed by " + "their id. To reveal a single pane without rearranging everything, use " + "focus_pane instead." + ), + "parameters": { + "type": "object", + "properties": { + "preset": { + "type": "string", + "description": "Layout preset id to apply (e.g. 'default', 'focus', 'terminal-deck', 'quad', or a user/plugin preset id).", + }, + }, + "required": ["preset"], + }, +} + + +registry.register( + name="apply_layout", + toolset="desktop_ui", + schema=APPLY_LAYOUT_SCHEMA, + handler=lambda args, **kw: apply_layout_tool(preset=args.get("preset", "")), + emoji="🧱", +)