feat(desktop): agent-applied layout presets — apply_layout joins the desktop_ui toolset

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.
This commit is contained in:
Brooklyn Nicholson
2026-08-18 20:57:12 -05:00
committed by brooklyn!
parent b7bed2419e
commit 74f99af470
5 changed files with 174 additions and 1 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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

View File

@@ -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="🧱",
)