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:
committed by
brooklyn!
parent
b7bed2419e
commit
74f99af470
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
63
tests/tools/test_apply_layout_tool.py
Normal file
63
tests/tools/test_apply_layout_tool.py
Normal 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
|
||||
74
tools/apply_layout_tool.py
Normal file
74
tools/apply_layout_tool.py
Normal 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="🧱",
|
||||
)
|
||||
Reference in New Issue
Block a user